1. Alex Willmer
  2. trac-ticketlinks

Commits

rblank  committed 55afe6a

0.11-stable: Backported the Subversion merge property renderers from trunk.

Part of #7715.

  • Participants
  • Parent commits e235353
  • Branches 0.11-stable

Comments (0)

Files changed (10)

File setup.py

View file
         trac.ticket.web_ui = trac.ticket.web_ui
         trac.timeline = trac.timeline.web_ui
         trac.versioncontrol.svn_fs = trac.versioncontrol.svn_fs
+        trac.versioncontrol.svn_prop = trac.versioncontrol.svn_prop
         trac.versioncontrol.web_ui = trac.versioncontrol.web_ui
         trac.web.auth = trac.web.auth
         trac.wiki.interwiki = trac.wiki.interwiki

File trac/htdocs/css/browser.css

View file
  margin: 0 0 .4em 1.6em;
  padding: 0;
 }
-#info .props li { padding: 0; overflow: auto; }
+#info .props > li { padding: 2px 0; overflow: auto; }
+.trac-toggledeleted { display: none }
 
 /* Styles for the HTML preview */
 #preview { background: #fff; clear: both; margin: 0 }

File trac/htdocs/css/diff.css

View file
 .diff pre { background: #fff; border: 1px solid #ddd; font-size: 85%;
   margin: 0;
 }
+
+/* Styles for the property diffs */
+/* WARNING: This is a compatibility hack. 0.12 has a cleaner but non-compatible solution. */
+.diff table.props {
+ border: 0;
+ font-size: 13px;
+ margin: inherit;
+ width: inherit;
+}
+.diff table.props td {
+ padding: 2px 0.5em;
+ background: inherit;
+ font: inherit;
+}

File trac/util/__init__.py

View file
         else:
             return 0
 
+def to_ranges(revs):
+    """Converts a list of revisions to a minimal set of ranges.
+    
+    >>> to_ranges([2, 12, 3, 6, 9, 1, 5, 11])
+    '1-3,5-6,9,11-12'
+    >>> to_ranges([])
+    ''
+    """
+    ranges = []
+    begin = end = None
+    def store():
+        if end == begin:
+            ranges.append(str(begin))
+        else:
+            ranges.append('%d-%d' % (begin, end))
+    for rev in sorted(revs):
+        if begin is None:
+            begin = end = rev
+        elif rev == end + 1:
+            end = rev
+        else:
+            store()
+            begin = end = rev
+    if begin is not None:
+        store()
+    return ','.join(ranges)
+
 def content_disposition(type, filename=None):
     """Generate a properly escaped Content-Disposition header"""
     if filename is not None:

File trac/versioncontrol/cache.py

View file
 #
 # Author: Christopher Lenz <cmlenz@gmx.de>
 
+import bisect
+from datetime import datetime
 import os
 import posixpath
-from datetime import datetime
 
 from trac.core import TracError
 from trac.util.datefmt import utc, to_timestamp
     def get_node(self, path, rev=None):
         return self.repos.get_node(path, rev)
 
+    def _get_node_revs(self, path, rev=None):
+        """Return the revisions affecting `path` between its creation and
+        `rev`.
+        """
+        rev = self.normalize_rev(rev)
+        node = self.get_node(path, rev)     # Check node existence and perms
+        db = self.getdb()
+        cursor = db.cursor()
+        cursor.execute("SELECT DISTINCT rev FROM node_change "
+                       "WHERE (path = %%s OR path %s) "
+                       "  AND %s <= %%s" % (db.like(), db.cast('rev', 'int')),
+                       (path, db.like_escape(path + '/') + '%', rev))
+        revs = [int(row[0]) for row in cursor]
+        revs.sort()
+        cursor.execute("SELECT rev FROM node_change "
+                       "WHERE path = %%s "
+                       "  AND change_type IN ('A', 'C', 'M') "
+                       "  AND %s <= %%s "
+                       "ORDER BY %s DESC "
+                       "LIMIT 1" % ((db.cast('rev', 'int'),) * 2),
+                       (path, rev))
+        created = 0
+        for row in cursor:
+            created = int(row[0])
+        return revs[bisect.bisect_left(revs, created):]
+
     def has_node(self, path, rev=None):
         return self.repos.has_node(path, rev)
 

File trac/versioncontrol/svn_fs.py

View file
 import posixpath
 from datetime import datetime
 
-from genshi.builder import tag
-
 from trac.config import ListOption
 from trac.core import *
 from trac.versioncontrol import Changeset, Node, Repository, \
                                 NoSuchChangeset, NoSuchNode
 from trac.versioncontrol.cache import CachedRepository
 from trac.versioncontrol.svn_authz import SubversionAuthorizer
-from trac.versioncontrol.web_ui.browser import IPropertyRenderer
-from trac.util import sorted, embedded_numbers, reversed
+from trac.util import embedded_numbers
 from trac.util.text import exception_to_unicode, to_unicode
 from trac.util.translation import _
 from trac.util.datefmt import utc
         return version_string
 
 
-class SubversionPropertyRenderer(Component):
-    implements(IPropertyRenderer)
-
-    def __init__(self):
-        self._externals_map = {}
-
-    # IPropertyRenderer methods
-
-    def match_property(self, name, mode):
-        return name in ('svn:externals', 'svn:needs-lock') and 4 or 0
-    
-    def render_property(self, name, mode, context, props):
-        if name == 'svn:externals':
-            return self._render_externals(props[name])
-        elif name == 'svn:needs-lock':
-            return self._render_needslock(context)
-
-    def _render_externals(self, prop):
-        if not self._externals_map:
-            for dummykey, value in self.config.options('svn:externals'):
-                value = value.split()
-                if len(value) != 2:
-                    self.env.warn("svn:externals entry %s doesn't contain "
-                            "a space-separated key value pair, skipping.", 
-                            label)
-                    continue
-                key, value = value
-                self._externals_map[key] = value.replace('%', '%%') \
-                                           .replace('$path', '%(path)s') \
-                                           .replace('$rev', '%(rev)s')
-        externals = []
-        for external in prop.splitlines():
-            elements = external.split()
-            if not elements:
-                continue
-            localpath, rev, url = elements[0], '', elements[-1]
-            if localpath.startswith('#'):
-                externals.append((external, None, None, None, None))
-                continue
-            if len(elements) == 3:
-                rev = elements[1]
-                rev = rev.replace('-r', '')
-            # retrieve a matching entry in the externals map
-            prefix = []
-            base_url = url
-            while base_url:
-                if base_url in self._externals_map or base_url==u'/':
-                    break
-                base_url, pref = posixpath.split(base_url)
-                prefix.append(pref)
-            href = self._externals_map.get(base_url)
-            revstr = rev and ' at revision '+rev or ''
-            if not href and (url.startswith('http://') or 
-                             url.startswith('https://')):
-                href = url.replace('%', '%%')
-            if href:
-                remotepath = ''
-                if prefix:
-                    remotepath = posixpath.join(*reversed(prefix))
-                externals.append((localpath, revstr, base_url, remotepath,
-                                  href % {'path': remotepath, 'rev': rev}))
-            else:
-                externals.append((localpath, revstr, url, None, None))
-        externals_data = []
-        for localpath, rev, url, remotepath, href in externals:
-            label = localpath
-            if url is None:
-                title = ''
-            elif href:
-                if url:
-                    url = ' in ' + url
-                label += rev + url
-                title = ''.join((remotepath, rev, url))
-            else:
-                title = _('No svn:externals configured in trac.ini')
-            externals_data.append((label, href, title))
-        return tag.ul([tag.li(tag.a(label, href=href, title=title))
-                       for label, href, title in externals_data])
-
-    def _render_needslock(self, context):
-        return tag.img(src=context.href.chrome('common/lock-locked.png'),
-                       alt="needs lock", title="needs lock")
-
-
 class SubversionRepository(Repository):
     """Repository implementation based on the svn.fs API."""
 
 
         return SubversionNode(path, rev, self, self.pool)
 
+    def _get_node_revs(self, path, rev=None):
+        """Return the revisions affecting `path` between its creation and
+        `rev`.
+        """
+        node = self.get_node(path, rev)
+        revs = []
+        for (p, r, chg) in node.get_history():
+            if p != path:
+                break
+            revs.append(r)
+        return revs
+
     def _history(self, path, start, end, pool):
         """`path` is a unicode path in the scope.
 
 
 class SubversionNode(Node):
 
-    def __init__(self, path, rev, repos, pool=None, parent=None):
+    def __init__(self, path, rev, repos, pool=None, parent_root=None):
         self.repos = repos
         self.fs_ptr = repos.fs_ptr
         self.authz = repos.authz
         self._requested_rev = rev
         pool = self.pool()
 
-        if parent and parent._requested_rev == self._requested_rev:
-            self.root = parent.root
+        if parent_root:
+            self.root = parent_root
         else:
             self.root = fs.revision_root(self.fs_ptr, rev, self.pool())
         node_type = fs.check_path(self.root, self._scoped_path_utf8, pool)
                                                             path.strip('/'))):
                 continue
             yield SubversionNode(path, self._requested_rev, self.repos,
-                                 self.pool, self)
+                                 self.pool, self.root)
 
     def get_history(self, limit=None):
         newer = None # 'newer' is the previously seen history tuple

File trac/versioncontrol/svn_prop.py

View file
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2005-2009 Edgewall Software
+# Copyright (C) 2005 Christopher Lenz <cmlenz@gmx.de>
+# Copyright (C) 2005-2007 Christian Boos <cboos@neuf.fr>
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.org/wiki/TracLicense.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://trac.edgewall.org/log/.
+#
+# Author: Christopher Lenz <cmlenz@gmx.de>
+#         Christian Boos <cboos@neuf.fr>
+
+import posixpath
+
+from genshi.builder import tag
+
+from trac.core import *
+from trac.versioncontrol import NoSuchNode
+from trac.versioncontrol.web_ui.browser import IPropertyRenderer
+from trac.versioncontrol.web_ui.changeset import IPropertyDiffRenderer
+from trac.util import Ranges, to_ranges
+from trac.util.compat import set
+from trac.util.translation import _
+
+
+class SubversionPropertyRenderer(Component):
+    implements(IPropertyRenderer, IPropertyDiffRenderer)
+
+    def __init__(self):
+        self._externals_map = {}
+
+    # IPropertyRenderer methods
+
+    def match_property(self, name, mode):
+        return name in ('svn:externals', 'svn:mergeinfo', 'svn:needs-lock',
+                        'svnmerge-blocked', 'svnmerge-integrated') and 4 or 0
+    
+    def render_property(self, name, mode, context, props):
+        if name == 'svn:externals':
+            return self._render_externals(props[name])
+        elif name == 'svn:mergeinfo' or name.startswith('svnmerge-'):
+            return self._render_mergeinfo(name, mode, context, props)
+        elif name == 'svn:needs-lock':
+            return self._render_needslock(context)
+
+    def _render_externals(self, prop):
+        if not self._externals_map:
+            for dummykey, value in self.config.options('svn:externals'):
+                value = value.split()
+                if len(value) != 2:
+                    self.env.warn("svn:externals entry %s doesn't contain "
+                            "a space-separated key value pair, skipping.", 
+                            label)
+                    continue
+                key, value = value
+                self._externals_map[key] = value.replace('%', '%%') \
+                                           .replace('$path', '%(path)s') \
+                                           .replace('$rev', '%(rev)s')
+        externals = []
+        for external in prop.splitlines():
+            elements = external.split()
+            if not elements:
+                continue
+            localpath, rev, url = elements[0], '', elements[-1]
+            if localpath.startswith('#'):
+                externals.append((external, None, None, None, None))
+                continue
+            if len(elements) == 3:
+                rev = elements[1]
+                rev = rev.replace('-r', '')
+            # retrieve a matching entry in the externals map
+            prefix = []
+            base_url = url
+            while base_url:
+                if base_url in self._externals_map or base_url==u'/':
+                    break
+                base_url, pref = posixpath.split(base_url)
+                prefix.append(pref)
+            href = self._externals_map.get(base_url)
+            revstr = rev and ' at revision '+rev or ''
+            if not href and (url.startswith('http://') or 
+                             url.startswith('https://')):
+                href = url.replace('%', '%%')
+            if href:
+                remotepath = ''
+                if prefix:
+                    remotepath = posixpath.join(*reversed(prefix))
+                externals.append((localpath, revstr, base_url, remotepath,
+                                  href % {'path': remotepath, 'rev': rev}))
+            else:
+                externals.append((localpath, revstr, url, None, None))
+        externals_data = []
+        for localpath, rev, url, remotepath, href in externals:
+            label = localpath
+            if url is None:
+                title = ''
+            elif href:
+                if url:
+                    url = ' in ' + url
+                label += rev + url
+                title = ''.join((remotepath, rev, url))
+            else:
+                title = _('No svn:externals configured in trac.ini')
+            externals_data.append((label, href, title))
+        return tag.ul([tag.li(tag.a(label, href=href, title=title))
+                       for label, href, title in externals_data])
+
+    def _render_mergeinfo(self, name, mode, context, props):
+        """Parse svn:mergeinfo and svnmerge-* properties, converting branch
+        names to links and providing links to the revision log for merged
+        and eligible revisions.
+        """
+        has_eligible = name in ('svnmerge-integrated', 'svn:mergeinfo')
+        revs_label = (_('merged'), _('blocked'))[name.endswith('blocked')]
+        revs_cols = has_eligible and 2 or None
+        repos = self.env.get_repository()
+        rows = []
+        for line in props[name].splitlines():
+            path, revs = line.split(':', 1)
+            spath = path.strip('/')
+            revs = revs.strip()
+            deleted = False
+            if 'LOG_VIEW' in context.perm('source', spath):
+                try:
+                    node = repos.get_node(spath, context.resource.version)
+                    row = [self._get_source_link(path, context),
+                           self._get_revs_link(revs_label, context,
+                                               spath, revs)]
+                    if has_eligible:
+                        eligible = set(repos._get_node_revs(spath,
+                                                    context.resource.version))
+                        eligible -= set(Ranges(revs))
+                        blocked = self._get_blocked_revs(props, name, spath)
+                        eligible -= set(Ranges(blocked))
+                        eligible = to_ranges(eligible)
+                        row.append(self._get_revs_link(_('eligible'), context,
+                                                       spath, eligible))
+                    rows.append((False, spath, [tag.td(each) for each in row]))
+                    continue
+                except NoSuchNode:
+                    deleted = True
+            revs = revs.replace(',', u',\u200b')
+            rows.append((deleted, spath,
+                         [tag.td(path), tag.td(revs, colspan=revs_cols)]))
+        rows.sort()
+        has_deleted = rows and rows[-1][0] or None
+        return tag(has_deleted and tag.a(_('(toggle deleted branches)'),
+                                         class_='trac-toggledeleted',
+                                         href='#'),
+                   tag.table(tag.tbody(
+                       [tag.tr(row, class_=deleted and 'trac-deleted' or None)
+                        for deleted, p, row in rows])))
+
+    def _get_blocked_revs(self, props, name, path):
+        """Return the revisions blocked from merging for the given property
+        name and path.
+        """
+        if name == 'svnmerge-integrated':
+            prop = props.get('svnmerge-blocked', '')
+        else:
+            return ""
+        for line in prop.splitlines():
+            try:
+                p, revs = line.split(':', 1)
+                if p.strip('/') == path:
+                    return revs
+            except Exception:
+                pass
+        return ""
+
+    def _get_source_link(self, path, context):
+        """Return a link to a merge source."""
+        return tag.a(path, title=_('View merge source'),
+                     href=context.href.browser(path,
+                                               rev=context.resource.version))
+
+    def _get_revs_link(self, label, context, spath, revs):
+        """Return a link to the revision log when more than one revision is
+        given, to the revision itself for a single revision, or a `<span>`
+        with "no revision" for none.
+        """
+        if not revs:
+            return tag.span(label, title=_('No revisions'))
+        elif ',' in revs or '-' in revs:
+            revs_href = context.href.log(spath, revs=revs)
+        else:
+            revs_href = context.href.changeset(revs, spath)
+        return tag.a(label, title=revs.replace(',', ', '), href=revs_href)
+
+    def _render_needslock(self, context):
+        return tag.img(src=context.href.chrome('common/lock-locked.png'),
+                       alt="needs lock", title="needs lock")
+
+    # IPropertyDiffRenderer methods
+
+    def match_property_diff(self, name):
+        return name in ('svn:mergeinfo', 'svnmerge-blocked',
+                        'svnmerge-integrated') and 4 or 0
+
+    def render_property_diff(self, name, old_context, old_props,
+                             new_context, new_props, options):
+        # Build 3 columns table showing modifications on merge sources
+        # || source || added revs || removed revs ||
+        # || source || removed                    ||
+        def parse_sources(props):
+            sources = {}
+            for line in props[name].splitlines():
+                path, revs = line.split(':', 1)
+                spath = path.strip('/')
+                sources[spath] = (path, set(Ranges(revs.strip())))
+            return sources
+        old_sources = parse_sources(old_props)
+        new_sources = parse_sources(new_props)
+        # Go through new sources, detect modified ones or added ones
+        blocked = name.endswith('blocked')
+        added_label = [_('merged: '), _('blocked: ')][blocked]
+        removed_label = [_('reverse-merged: '), _('un-blocked: ')][blocked]
+        def revs_link(revs, context):
+            if revs:
+                revs = to_ranges(revs)
+                return self._get_revs_link(revs.replace(',', u',\u200b'),
+                                           context, spath, revs)
+        repos = self.env.get_repository()
+        modified_sources = []
+        for spath, (path, new_revs) in new_sources.iteritems():
+            if spath in old_sources:
+                old_revs, status = old_sources.pop(spath)[1], None
+            else:
+                old_revs, status = set(), _(' (added)')
+            added = new_revs - old_revs
+            removed = old_revs - new_revs
+            try:
+                all_revs = set(repos._get_node_revs(spath))
+                added &= all_revs
+                removed &= all_revs
+            except NoSuchNode:
+                pass
+            if added or removed:
+                modified_sources.append((
+                    path, [self._get_source_link(path, new_context), status],
+                    added and tag(added_label, revs_link(added, new_context)),
+                    removed and tag(removed_label,
+                                    revs_link(removed, old_context))))
+        # Go through remaining old sources, those were deleted
+        removed_sources = []
+        for spath, (path, old_revs) in old_sources.iteritems():
+            removed_sources.append((path,
+                                    self._get_source_link(path, old_context)))
+        if modified_sources or removed_sources:
+            modified_sources.sort()
+            removed_sources.sort()
+            changes = tag.table(tag.tbody(
+                [tag.tr(tag.td(src), tag.td(added), tag.td(removed))
+                 for p, src, added, removed in modified_sources],
+                [tag.tr(tag.td(src), tag.td(_('removed'), colspan=2))
+                 for p, src in removed_sources]), class_='props')
+        else:
+            changes = tag.em(_(' (with no actual effect on merging)'))
+        return tag.li(tag('Property ', tag.strong(name), ' changed'),
+                      changes)

File trac/versioncontrol/templates/browser.html

View file
     <meta py:if="dir" name="ROBOTS" content="NOINDEX" />
     <script type="text/javascript">
       jQuery(document).ready(function($) {
+        $(".trac-toggledeleted").show().click(function() {
+                  $(this).siblings().find(".trac-deleted").toggle();
+                  return false;
+        }).click();
         $("#jumploc input").hide();
         $("#jumploc select").change(function () {
           this.parentNode.parentNode.submit();
-        })
+        });
 
         <py:if test="dir">
           /* browsers using old WebKits have issues with expandDir... */

File trac/versioncontrol/web_ui/browser.py

View file
 from trac.util import sorted, embedded_numbers
 from trac.util.datefmt import http_date, utc
 from trac.util.html import escape, Markup
-from trac.util.text import shorten_line
+from trac.util.text import exception_to_unicode, shorten_line
 from trac.util.translation import _
 from trac.web import IRequestHandler, RequestDone
 from trac.web.chrome import add_ctxtnav, add_link, add_script, add_stylesheet, \
             quality = renderer.match_property(name, mode)
             if quality > 0:
                 candidates.append((quality, renderer))
-        if candidates:
-            renderer = sorted(candidates, reverse=True)[0][1]
-            rendered = renderer.render_property(name, mode, context, props)
-            if rendered:
+        candidates.sort()
+        candidates.reverse()
+        for (quality, renderer) in candidates:
+            try:
+                rendered = renderer.render_property(name, mode, context, props)
+                if not rendered:
+                    return rendered
                 if isinstance(rendered, RenderedProperty):
                     value = rendered.content
                     rendered = rendered
                     rendered = None
                 prop = {'name': name, 'value': value, 'rendered': rendered}
                 return prop
+            except Exception, e:
+                self.log.warning('Rendering failed for property %s with '
+                                 'renderer %s: %s', name,
+                                 renderer.__class__.__name__,
+                                 exception_to_unicode(e, traceback=True))
 
     # IWikiSyntaxProvider methods
 

File trac/versioncontrol/web_ui/changeset.py

View file
 from trac.util import embedded_numbers, content_disposition
 from trac.util.compat import any, sorted, groupby
 from trac.util.datefmt import pretty_timedelta, utc
-from trac.util.text import unicode_urlencode, shorten_line, CRLF
+from trac.util.text import exception_to_unicode, unicode_urlencode, \
+                           shorten_line, CRLF
 from trac.util.translation import _
 from trac.versioncontrol import Changeset, Node, NoSuchChangeset
 from trac.versioncontrol.diff import get_diff_options, diff_blocks, \
             quality = renderer.match_property_diff(name)
             if quality > 0:
                 candidates.append((quality, renderer))
-        if candidates:
-            renderer = sorted(candidates, reverse=True)[0][1]
-            return renderer.render_property_diff(name, old_node, old_props,
-                                                 new_node, new_props, options)
-        else:
-            return None
+        candidates.sort()
+        candidates.reverse()
+        for (quality, renderer) in candidates:
+            try:
+                return renderer.render_property_diff(name, old_node, old_props,
+                                                     new_node, new_props,
+                                                     options)
+            except Exception, e:
+                self.log.warning('Diff rendering failed for property %s with '
+                                 'renderer %s: %s', name,
+                                 renderer.__class__.__name__,
+                                 exception_to_unicode(e, traceback=True))
 
     def _get_location(self, files):
         """Return the deepest common path for the given files.