Edgewall Software

Ticket #8417: backend.3.py

File backend.3.py, 55.1 KB (added by me@…, 13 months ago)

Revised again: error handling & branch display in the timeline

Line 
1# -*- coding: iso-8859-1 -*-
2#
3# Copyright (C) 2005 Edgewall Software
4# Copyright (C) 2005-2007 Christian Boos <cboos@neuf.fr>
5# All rights reserved.
6#
7# This software may be used and distributed according to the terms
8# of the GNU General Public License, incorporated herein by reference.
9#
10# Author: Christian Boos <cboos@neuf.fr>
11
12from bisect import bisect
13from datetime import datetime
14import os
15import time
16import posixpath
17import re
18import sys
19
20import pkg_resources
21
22from genshi.builder import tag
23
24from trac.core import *
25from trac.config import BoolOption, ChoiceOption, ListOption, PathOption
26from trac.env import ISystemInfoProvider
27from trac.util import arity
28from trac.util.datefmt import FixedOffset, utc
29from trac.util.text import exception_to_unicode, shorten_line, to_unicode
30from trac.util.translation import domain_functions
31from trac.versioncontrol.api import Changeset, Node, Repository, \
32                                    IRepositoryConnector, RepositoryManager, \
33                                    NoSuchChangeset, NoSuchNode
34from trac.versioncontrol.cache import CachedRepository, CachedChangeset, \
35                                      CACHE_METADATA_KEYS, CACHE_REPOSITORY_DIR, CACHE_YOUNGEST_REV, \
36                                      _kindmap, _actionmap
37from trac.versioncontrol.web_ui import IPropertyRenderer, RenderedProperty
38from trac.util.datefmt import from_utimestamp, to_utimestamp
39from trac.wiki import IWikiSyntaxProvider
40
41# -- plugin i18n
42
43gettext, _, tag_, N_, add_domain = \
44    domain_functions('tracmercurial', 
45                     ('gettext', '_', 'tag_', 'N_', 'add_domain'))
46
47# -- Using internal Mercurial API, see:
48#     * http://mercurial.selenic.com/wiki/MercurialApi
49#     * http://mercurial.selenic.com/wiki/ApiChanges
50
51hg_import_error = []
52try:
53    # The new `demandimport` mechanism doesn't play well with code relying
54    # on the `ImportError` exception being caught.
55    # OTOH, we can't disable `demandimport` because mercurial relies on it
56    # (circular reference issue). So for now, we activate `demandimport`
57    # before loading mercurial modules, and desactivate it afterwards.
58    #
59    # See http://www.selenic.com/mercurial/bts/issue605
60   
61    try:
62        from mercurial import demandimport
63        demandimport.enable();
64    except ImportError, hg_import_error:
65        demandimport = None
66
67    from mercurial import hg
68    from mercurial.context import filectx
69    from mercurial.ui import ui
70    from mercurial.node import hex, short, nullid, nullrev
71    from mercurial.util import pathto, cachefunc
72    from mercurial import cmdutil
73    from mercurial import encoding
74    from mercurial import extensions
75    from mercurial.extensions import loadall
76
77    # Note: due to the nature of demandimport, there will be no actual
78    # import error until those symbols get accessed, so here we go:
79    for sym in ("filectx ui hex short nullid pathto "
80                "cachefunc loadall".split()):
81        if repr(globals()[sym]) == "<unloaded module '%s'>" % sym:
82            hg_import_error.append(sym)
83    if hg_import_error:
84        hg_import_error = "Couldn't import symbols: "+','.join(hg_import_error)
85
86    # Mercurial versions >= 1.2 won't have mercurial.repo.RepoError anymore
87    from mercurial.repo import RepoError
88    from mercurial.revlog import LookupError as HgLookupError
89    if repr(RepoError) == "<unloaded module 'RepoError'>":
90        from mercurial.error import RepoError, LookupError as HgLookupError
91
92    # Force local encoding to be non-lossy (#7217)
93    os.environ['HGENCODING'] = 'utf-8'
94    encoding.tolocal = str
95   
96    if demandimport:
97        demandimport.disable();
98   
99except ImportError, e:
100    hg_import_error = e
101    ui = object
102
103
104### Helpers
105
106def checked_encode(u, encodings, check):
107    """Convert `unicode` to `str` trying several encodings until a
108    condition is met.
109
110    :param u: the `unicode` input
111    :param encodings: the list of possible encodings
112    :param check: the predicate to satisfy
113
114    :return: the first converted `str` if `check(s)` is `True`,
115             otherwise `None`.  Note that if no encoding is able to
116             successfully convert the input, the empty string will be
117             given to `check`, which can accept it as valid or not.
118    """
119    s = u
120    if isinstance(u, unicode): 
121        for enc in encodings:
122            try:
123                s = u.encode(enc)
124                if check(s):
125                    return s
126            except UnicodeEncodeError:
127                pass
128        else:
129            s = ''
130    if check(s):
131        return s
132
133       
134class trac_ui(ui):
135    # Note: will be dropped in 0.13, see MercurialConnector._setup_ui
136    def __init__(self, *args, **kwargs):
137        ui.__init__(self, *args)
138        self.setconfig('ui', 'interactive', 'off')
139        self.log = kwargs.get('log', args and args[0].log or None)
140       
141    def write(self, *args, **opts):
142        for a in args:
143            self.log.info('(mercurial status) %s', a)
144
145    def write_err(self, *args, **opts):
146        for a in args:
147            self.log.warn('(mercurial warning) %s', a)
148
149    def plain(self):
150        return False # so that '[hg] hgrc' file can specify [ui] options
151
152    def interactive(self): 
153        return False
154
155    def readline(self):
156        raise TracError('*** Mercurial ui.readline called ***')
157
158
159### Components
160
161class CsetPropertyRenderer(Component):
162
163    implements(IPropertyRenderer)
164
165    def match_property(self, name, mode):
166        return (name.startswith('hg-') and
167                name[3:] in ('Parents', 'Children', 'Tags', 'Branch') and
168                mode == 'revprop') and 4 or 0
169   
170    def render_property(self, name, mode, context, props):
171        return RenderedProperty(name=gettext(name[3:] + ':'), 
172                name_attributes=[("class", "property")],
173                content=self._render_property(name, mode, context, props))
174
175    def _render_property(self, name, mode, context, props):
176        repos, revs = props[name]
177       
178        if name in ('hg-Parents', 'hg-Children'):
179            label = repos.display_rev
180        else:
181            label = lambda rev: rev
182       
183        def link(rev):
184            chgset = repos.get_changeset(rev)
185            return tag.a(label(rev), class_="changeset",
186                         title=shorten_line(chgset.message),
187                         href=context.href.changeset(rev, repos.reponame))
188       
189        if name == 'hg-Parents' and len(revs) == 2: # merge
190            new = context.resource.id
191            parent_links = [
192                    (link(rev), ' (',
193                     tag.a('diff', title=_("Diff against this parent "
194                           "(show the changes merged from the other parents)"),
195                           href=context.href.changeset(new, repos.reponame, 
196                                                       old=rev)), ')')
197                           for rev in revs]
198            return tag([(parent, ', ') for parent in parent_links[:-1]],
199                       parent_links[-1], tag.br(),
200                       tag.span(tag_("Note: this is a %(merge)s changeset, "
201                                     "the changes displayed below correspond "
202                                     "to the merge itself.",
203                                     merge=tag.strong('merge')),
204                                class_='hint'), tag.br(), 
205                       # TODO: only keep chunks present in both parents
206                       #       (conflicts) or in none (extra changes)
207                       # tag.span('No changes means the merge was clean.',
208                       #         class_='hint'), tag.br(),
209                       tag.span(tag_("Use the %(diff)s links above to see all "
210                                     "the changes relative to each parent.",
211                                     diff=tag.tt('(diff)')),
212                                class_='hint'))
213        return tag([tag(link(rev), ', ') for rev in revs[:-1]],
214                   link(revs[-1]))
215
216
217class HgExtPropertyRenderer(Component):
218
219    implements(IPropertyRenderer)
220
221    def match_property(self, name, mode):
222       return name in ('hg-transplant_source', 'hg-convert_revision') and \
223           mode == 'revprop' and 4 or 0
224   
225    def render_property(self, name, mode, context, props):
226        repos, value = props[name]
227        if name == 'hg-transplant_source':
228            try:
229                ctx = self.changectx(value)
230                chgset = MercurialChangeset(repos, ctx)
231                href = context.href.changeset(ctx.hex(), repos.reponame)
232                link = tag.a(repos._display(ctx), class_="changeset",
233                             title=shorten_line(chgset.message), href=href)
234            except NoSuchChangeset:
235                link = tag.a(hex(value), class_="missing changeset",
236                             title=_("no such changeset"), rel="nofollow")
237            return RenderedProperty(name=_("Transplant:"), content=link,
238                                    name_attributes=[("class", "property")])
239       
240        elif name == 'hg-convert_revision':
241            text = repos.to_u(value)
242            if value.startswith('svn:'):
243                # e.g. 'svn:af82e41b-90c4-0310-8c96-b1721e28e2e2/trunk@9517'
244                uuid = value[:40]
245                rev = value.rsplit('@', 1)[-1]
246                for r in RepositoryManager(self.env).get_real_repositories():
247                    if r.name.startswith(uuid + ':'):
248                        path = r.reponame
249                        href = context.href.changeset(rev, path or None)
250                        text = tag.a('[%s%s]' % (rev, path and '/' + path),
251                                     class_='changeset', href=href,
252                                     title=_('Changeset in source repository'))
253                        break
254            return RenderedProperty(name=_('Convert:'), content=text,
255                                    name_attributes=[("class", "property")])
256
257
258class HgDefaultPropertyRenderer(Component):
259
260    implements(IPropertyRenderer)
261
262    def match_property(self, name, mode):
263       return name.startswith('hg-') and mode == 'revprop' and 1 or 0
264   
265    def render_property(self, name, mode, context, props):
266        return RenderedProperty(name=name[3:] + ':', 
267                                name_attributes=[("class", "property")],
268                                content=self._render_property(name, mode, 
269                                                              context, props))
270
271    def _render_property(self, name, mode, context, props):
272        repos, value = props[name]
273        try:
274            return unicode(value)
275        except UnicodeDecodeError:
276            if len(value) <= 100:
277                return tag.tt(''.join(("%02x" % ord(c)) for c in value))
278            else:
279                return tag.em(_("(binary, size greater than 100 bytes)"))
280
281
282class MercurialConnector(Component):
283
284    implements(ISystemInfoProvider, IRepositoryConnector, IWikiSyntaxProvider)
285
286    encoding = ListOption('hg', 'encoding', 'utf-8', doc="""
287        Encoding that should be used to decode filenames, file
288        content, and changeset metadata.  If multiple encodings are
289        used for these different situations (or even multiple
290        encodings were used for filenames), simply specify a list of
291        encodings which will be tried in turn (''since 0.12.0.24'').
292        """)
293
294    show_rev = BoolOption('hg', 'show_rev', True, doc="""
295        Show decimal revision in front of the commit SHA1 hash.  While
296        this number is specific to the particular clone used to browse
297        the repository, this can sometimes give an useful hint about
298        the relative "age" of a revision.
299        """)
300
301    node_format = ChoiceOption('hg', 'node_format', ['short', 'hex'], doc="""
302        Specify how the commit SHA1 hashes should be
303        displayed. Possible choices are: 'short', the SHA1 hash is
304        abbreviated to its first 12 digits, or 'hex', the hash is
305        shown in full.
306        """)
307
308    hgrc = PathOption('hg', 'hgrc', '', doc="""
309        Optional path to an hgrc file which will be used to specify
310        extra Mercurial configuration options (see
311        http://www.selenic.com/mercurial/hgrc.5.html).
312        """)
313
314    def __init__(self):
315        self.ui = None
316        locale_dir = pkg_resources.resource_filename(__name__, 'locale')
317        add_domain(self.env.path, locale_dir)
318        self._version = self._version_info = None
319        if not hg_import_error:
320            try:
321                from mercurial.version import get_version
322                self._version = get_version()
323            except ImportError: # gone in Mercurial 1.2 (hg:9626819b2e3d)
324                from mercurial.util import version
325                self._version = version()
326            # development version assumed to be always the ''newest'' one,
327            # i.e. old development version won't be supported
328            self._version_info = (999, 0, 0) 
329            m = re.match(r'(\d+)\.(\d+)(?:\.(\d+))?', self._version or '')
330            if m:
331                self._version_info = tuple([int(n or 0) for n in m.groups()])
332
333    def _setup_ui(self, hgrc_path):
334        # Starting with Mercurial 1.3 we can probably do simply:
335        #
336        #   ui = baseui.copy() # there's no longer a parent/child concept
337        #   ui.setconfig('ui', 'interactive', 'off')
338        #
339        self.ui = trac_ui(log=self.log)
340
341        # (code below adapted from mercurial.dispatch._dispatch)
342
343        # read the local repository .hgrc into a local ui object
344        if hgrc_path:
345            if not os.path.exists(hgrc_path):
346                self.log.warn("'[hg] hgrc' file (%s) not found ", hgrc_path)
347            try:
348                self.ui = trac_ui(self.ui, log=self.log)
349                self.ui.check_trusted = False
350                self.ui.readconfig(hgrc_path)
351            except IOError, e:
352                self.log.warn("'[hg] hgrc' file (%s) can't be read: %s", 
353                              hgrc_path, e)
354
355        extensions.loadall(self.ui)
356        if hasattr(extensions, 'extensions'):
357            for name, module in extensions.extensions():
358                # setup extensions
359                extsetup = getattr(module, 'extsetup', None)
360                if extsetup:
361                    if arity(extsetup) == 1:
362                        extsetup(self.ui)
363                    else:
364                        extsetup()
365
366    # ISystemInfoProvider methods
367
368    def get_system_info(self):
369        if self._version is not None:
370            yield 'Mercurial', self._version
371
372    # IRepositoryConnector methods
373   
374    def get_supported_types(self):
375        """Support for `repository_type = hg`"""
376        if hg_import_error:
377            self.error = hg_import_error
378            yield ("hg", -1)
379        else:
380            yield ("hg", 8)
381
382    def get_repository(self, type, dir, params):
383        """Return a `MercurialRepository`"""
384        if not self.ui:
385            self._setup_ui(self.hgrc)
386        repos = MercurialRepository(dir, params, self.log, self)
387        repos.version_info = self._version_info
388        cached_repos = MercurialCachedRepository(self.env, repos, self.log)
389        cached_repos.has_linear_changesets = False
390        return cached_repos
391
392    # IWikiSyntaxProvider methods
393   
394    def get_wiki_syntax(self):
395        yield (r'!?(?P<hgrev>[0-9a-f]{12,40})(?P<hgpath>/\S+\b)?',
396               lambda formatter, label, match:
397                   self._format_link(formatter, 'cset', match.group(0),
398                                     match.group(0), match))
399
400    def get_link_resolvers(self):
401        yield ('cset', self._format_link)
402        yield ('chgset', self._format_link)
403        yield ('branch', self._format_link)    # go to the corresponding head
404        yield ('tag', self._format_link)
405
406    def _format_link(self, formatter, ns, rev, label, fullmatch=None):
407        reponame = path = ''
408        repos = None
409        rm = RepositoryManager(self.env)
410        try:
411            if fullmatch:
412                rev = fullmatch.group('hgrev')
413                path = fullmatch.group('hgpath')
414                if path:
415                    reponame, repos, path = \
416                        rm.get_repository_by_path(path.strip('/'))
417            if not repos:
418                context = formatter.context
419                while context:
420                    if context.resource.realm in ('source', 'changeset'):
421                        reponame = context.resource.parent.id
422                        break
423                    context = context.parent
424                repos = rm.get_repository(reponame)
425            if repos:
426                if ns == 'branch':
427                    for b, n in repos.repo.branchtags().items():
428                        if repos.to_u(b) == rev:
429                            rev = repos.repo.changelog.rev(n)
430                            break
431                    else:
432                        raise NoSuchChangeset(rev)
433                chgset = repos.get_changeset(rev)
434                return tag.a(label, class_="changeset",
435                             title=shorten_line(chgset.message),
436                             href=formatter.href.changeset(rev, reponame,
437                                                           path))
438            raise TracError("Repository not found")
439        except NoSuchChangeset, e:
440            errmsg = to_unicode(e)
441        except TracError:
442            errmsg = _("Repository '%(repo)s' not found", repo=reponame)
443        return tag.a(label, class_="missing changeset",
444                     title=errmsg, rel="nofollow")
445
446
447class MercurialCachedRepository(CachedRepository):
448
449    def display_rev(self, rev):
450        return self.repos.display_rev(rev)
451
452    def normalize_rev(self, rev):
453        if rev is None or isinstance(rev, basestring) and \
454               rev.lower() in ('', 'head', 'latest', 'youngest'):
455            return self.rev_db(self.youngest_rev or nullrev)
456        else:
457            return self.repos.normalize_rev(rev)
458
459    def db_rev(self, rev):
460        return self.repos.short_rev(rev)
461
462    def rev_db(self, rev):
463        return self.repos.normalize_rev(rev)
464
465    def get_changeset(self, rev):
466        return MercurialCachedChangeset(self, self.normalize_rev(rev), self.env)
467
468    def sync(self, feedback=None, clean=False):
469        if clean:
470            self.log.info('Cleaning cache')
471            @self.env.with_transaction()
472            def do_clean(db):
473                cursor = db.cursor()
474                cursor.execute("DELETE FROM revision WHERE repos=%s",
475                               (self.id,))
476                cursor.execute("DELETE FROM node_change WHERE repos=%s",
477                               (self.id,))
478                cursor.executemany("""
479                    DELETE FROM repository WHERE id=%s AND name=%s
480                    """, [(self.id, k) for k in CACHE_METADATA_KEYS])
481                cursor.executemany("""
482                    INSERT INTO repository (id,name,value) VALUES (%s,%s,%s)
483                    """, [(self.id, k, '') for k in CACHE_METADATA_KEYS])
484                del self.metadata
485
486        metadata = self.metadata
487       
488        @self.env.with_transaction()
489        def do_transaction(db):
490            cursor = db.cursor()
491            invalidate = False
492   
493            # -- check that we're populating the cache for the correct
494            #    repository
495            repository_dir = metadata.get(CACHE_REPOSITORY_DIR)
496            if repository_dir:
497                # directory part of the repo name can vary on case insensitive
498                # fs
499                if os.path.normcase(repository_dir) \
500                        != os.path.normcase(self.name):
501                    self.log.info("'repository_dir' has changed from %r to %r",
502                                  repository_dir, self.name)
503                    raise TracError(_("The repository directory has changed, "
504                                      "you should resynchronize the "
505                                      "repository with: trac-admin $ENV "
506                                      "repository resync '%(reponame)s'",
507                                      reponame=self.reponame or '(default)'))
508            elif repository_dir is None: #
509                self.log.info('Storing initial "repository_dir": %s',
510                              self.name)
511                cursor.execute("""
512                    INSERT INTO repository (id,name,value) VALUES (%s,%s,%s)
513                    """, (self.id, CACHE_REPOSITORY_DIR, self.name))
514                invalidate = True
515            else: # 'repository_dir' cleared by a resync
516                self.log.info('Resetting "repository_dir": %s', self.name)
517                cursor.execute("""
518                    UPDATE repository SET value=%s WHERE id=%s AND name=%s
519                    """, (self.name, self.id, CACHE_REPOSITORY_DIR))
520                invalidate = True
521   
522            # -- insert a 'youngeset_rev' for the repository if necessary
523            if metadata.get(CACHE_YOUNGEST_REV) is None:
524                cursor.execute("""
525                    INSERT INTO repository (id,name,value) VALUES (%s,%s,%s)
526                    """, (self.id, CACHE_YOUNGEST_REV, ''))
527                invalidate = True
528   
529            if invalidate:
530                del self.metadata
531
532        # -- retrieve the youngest revision in the repository and the youngest
533        #    revision cached so far
534        self.repos.clear()
535
536        kindmap = dict(zip(_kindmap.values(), _kindmap.keys()))
537        actionmap = dict(zip(_actionmap.values(), _actionmap.keys()))
538
539        # Retrieve all revisions.
540        repo_revs = set(self.db_rev(rev) for rev in self.repos.repo.changelog)
541       
542        @self.env.with_transaction()
543        def do_transaction(db):
544            cursor = db.cursor()
545
546            # Note: The idea using sets for add_revs/del_revs is borrowed from
547            # https://github.com/maraujop/TracMercurialChangesetPlugin/blob/master/mercurialchangeset/admin.py
548            # The term "nodes" are change to "revs" for consistency of terminology.
549            def _cache_and_get_revision_info(srev):
550                cset = self.repos.get_changeset(self.rev_db(srev))
551
552                # This is the time-consuming part, so we feedback here.
553                for path, kind, action, bpath, brev in cset.get_changes():
554                    kind = kindmap[kind]
555                    action = actionmap[action]
556                    try:
557                        cursor.execute("""
558                            INSERT INTO node_change
559                                (repos,rev,path,node_type,
560                                 change_type,base_path,base_rev)
561                            VALUES (%s,%s,%s,%s,%s,%s,%s)
562                            """, (self.id, srev, path, kind, action, bpath,
563                                  brev))
564                    except Exception, e:
565                        self.log.error('Error %s while inserting into node_change: %s',
566                                       e, (self.id, srev, path, kind, action, bpath, brev))
567                if feedback:
568                    feedback(self.rev_db(srev))
569
570                return (self.id, srev, to_utimestamp(cset.date), cset.author, cset.message)
571
572            # sql_revs: all nodes already synced in revision the table
573            sql_string = """
574                         SELECT rev FROM revision
575                         WHERE repos = %s
576                         """
577            cursor.execute(sql_string, (self.id, ))
578            sql_revs = set(int(row[0]) for row in cursor.fetchall())
579           
580            # add_revs: new revisions to be synced
581            # del_revs: There might be revisions synced that are outdated.
582            add_revs = [ _cache_and_get_revision_info(srev) for srev in repo_revs - sql_revs ]
583            del_revs = [ (self.id, srev) for srev in sql_revs - repo_revs ]
584
585            sql_string = """
586                         INSERT INTO revision (repos, rev, time, author, message)
587                         VALUES (%s, %s, %s, %s, %s)
588                         """
589            # We insert the new revisions' information into Trac's revision table
590            # trac.db.utils.executemany can not be passed an iterator
591            # Constructing a list here slow things down, but it is the only way at the moment
592            cursor.executemany(sql_string, list(add_revs))
593
594            sql_string = """
595                         DELETE FROM revision
596                         WHERE repos = %s AND rev = %s
597                         """
598            cursor.executemany(sql_string, list(del_revs))
599
600            # Update the most recent revision for the browser.
601            cursor.execute("""
602                UPDATE repository SET value=%s WHERE id=%s AND name=%s
603                """, (str(self.repos.changectx().hex()), self.id, CACHE_YOUNGEST_REV))
604            del self.metadata
605
606
607class MercurialCachedChangeset(CachedChangeset):
608
609    hg_properties = [
610        N_("Parents:"), N_("Children:"), N_("Branch:"), N_("Tags:")
611    ]
612
613    def __init__(self, repos, rev, env):
614        super(MercurialCachedChangeset, self).__init__(repos, rev, env)
615        self.ctx = repos.repos.changectx(rev)
616        self.branch = repos.repos.to_u(self.ctx.branch())
617
618    def get_branches(self):
619        """Yield branch names to which this changeset belong."""
620        return self.branch and [(self.branch, 
621                                len(self.ctx.children()) == 0)] or []
622
623### Version Control API
624   
625class MercurialRepository(Repository):
626    """Repository implementation based on the mercurial API.
627
628    This wraps an hg.repository object.  The revision navigation
629    follows the branches, and defaults to the first parent/child in
630    case there are many.  The eventual other parents/children are
631    listed as additional changeset properties.
632    """
633
634    def __init__(self, path, params, log, connector):
635        self.ui = connector.ui
636        self._show_rev = connector.show_rev
637        self._node_fmt = connector.node_format
638        # TODO 0.13: per repository ui and options
639
640        # -- encoding
641        encoding = connector.encoding
642        if not encoding:
643            encoding = ['utf-8']
644        # verify given encodings
645        for enc in encoding:
646            try:
647                u''.encode(enc)
648            except LookupError, e:
649                log.warning("'[hg] encoding' (%r) not valid", e)
650        if 'latin1' not in encoding:
651            encoding.append('latin1')
652        self.encoding = encoding
653
654        def to_u(s):
655            if isinstance(s, unicode):
656                return s
657            for enc in encoding:
658                try:
659                    return unicode(s, enc)
660                except UnicodeDecodeError:
661                    pass
662        def to_s(u):
663            if isinstance(u, str):
664                return u
665            for enc in encoding:
666                try:
667                    return u.encode(enc)
668                except UnicodeEncodeError:
669                    pass
670        self.to_u = to_u
671        self.to_s = to_s
672
673        # -- repository path
674        self.path = str_path = path
675        # Note: `path` is a filesystem path obtained either from the
676        #       trac.ini file or from the `repository` table, so it's
677        #       normally an `unicode` instance. '[hg] encoding'
678        #       shouldn't play a role here, but we can nevertheless
679        #       use that as secondary choices.
680        fsencoding = [sys.getfilesystemencoding() or 'utf-8'] + encoding
681        str_path = checked_encode(path, fsencoding, os.path.exists)
682        if str_path is None:
683            raise TracError(_("Repository path '%(path)s' does not exist.", 
684                              path=path))
685        try:
686            self.repo = hg.repository(ui=self.ui, path=str_path)
687        except RepoError, e:
688            version = connector._version
689            error = exception_to_unicode(e)
690            log.error("Mercurial %s can't open repository (%s)", version, error)
691            raise TracError(_("'%(path)s' does not appear to contain a"
692                              " repository (Mercurial %(version)s says "
693                              "%(error)s)",
694                              path=path, version=version, error=error))
695        Repository.__init__(self, 'hg:%s' % path, params, log)
696
697    def from_hg_time(self, timeinfo):
698        time, tz = timeinfo
699        tzinfo = FixedOffset(tz / 60, 'GMT %d:00' % (tz / 3600))
700        return datetime.fromtimestamp(time, tzinfo)
701
702    def changectx(self, rev=None):
703        """Produce a Mercurial `context.changectx` from given Trac revision."""
704        return self.repo[self.short_rev(rev)]
705
706    def close(self):
707        self.repo = None
708
709    def normalize_path(self, path):
710        """Remove leading "/" (even at root)"""
711        return path and path.strip('/') or ''
712
713    def normalize_rev(self, rev):
714        """Return the full hash for the specified rev."""
715        return self.changectx(rev).hex()
716
717    def short_rev(self, rev):
718        """Find Mercurial revision number corresponding to given Trac revision.
719
720        :param rev: any kind of revision specification, either an
721                    `unicode` string, or a revision number.  If `None`
722                    or '', latest revision will be returned.
723
724        :return: an integer revision
725        """
726        repo = self.repo
727        if rev == 0:
728            return rev
729        if not rev:
730            return len(repo) - 1
731        if isinstance(rev, (long, int)):
732            return rev
733        if rev[0] != "'": # "'11:11'" can be a tag name?
734            rev = rev.split(':', 1)[0]
735            if rev == '-1':
736                return nullrev
737            if rev.isdigit():
738                r = int(rev)
739                if 0 <= r < len(repo):
740                    return r
741        try:
742            return repo[repo.lookup(self.to_s(rev))].rev()
743        except (HgLookupError, RepoError):
744            import pdb; pdb.set_trace()
745            raise NoSuchChangeset(rev)
746
747    def display_rev(self, rev):
748        return self._display(self.changectx(rev))
749
750    def _display(self, ctx):
751        """Return user-readable revision information for node `n`.
752
753        The specific format depends on the `node_format` and
754        `show_rev` options.
755        """
756        nodestr = self._node_fmt == "hex" and ctx.hex() or str(ctx)
757        if self._show_rev:
758            return '%s:%s' % (ctx.rev(), nodestr)
759        else:
760            return nodestr
761
762    def get_quickjump_entries(self, rev):
763        # map ctx to (unicode) branch
764        branches = {}
765        closed_branches = {}
766        for b, n in self.repo.branchtags().items():
767            b = self.to_u(b)
768            ctx = self.repo[n]
769            if 'close' in ctx.extra():
770                closed_branches[ctx] = b
771            else:
772                branches[ctx] = b
773        # map node to tag names
774        tags = {}
775        tagslist = self.repo.tagslist()
776        for tag, n in tagslist:
777            tags.setdefault(n, []).append(self.to_u(tag))
778        def taginfo(ctx):
779            t = tags.get(ctx.node())
780            if t:
781                return ' (%s)' % ', '.join(t)
782            else:
783                return ''
784        # branches
785        for ctx, b in sorted(branches.items(), reverse=True, 
786                             key=lambda (ctx, b): ctx.rev()):
787            yield ('branches', b + taginfo(ctx), '/', self._display(ctx))
788        # heads
789        for n in self.repo.heads():
790            ctx = self.repo[n]
791            if ctx not in branches and ctx not in closed_branches:
792                h = self._display(ctx)
793                yield ('extra heads', h + taginfo(ctx), '/', h)
794        # tags
795        for t, n in reversed(tagslist):
796            try:
797                yield ('tags', ', '.join(tags.pop(n)),  # FIXME: pop?
798                       '/', self._display(self.repo[n]))
799            except KeyError:
800                pass
801        # closed branches
802        for ctx, b in sorted(closed_branches.items(), reverse=True, 
803                             key=lambda (ctx, b): ctx.rev()):
804            yield ('closed branches', b + taginfo(ctx), '/', self._display(ctx))
805
806    def get_path_url(self, path, rev):
807        url = self.params.get('url')
808        if url and (not path or path == '/'):
809            if not rev:
810                return url
811            branch = self.changectx(rev).branch()
812            if branch == 'default':
813                return url
814            return url + '#' + self.to_u(branch) # URL for cloning that branch
815
816            # Note: link to matching location in Mercurial's file browser
817            #rev = rev is not None and short(n) or 'tip'
818            #return '/'.join([url, 'file', rev, path])
819 
820    def get_changeset(self, rev):
821        return MercurialChangeset(self, self.changectx(rev))
822
823    def get_changeset_uid(self, rev):
824        return self.changectx(rev).hex()
825
826    def get_changesets(self, start, stop):
827        """Follow each head and parents in order to get all changesets
828
829        FIXME: this can only be handled correctly and efficiently by
830        using the db repository cache.
831       
832        The code below is only an heuristic, and doesn't work in the
833        general case. E.g. look at the mercurial repository timeline
834        for 2006-10-18, you need to give ''38'' daysback in order to
835        see the changesets from 2006-10-17...
836
837        This is because of the following '''linear''' sequence of csets:
838          - 3445:233c733e4af5         10/18/2006 9:08:36 AM mpm
839          - 3446:0b450267cf47         9/10/2006 3:25:06 AM  hopper
840          - 3447:ef1032c223e7         9/10/2006 3:25:06 AM  hopper
841          - 3448:6ca49c5fe268         9/10/2006 3:25:07 AM  hopper
842          - 3449:c8686e3f0291         10/18/2006 9:14:26 AM hopper
843
844          This is most probably because [3446:3448] correspond to old
845          changesets that have been ''hg import''ed, with their
846          original dates.
847        """
848        seen = {nullrev: 1}
849        seeds = [self.repo[n] for n in self.repo.heads()]
850        while seeds:
851            ctx = seeds.pop(0)
852            time = self.from_hg_time(ctx.date())
853            if time < start:
854                continue # assume no ancestor is younger and use next seed
855                # (and that assumption is wrong for 3448 in the example above)
856            elif time < stop:
857                yield MercurialChangeset(self, ctx)
858            for p in ctx.parents():
859                if p.rev() not in seen:
860                    seen[p.rev()] = 1
861                    seeds.append(p)
862
863    def get_node(self, path, rev=None):
864        return MercurialNode(self, self.normalize_path(path), 
865                             self.changectx(rev))
866
867    def get_oldest_rev(self):
868        return nullrev
869
870    def get_youngest_rev(self):
871        return self.changectx().hex()
872   
873    def previous_rev(self, rev, path=''): # FIXME: path ignored for now
874        for parent_rev in self.changectx(rev).ancestors():
875            return parent_rev.hex()
876        return None
877   
878    def next_rev(self, rev, path=''):
879        for following_rev in self.changectx(rev).descendants():
880            return following_rev.hex()
881        return None
882   
883    def rev_older_than(self, rev1, rev2):
884        # FIXME use == and ancestors?
885        return self.short_rev(rev1) < self.short_rev(rev2)
886
887#    def get_path_history(self, path, rev=None, limit=None):
888#      (not really relevant for Mercurial)
889
890    def get_changes(self, old_path, old_rev, new_path, new_rev,
891                    ignore_ancestry=1):
892        """Generates changes corresponding to generalized diffs.
893       
894        Generator that yields change tuples (old_node, new_node, kind,
895        change) for each node change between the two arbitrary
896        (path,rev) pairs.
897
898        The old_node is assumed to be None when the change is an ADD,
899        the new_node is assumed to be None when the change is a
900        DELETE.
901        """
902        old_node = new_node = None
903        old_node = self.get_node(old_path, old_rev)
904        new_node = self.get_node(new_path, new_rev)
905        # check kind, both should be same.
906        if new_node.kind != old_node.kind:
907            raise TracError(
908                _("Diff mismatch: "
909                  "Base is a %(okind)s (%(opath)s in revision %(orev)s) "
910                  "and Target is a %(nkind)s (%(npath)s in revision %(nrev)s).",
911                  okind=old_node.kind, opath=old_path, orev=old_rev,
912                  nkind=new_node.kind, npath=new_path, nrev=new_rev))
913        # Correct change info from changelog(revlog)
914        # Finding changes between two revs requires tracking back
915        # several routes.
916                             
917        if new_node.isdir:
918            # TODO: Should we follow rename and copy?
919            # As temporary workaround, simply compare entry names.
920            changes = []
921            str_new_path = self.to_s(new_path)
922            str_old_path = self.to_s(old_path)
923            # additions and edits
924            for str_path in new_node.manifest:
925                # changes out of scope
926                if str_new_path and not str_path.startswith(str_new_path + '/'):
927                    continue
928                # 'added' if not present in old manifest
929                str_op = str_old_path + str_path[len(str_new_path):]
930                if str_op not in old_node.manifest:
931                    changes.append((str_path, None, new_node.subnode(str_path),
932                                    Node.FILE, Changeset.ADD))
933                elif old_node.manifest[str_op] != new_node.manifest[str_path]:
934                    changes.append((str_path, old_node.subnode(str_op),
935                                    new_node.subnode(str_path),
936                                    Node.FILE, Changeset.EDIT))
937            # deletions
938            for str_path in old_node.manifest:
939                # changes out of scope
940                if str_old_path and not str_path.startswith(str_old_path + '/'):
941                    continue
942                # 'deleted' if not present in new manifest
943                str_np = str_new_path + str_path[len(str_old_path):]
944                if str_np not in new_node.manifest:
945                    changes.append((str_path, old_node.subnode(str_np), None,
946                                    Node.FILE, Changeset.DELETE))
947            # Note: `str_path` only used as a key, no need to convert to_u
948            for change in sorted(changes, key=lambda c: c[0]):
949                yield(change[1], change[2], change[3], change[4])
950        else:
951            if old_node.manifest[old_node.str_path] != \
952                   new_node.manifest[new_node.str_path]:
953                yield(old_node, new_node, Node.FILE, Changeset.EDIT)
954
955
956class MercurialNode(Node):
957    """A path in the repository, at a given revision.
958
959    It encapsulates the repository manifest for the given revision.
960
961    As directories are not first-class citizens in Mercurial,
962    retrieving revision information for directory can be much slower
963    than for files, except when created as a `subnode()` of an
964    existing MercurialNode.
965    """
966   
967    filectx = dirnode = None
968
969    def __init__(self, repos, path, changectx, 
970                 manifest=None, dirctx=None, str_entry=None):
971        """
972        :param repos: the `MercurialRepository`
973        :param path: the `unicode` path corresponding to this node
974        :param rev: requested revision (i.e. "browsing at")
975        :param changectx: the `changectx` for the  "requested" revision
976
977        The following parameters are passed when creating a subnode
978        instance:
979
980        :param manifest: `manifest` object from parent `MercurialNode`
981        :param dirctx: `changectx` for a directory determined by
982                       parent `MercurialNode`
983        :param str_entry: entry name if node created from parent node
984        """
985        repo = repos.repo
986        self.repos = repos
987        self.changectx = changectx
988        self.manifest = manifest or changectx.manifest()
989        str_entries = []
990
991        if path == '' or path == '/':
992            str_path = ''
993        elif dirctx:
994            str_path = str_entry
995        else:
996            # Fast path: check for existing file
997            str_path = checked_encode(path, repos.encoding, 
998                                      lambda s: s in self.manifest)
999            if str_path is None:
1000                # Slow path: this might be a directory node
1001                str_files = sorted(self.manifest)
1002                idx = [-1]
1003                def has_dir_node(str_dir):
1004                    if not str_dir: # no encoding matched, i.e. not existing
1005                        return False
1006                    idx[0] = lo = bisect(str_files, str_dir)
1007                    return lo < len(str_files) \
1008                           and str_files[lo].startswith(str_dir)
1009                str_path = checked_encode(path + '/', repos.encoding,
1010                                          has_dir_node)
1011                if str_path is None:
1012                    raise NoSuchNode(path, changectx.hex())
1013                lo = idx[0]
1014                for hi in xrange(lo, len(str_files)):
1015                    if not str_files[hi].startswith(str_path):
1016                        break
1017                str_path = str_path[:-1]
1018                str_entries = str_files[lo:hi]
1019        self.str_path = str_path
1020
1021        # Determine `kind`, `rev` (requested rev) and `created_rev`
1022        # (last changed revision before requested rev)
1023
1024        kind = None
1025        rev = changectx.rev()
1026        if str_path == '':
1027            kind = Node.DIRECTORY
1028            dirctx = changectx
1029        elif str_path in self.manifest: # then it's a file
1030            kind = Node.FILE
1031            self.filectx = changectx.filectx(str_path)
1032            created_rev = self.filectx.linkrev()
1033            # FIXME (0.13) this is a hack, we should fix that at the
1034            #       Trac level, which should really show the
1035            #       created_rev value for files in the browser.
1036            rev = created_rev
1037        else: # we already know it's a dir
1038            kind = Node.DIRECTORY
1039            if not dirctx:
1040                # we need to find the most recent change for a file below dir
1041                str_dir = str_path + '/'
1042                dirctxs = self.find_dirctx(changectx.rev(), [str_dir,],
1043                                           {str_dir: str_entries})
1044                dirctx = dirctxs.values()[0]
1045
1046        if not kind:
1047            if repo.changelog.tip() == nullid or \
1048                    not (self.manifest or str_path):
1049                # empty or emptied repository
1050                kind = Node.DIRECTORY
1051                dirctx = changectx
1052            else:
1053                raise NoSuchNode(path, changectx.hex())
1054
1055        self.time = self.repos.from_hg_time(changectx.date())
1056        if dirctx is not None:
1057            # FIXME (0.13) same remark as above
1058            rev = created_rev = dirctx.rev()
1059        Node.__init__(self, self.repos, path, rev or '0', kind)
1060        self.created_path = path
1061        self.created_rev = created_rev
1062        self.data = None
1063
1064    def find_dirctx(self, max_rev, str_dirnames, str_entries):
1065        """Find most recent modification for each given directory path.
1066       
1067        :param max_rev: find no revision more recent than this one
1068        :param str_dirnames: directory paths to consider
1069                             (as `str` ending with '/')
1070        :param str_entries: optionally maps directories to their file content
1071
1072        :return: a `dict` with `str_dirnames` as keys, `changectx` as values
1073
1074        As directories are not first-class citizens in Mercurial, this
1075        operation is not trivial. There are basically two strategies:
1076
1077         - for each file below the given directories, retrieve the
1078           linkrev (most recent modification for this file), and take
1079           the max; this approach is very inefficient for repositories
1080           containing many files (#7746)
1081
1082         - retrieve the files modified when going backward through the
1083           changelog and detect the first occurrence of a change in
1084           each directory; this is much faster but can still be slow
1085           if some folders are only modified in the distant past
1086           
1087        It is possible to combine both approach, and this can yield
1088        excellent results in some cases (e.g. browsing the Linux repos
1089        @ 118733 takes several minutes with the first approach, 11s
1090        with the second, but only 1.2s with the hybrid approach)
1091
1092        Note that the specialized scan of the changelog we do below is
1093        more efficient than the general cmdutil.walkchangerevs here.
1094        """
1095        str_dirctxs = {}
1096        repo = self.repos.repo
1097        max_ctx = repo[max_rev]
1098        for r in xrange(max_rev, -1, -1):
1099            ctx = repo[r]
1100            # lookup changes to str_dirnames in current cset
1101            for str_file in ctx.files():
1102                for str_dir in str_dirnames[:]:
1103                    if str_file.startswith(str_dir):
1104                        str_dirctxs[str_dir] = ctx
1105                        str_dirnames.remove(str_dir)
1106                        if not str_dirnames: # if nothing left to find
1107                            return str_dirctxs
1108            # in parallel, try the filelog strategy (the 463, 2, 40
1109            # values below look a bit like magic numbers; actually
1110            # they were selected by testing the plugin on the Linux
1111            # and NetBeans repositories)
1112            if r % 463 == 0:
1113                k = max(2, 40 / len(str_dirnames))
1114                for str_dir in str_dirnames[:]:
1115                    str_files = str_entries[str_dir]
1116                    dr = str_dirctxs.get(str_dir, 0)
1117                    for f in str_files[:k]:
1118                        dr = max(dr, max_ctx.filectx(f).linkrev())
1119                    str_files = str_files[k:]
1120                    if str_files:
1121                        str_entries[str_dir] = str_files
1122                        str_dirctxs[str_dir] = dr
1123                    else:
1124                        str_dirctxs[str_dir] = repo[dr]
1125                        str_dirnames.remove(str_dir)
1126                        if not str_dirnames:
1127                            return str_dirctxs
1128                       
1129               
1130    def subnode(self, str_path, subctx=None):
1131        """Return a node with the same revision information but for
1132        another path
1133
1134        :param str_path: should be the an existing entry in the manifest
1135        """
1136        return MercurialNode(self.repos, self.repos.to_u(str_path),
1137                             self.changectx, self.manifest, subctx, str_path)
1138
1139    def get_content(self):
1140        if self.isdir:
1141            return None
1142        self.pos = 0 # reset the read()
1143        return self # something that can be `read()` ...
1144
1145    def read(self, size=None):
1146        if self.isdir:
1147            return TracError(_("Can't read from directory %(path)s", 
1148                               path=self.path))
1149        if self.data is None:
1150            self.data = self.filectx.data()
1151            self.pos = 0
1152        if size:
1153            prev_pos = self.pos
1154            self.pos += size
1155            return self.data[prev_pos:self.pos]
1156        return self.data
1157
1158    def get_entries(self):
1159        if self.isfile:
1160            return
1161       
1162        # dirnames are entries which are sub-directories
1163        str_entries = {}
1164        str_dirnames = []
1165        def add_entry(str_file, idx):
1166            str_entry = str_file
1167            if idx > -1: # directory
1168                str_entry = str_file[:idx + 1]
1169                str_files = str_entries.setdefault(str_entry, [])
1170                if not str_files:
1171                    str_dirnames.append(str_entry)
1172                str_files.append(str_file)
1173            else:
1174                str_entries[str_entry] = 1
1175
1176        if self.str_path:
1177            str_dir = self.str_path + '/'
1178            for str_file in self.manifest:
1179                if str_file.startswith(str_dir):
1180                    add_entry(str_file, str_file.find('/', len(str_dir)))
1181        else:
1182            for str_file in self.manifest:
1183                add_entry(str_file, str_file.find('/'))
1184
1185        # pre-computing the changectx for the last change in each sub-directory
1186        if str_dirnames:
1187            dirctxs = self.find_dirctx(self.created_rev, str_dirnames, 
1188                                       str_entries)
1189        else:
1190            dirctxs = {}
1191
1192        for str_entry in str_entries:
1193            yield self.subnode(str_entry.rstrip('/'),
1194                               dirctxs.get(str_entry, None))
1195
1196    def get_history(self, limit=None):
1197        repo = self.repos.repo
1198        pats = []
1199        if self.str_path:
1200            pats.append('path:' + self.str_path)
1201        opts = {'rev': ['%s:0' % self.changectx.hex()]}
1202        if self.isfile:
1203            opts['follow'] = True
1204        if arity(cmdutil.walkchangerevs) == 4:
1205            return self._get_history_1_4(repo, pats, opts, limit)
1206        else:
1207            return self._get_history_1_3(repo, pats, opts, limit)
1208
1209    def _get_history_1_4(self, repo, pats, opts, limit):
1210        matcher = cmdutil.match(repo, pats, opts)
1211        if self.isfile:
1212            fncache = {}
1213            def prep(ctx, fns):
1214                if self.isfile:
1215                    fncache[ctx.rev()] = self.repos.to_u(fns[0])
1216        else:
1217            def prep(ctx, fns):
1218                pass
1219
1220        # keep one lookahead entry so that we can detect renames
1221        path = self.path
1222        entry = None
1223        count = 0
1224        for ctx in cmdutil.walkchangerevs(repo, matcher, opts, prep):
1225            if self.isfile and entry:
1226                path = fncache[ctx.rev()]
1227                if path != entry[0]:
1228                    entry = entry[0:2] + (Changeset.COPY,)
1229            if entry:
1230                yield entry
1231                count += 1
1232                if limit is not None and count >= limit:
1233                    return
1234            entry = (path, ctx.hex(), Changeset.EDIT)
1235        if entry:
1236            if limit is None or count < limit:
1237                entry = entry[0:2] + (Changeset.ADD,)
1238            yield entry
1239
1240    def _get_history_1_3(self, repo, pats, opts, limit):
1241        if self.repos.version_info > (1, 3, 999):
1242            changefn = lambda r: repo[r]
1243        else:
1244            changefn = lambda r: repo[r].changeset()
1245        get = cachefunc(changefn)
1246        if self.isfile:
1247            fncache = {}
1248        chgiter, matchfn = cmdutil.walkchangerevs(self.repos.ui, repo, pats, 
1249                                                  get, opts)
1250        # keep one lookahead entry so that we can detect renames
1251        path = self.path
1252        entry = None
1253        count = 0
1254        for st, rev, fns in chgiter:
1255            if st == 'add' and self.isfile:
1256                fncache[rev] = self.repos.to_u(fns[0])
1257            elif st == 'iter':
1258                if self.isfile and entry:
1259                    path = fncache[rev]
1260                    if path != entry[0]:
1261                        entry = entry[0:2] + (Changeset.COPY,)
1262                if entry:
1263                    yield entry
1264                    count += 1
1265                    if limit is not None and count >= limit:
1266                        return
1267                n = repo.changelog.node(rev)
1268                entry = (path, hex(n), Changeset.EDIT)
1269        if entry:
1270            if limit is None or count < limit:
1271                entry = entry[0:2] + (Changeset.ADD,)
1272            yield entry
1273
1274    def get_annotations(self):
1275        annotations = []
1276        if self.filectx:
1277            for fc, line in self.filectx.annotate(follow=True):
1278                annotations.append(fc.rev() or '0')
1279        return annotations
1280       
1281    def get_properties(self):
1282        if self.isfile and 'x' in self.manifest.flags(self.str_path):
1283            return {'exe': '*'}
1284        else:
1285            return {}
1286
1287    def get_content_length(self):
1288        if self.isdir:
1289            return None
1290        return self.filectx.size()
1291
1292    def get_content_type(self):
1293        if self.isdir:
1294            return None
1295        if 'mq' in self.repos.params: # FIXME
1296            if self.str_path not in ('.hgignore', 'series'):
1297                return 'text/x-diff'
1298        return ''
1299
1300    def get_last_modified(self):
1301        return self.time
1302
1303
1304class MercurialChangeset(Changeset):
1305    """A changeset in the repository.
1306
1307    This wraps the corresponding information from the changelog.  The
1308    files changes are obtained by comparing the current manifest to
1309    the parent manifest(s).
1310    """
1311   
1312    def __init__(self, repos, ctx):
1313        self.repos = repos
1314        self.ctx = ctx
1315        self.branch = self.repos.to_u(ctx.branch())
1316        # Note: desc and time are already processed by hg's
1317        # `encoding.tolocal`; by setting $HGENCODING to latin1, we are
1318        # however guaranteed to get back the bytes as they were
1319        # stored.
1320        desc = repos.to_u(ctx.description())
1321        user = repos.to_u(ctx.user())
1322        time = repos.from_hg_time(ctx.date())
1323        Changeset.__init__(self, repos, ctx.hex(), desc, user, time)
1324
1325    hg_properties = [
1326        N_("Parents:"), N_("Children:"), N_("Branch:"), N_("Tags:")
1327    ]
1328
1329    def get_properties(self):
1330        properties = {}
1331        parents = self.ctx.parents()
1332        if len(parents) > 1:
1333            properties['hg-Parents'] = (self.repos, 
1334                                        [p.hex() for p in parents if p])
1335        children = self.ctx.children()
1336        if len(children) > 1:
1337            properties['hg-Children'] = (self.repos,
1338                                         [c.hex() for c in children])
1339        if self.branch:
1340            properties['hg-Branch'] = (self.repos, [self.branch])
1341        tags = self.ctx.tags()
1342        if len(tags):
1343            properties['hg-Tags'] = (self.repos, 
1344                                     [self.repos.to_u(t) for t in tags])
1345        for k, v in self.ctx.extra().iteritems():
1346            if k != 'branch':
1347                properties['hg-' + k] = (self.repos, v)
1348        return properties
1349
1350    def get_changes(self):
1351        u = self.repos.to_u
1352        repo = self.repos.repo
1353        manifest = self.ctx.manifest()
1354        parents = self.ctx.parents()
1355
1356        renames = []
1357        str_deletions = {}
1358        changes = []
1359        for str_file in self.ctx.files(): # added, edited and deleted files
1360            f = u(str_file)
1361            # TODO: find a way to detect conflicts and show how they were
1362            #       solved (kind of 3-way diff - theirs/mine/merged)
1363            edits = [p for p in parents if str_file in p.manifest()]
1364            edits = edits[:1]
1365
1366            if str_file not in manifest:
1367                str_deletions[str_file] = edits[0]
1368            elif edits:
1369                for p in edits:
1370                    changes.append((f, Node.FILE, Changeset.EDIT, f, p.rev()))
1371            else:
1372                renamed = repo.file(str_file).renamed(manifest[str_file])
1373                if renamed:
1374                    renames.append((f, renamed))
1375                else:
1376                    changes.append((f, Node.FILE, Changeset.ADD, '', None))
1377        # distinguish between move and copy
1378        for f, (str_base_path, base_filenode) in renames:
1379            base_ctx = repo.filectx(str_base_path, fileid=base_filenode)
1380            if str_base_path in str_deletions:
1381                del str_deletions[str_base_path]
1382                action = Changeset.MOVE
1383            else:
1384                action = Changeset.COPY
1385            changes.append((f, Node.FILE, action, u(str_base_path), 
1386                            base_ctx.rev()))
1387        # remaining str_deletions are real deletions
1388        for str_file, p in str_deletions.items():
1389            f = u(str_file)
1390            changes.append((f, Node.FILE, Changeset.DELETE, f, p.rev()))
1391        changes.sort()
1392        for change in changes:
1393            yield change
1394
1395    def get_branches(self):
1396        """Yield branch names to which this changeset belong."""
1397        return self.branch and [(self.branch, 
1398                                 len(self.ctx.children()) == 0)] or []