Edgewall Software

Ticket #8417: backend.py

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

I have implemented a naive version of MecrurialCachedRepository? using a backward-traversing resync which overrides the default resync algorithm.

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        # -- compare them and try to resync if different
537        next_youngest = None
538        seen = {}
539        for bt_name, youngest in self.repos.repo.branchtags().iteritems():
540            print 'synchronizing branch/tag %s (head %s)...' % (bt_name, self.display_rev(youngest))
541
542            # 1. prepare for resyncing
543            #    (there still might be a race condition at this point)
544
545            next_youngest = youngest
546            kindmap = dict(zip(_kindmap.values(), _kindmap.keys()))
547            actionmap = dict(zip(_actionmap.values(), _actionmap.keys()))
548
549            while next_youngest is not None:
550                srev = self.db_rev(next_youngest)
551                exit = [False]
552                if not seen.has_key(srev):
553               
554                    @self.env.with_transaction()
555                    def do_transaction(db):
556                        cursor = db.cursor()
557                       
558                        # 1.1 Attempt to resync the 'revision' table
559                        self.log.info("Trying to sync revision [%s]",
560                                      next_youngest)
561                        cset = self.repos.get_changeset(next_youngest)
562                        try:
563                            cursor.execute("""
564                                INSERT INTO revision
565                                    (repos,rev,time,author,message)
566                                VALUES (%s,%s,%s,%s,%s)
567                                """, (self.id, srev, to_utimestamp(cset.date),
568                                      cset.author, cset.message))
569                            seen[srev] = True
570                        except Exception, e: # *another* 1.1. resync attempt won
571                            self.log.warning('Revision %s already cached: %r',
572                                             next_youngest, e)
573                            # also potentially in progress, so keep ''previous''
574                            # notion of 'youngest'
575                            self.repos.clear(youngest_rev=youngest)
576                            # FIXME: This aborts a containing transaction
577                            db.rollback()
578                            exit[0] = True
579                            return
580       
581                        # 1.2. now *only* one process was able to get there
582                        #      (i.e. there *shouldn't* be any race condition here)
583       
584                        for path, kind, action, bpath, brev in cset.get_changes():
585                            self.log.debug("Caching node change in [%s]: %r",
586                                           next_youngest,
587                                           (path, kind, action, bpath, brev))
588                            kind = kindmap[kind]
589                            action = actionmap[action]
590                            cursor.execute("""
591                                INSERT INTO node_change
592                                    (repos,rev,path,node_type,
593                                     change_type,base_path,base_rev)
594                                VALUES (%s,%s,%s,%s,%s,%s,%s)
595                                """, (self.id, srev, path, kind, action, bpath,
596                                      brev))
597
598                        # FIXME: it is not necessary to update everytime in the loop.
599                        cursor.execute("""
600                            UPDATE repository SET value=%s WHERE id=%s AND name=%s
601                            """, (str(self.repos.changectx().hex()), self.id, CACHE_YOUNGEST_REV))
602
603                if exit[0]:
604                    return
605               
606                # 1.4. iterate (1.1 should always succeed now)
607                youngest = next_youngest
608                next_youngest = self.repos.previous_rev(next_youngest)
609
610                # 1.5. provide some feedback
611                if feedback:
612                    feedback(youngest)
613
614    def get_node(self, path, rev=None):
615        return self.repos.get_node(path, self.normalize_rev(rev))
616
617    def _get_node_revs(self, path, last=None, first=None):
618        """Return the revisions affecting `path` between `first` and `last`
619        revisions.
620        """
621        last = self.normalize_rev(last)
622        slast = self.db_rev(last)
623        node = self.get_node(path, last)    # Check node existence
624        db = self.env.get_db_cnx()
625        cursor = db.cursor()
626        if first is None:
627            cursor.execute("SELECT rev FROM node_change "
628                           "WHERE repos=%s AND rev<=%s "
629                           "  AND path=%s "
630                           "  AND change_type IN ('A', 'C', 'M') "
631                           "ORDER BY rev DESC LIMIT 1",
632                           (self.id, slast, path))
633            first = 0
634            for row in cursor:
635                first = int(row[0])
636        sfirst = self.db_rev(first)
637        cursor.execute("SELECT DISTINCT rev FROM node_change "
638                       "WHERE repos=%%s AND rev>=%%s AND rev<=%%s "
639                       " AND (path=%%s OR path %s)" % db.like(),
640                       (self.id, sfirst, slast, path,
641                        db.like_escape(path + '/') + '%'))
642        return [int(row[0]) for row in cursor]
643
644class MercurialCachedChangeset(CachedChangeset):
645    pass
646
647### Version Control API
648   
649class MercurialRepository(Repository):
650    """Repository implementation based on the mercurial API.
651
652    This wraps an hg.repository object.  The revision navigation
653    follows the branches, and defaults to the first parent/child in
654    case there are many.  The eventual other parents/children are
655    listed as additional changeset properties.
656    """
657
658    def __init__(self, path, params, log, connector):
659        self.ui = connector.ui
660        self._show_rev = connector.show_rev
661        self._node_fmt = connector.node_format
662        # TODO 0.13: per repository ui and options
663
664        # -- encoding
665        encoding = connector.encoding
666        if not encoding:
667            encoding = ['utf-8']
668        # verify given encodings
669        for enc in encoding:
670            try:
671                u''.encode(enc)
672            except LookupError, e:
673                log.warning("'[hg] encoding' (%r) not valid", e)
674        if 'latin1' not in encoding:
675            encoding.append('latin1')
676        self.encoding = encoding
677
678        def to_u(s):
679            if isinstance(s, unicode):
680                return s
681            for enc in encoding:
682                try:
683                    return unicode(s, enc)
684                except UnicodeDecodeError:
685                    pass
686        def to_s(u):
687            if isinstance(u, str):
688                return u
689            for enc in encoding:
690                try:
691                    return u.encode(enc)
692                except UnicodeEncodeError:
693                    pass
694        self.to_u = to_u
695        self.to_s = to_s
696
697        # -- repository path
698        self.path = str_path = path
699        # Note: `path` is a filesystem path obtained either from the
700        #       trac.ini file or from the `repository` table, so it's
701        #       normally an `unicode` instance. '[hg] encoding'
702        #       shouldn't play a role here, but we can nevertheless
703        #       use that as secondary choices.
704        fsencoding = [sys.getfilesystemencoding() or 'utf-8'] + encoding
705        str_path = checked_encode(path, fsencoding, os.path.exists)
706        if str_path is None:
707            raise TracError(_("Repository path '%(path)s' does not exist.", 
708                              path=path))
709        try:
710            self.repo = hg.repository(ui=self.ui, path=str_path)
711        except RepoError, e:
712            version = connector._version
713            error = exception_to_unicode(e)
714            log.error("Mercurial %s can't open repository (%s)", version, error)
715            raise TracError(_("'%(path)s' does not appear to contain a"
716                              " repository (Mercurial %(version)s says "
717                              "%(error)s)",
718                              path=path, version=version, error=error))
719        Repository.__init__(self, 'hg:%s' % path, params, log)
720
721    def from_hg_time(self, timeinfo):
722        time, tz = timeinfo
723        tzinfo = FixedOffset(tz / 60, 'GMT %d:00' % (tz / 3600))
724        return datetime.fromtimestamp(time, tzinfo)
725
726    def changectx(self, rev=None):
727        """Produce a Mercurial `context.changectx` from given Trac revision."""
728        return self.repo[self.short_rev(rev)]
729
730    def close(self):
731        self.repo = None
732
733    def normalize_path(self, path):
734        """Remove leading "/" (even at root)"""
735        return path and path.strip('/') or ''
736
737    def normalize_rev(self, rev):
738        """Return the full hash for the specified rev."""
739        return self.changectx(rev).hex()
740
741    def short_rev(self, rev):
742        """Find Mercurial revision number corresponding to given Trac revision.
743
744        :param rev: any kind of revision specification, either an
745                    `unicode` string, or a revision number.  If `None`
746                    or '', latest revision will be returned.
747
748        :return: an integer revision
749        """
750        repo = self.repo
751        if rev == 0:
752            return rev
753        if not rev:
754            return len(repo) - 1
755        if isinstance(rev, (long, int)):
756            return rev
757        if rev[0] != "'": # "'11:11'" can be a tag name?
758            rev = rev.split(':', 1)[0]
759            if rev == '-1':
760                return nullrev
761            if rev.isdigit():
762                r = int(rev)
763                if 0 <= r < len(repo):
764                    return r
765        try:
766            return repo[repo.lookup(self.to_s(rev))].rev()
767        except (HgLookupError, RepoError):
768            raise NoSuchChangeset(rev)
769
770    def display_rev(self, rev):
771        return self._display(self.changectx(rev))
772
773    def _display(self, ctx):
774        """Return user-readable revision information for node `n`.
775
776        The specific format depends on the `node_format` and
777        `show_rev` options.
778        """
779        nodestr = self._node_fmt == "hex" and ctx.hex() or str(ctx)
780        if self._show_rev:
781            return '%s:%s' % (ctx.rev(), nodestr)
782        else:
783            return nodestr
784
785    def get_quickjump_entries(self, rev):
786        # map ctx to (unicode) branch
787        branches = {}
788        closed_branches = {}
789        for b, n in self.repo.branchtags().items():
790            b = self.to_u(b)
791            ctx = self.repo[n]
792            if 'close' in ctx.extra():
793                closed_branches[ctx] = b
794            else:
795                branches[ctx] = b
796        # map node to tag names
797        tags = {}
798        tagslist = self.repo.tagslist()
799        for tag, n in tagslist:
800            tags.setdefault(n, []).append(self.to_u(tag))
801        def taginfo(ctx):
802            t = tags.get(ctx.node())
803            if t:
804                return ' (%s)' % ', '.join(t)
805            else:
806                return ''
807        # branches
808        for ctx, b in sorted(branches.items(), reverse=True, 
809                             key=lambda (ctx, b): ctx.rev()):
810            yield ('branches', b + taginfo(ctx), '/', self._display(ctx))
811        # heads
812        for n in self.repo.heads():
813            ctx = self.repo[n]
814            if ctx not in branches and ctx not in closed_branches:
815                h = self._display(ctx)
816                yield ('extra heads', h + taginfo(ctx), '/', h)
817        # tags
818        for t, n in reversed(tagslist):
819            try:
820                yield ('tags', ', '.join(tags.pop(n)),  # FIXME: pop?
821                       '/', self._display(self.repo[n]))
822            except KeyError:
823                pass
824        # closed branches
825        for ctx, b in sorted(closed_branches.items(), reverse=True, 
826                             key=lambda (ctx, b): ctx.rev()):
827            yield ('closed branches', b + taginfo(ctx), '/', self._display(ctx))
828
829    def get_path_url(self, path, rev):
830        url = self.params.get('url')
831        if url and (not path or path == '/'):
832            if not rev:
833                return url
834            branch = self.changectx(rev).branch()
835            if branch == 'default':
836                return url
837            return url + '#' + self.to_u(branch) # URL for cloning that branch
838
839            # Note: link to matching location in Mercurial's file browser
840            #rev = rev is not None and short(n) or 'tip'
841            #return '/'.join([url, 'file', rev, path])
842 
843    def get_changeset(self, rev):
844        return MercurialChangeset(self, self.changectx(rev))
845
846    def get_changeset_uid(self, rev):
847        return self.changectx(rev).hex()
848
849    def get_changesets(self, start, stop):
850        """Follow each head and parents in order to get all changesets
851
852        FIXME: this can only be handled correctly and efficiently by
853        using the db repository cache.
854       
855        The code below is only an heuristic, and doesn't work in the
856        general case. E.g. look at the mercurial repository timeline
857        for 2006-10-18, you need to give ''38'' daysback in order to
858        see the changesets from 2006-10-17...
859
860        This is because of the following '''linear''' sequence of csets:
861          - 3445:233c733e4af5         10/18/2006 9:08:36 AM mpm
862          - 3446:0b450267cf47         9/10/2006 3:25:06 AM  hopper
863          - 3447:ef1032c223e7         9/10/2006 3:25:06 AM  hopper
864          - 3448:6ca49c5fe268         9/10/2006 3:25:07 AM  hopper
865          - 3449:c8686e3f0291         10/18/2006 9:14:26 AM hopper
866
867          This is most probably because [3446:3448] correspond to old
868          changesets that have been ''hg import''ed, with their
869          original dates.
870        """
871        seen = {nullrev: 1}
872        seeds = [self.repo[n] for n in self.repo.heads()]
873        while seeds:
874            ctx = seeds.pop(0)
875            time = self.from_hg_time(ctx.date())
876            if time < start:
877                continue # assume no ancestor is younger and use next seed
878                # (and that assumption is wrong for 3448 in the example above)
879            elif time < stop:
880                yield MercurialChangeset(self, ctx)
881            for p in ctx.parents():
882                if p.rev() not in seen:
883                    seen[p.rev()] = 1
884                    seeds.append(p)
885
886    def get_node(self, path, rev=None):
887        return MercurialNode(self, self.normalize_path(path), 
888                             self.changectx(rev))
889
890    def get_oldest_rev(self):
891        return nullrev
892
893    def get_youngest_rev(self):
894        return self.changectx().hex()
895   
896    def previous_rev(self, rev, path=''): # FIXME: path ignored for now
897        for parent_rev in self.changectx(rev).ancestors():
898            return parent_rev.hex()
899        return None
900        #for p in self.changectx(rev).parents():
901        #    if p:
902        #        return p.hex() # always follow first parent
903   
904    def next_rev(self, rev, path=''):
905        for following_rev in self.changectx(rev).descendants():
906            return following_rev.hex()
907        return None
908       
909        #ctx = self.changectx(rev)
910        #if path: # might be a file
911        #    fc = filectx(self.repo, self.to_s(path), ctx.node())
912        #    # Note: the simpler form below raises an HgLookupError for a dir
913        #    # fc = ctx.filectx(self.to_s(path))
914        #    if fc: # it is a file
915        #        for c in fc.children():
916        #            return c.hex()
917        #        else:
918        #            return None
919        ## it might be a directory (not supported for now) FIXME
920        #for c in ctx.children():
921        #    return c.hex() # always follow first child
922   
923    def rev_older_than(self, rev1, rev2):
924        # FIXME use == and ancestors?
925        return self.short_rev(rev1) < self.short_rev(rev2)
926
927#    def get_path_history(self, path, rev=None, limit=None):
928#      (not really relevant for Mercurial)
929
930    def get_changes(self, old_path, old_rev, new_path, new_rev,
931                    ignore_ancestry=1):
932        """Generates changes corresponding to generalized diffs.
933       
934        Generator that yields change tuples (old_node, new_node, kind,
935        change) for each node change between the two arbitrary
936        (path,rev) pairs.
937
938        The old_node is assumed to be None when the change is an ADD,
939        the new_node is assumed to be None when the change is a
940        DELETE.
941        """
942        old_node = new_node = None
943        old_node = self.get_node(old_path, old_rev)
944        new_node = self.get_node(new_path, new_rev)
945        # check kind, both should be same.
946        if new_node.kind != old_node.kind:
947            raise TracError(
948                _("Diff mismatch: "
949                  "Base is a %(okind)s (%(opath)s in revision %(orev)s) "
950                  "and Target is a %(nkind)s (%(npath)s in revision %(nrev)s).",
951                  okind=old_node.kind, opath=old_path, orev=old_rev,
952                  nkind=new_node.kind, npath=new_path, nrev=new_rev))
953        # Correct change info from changelog(revlog)
954        # Finding changes between two revs requires tracking back
955        # several routes.
956                             
957        if new_node.isdir:
958            # TODO: Should we follow rename and copy?
959            # As temporary workaround, simply compare entry names.
960            changes = []
961            str_new_path = self.to_s(new_path)
962            str_old_path = self.to_s(old_path)
963            # additions and edits
964            for str_path in new_node.manifest:
965                # changes out of scope
966                if str_new_path and not str_path.startswith(str_new_path + '/'):
967                    continue
968                # 'added' if not present in old manifest
969                str_op = str_old_path + str_path[len(str_new_path):]
970                if str_op not in old_node.manifest:
971                    changes.append((str_path, None, new_node.subnode(str_path),
972                                    Node.FILE, Changeset.ADD))
973                elif old_node.manifest[str_op] != new_node.manifest[str_path]:
974                    changes.append((str_path, old_node.subnode(str_op),
975                                    new_node.subnode(str_path),
976                                    Node.FILE, Changeset.EDIT))
977            # deletions
978            for str_path in old_node.manifest:
979                # changes out of scope
980                if str_old_path and not str_path.startswith(str_old_path + '/'):
981                    continue
982                # 'deleted' if not present in new manifest
983                str_np = str_new_path + str_path[len(str_old_path):]
984                if str_np not in new_node.manifest:
985                    changes.append((str_path, old_node.subnode(str_np), None,
986                                    Node.FILE, Changeset.DELETE))
987            # Note: `str_path` only used as a key, no need to convert to_u
988            for change in sorted(changes, key=lambda c: c[0]):
989                yield(change[1], change[2], change[3], change[4])
990        else:
991            if old_node.manifest[old_node.str_path] != \
992                   new_node.manifest[new_node.str_path]:
993                yield(old_node, new_node, Node.FILE, Changeset.EDIT)
994
995    def clear(self, youngest_rev=None):
996        self.youngest = None
997        if youngest_rev is not None:
998            self.youngest = self.normalize_rev(youngest_rev)
999        self.oldest = None
1000
1001class MercurialNode(Node):
1002    """A path in the repository, at a given revision.
1003
1004    It encapsulates the repository manifest for the given revision.
1005
1006    As directories are not first-class citizens in Mercurial,
1007    retrieving revision information for directory can be much slower
1008    than for files, except when created as a `subnode()` of an
1009    existing MercurialNode.
1010    """
1011   
1012    filectx = dirnode = None
1013
1014    def __init__(self, repos, path, changectx, 
1015                 manifest=None, dirctx=None, str_entry=None):
1016        """
1017        :param repos: the `MercurialRepository`
1018        :param path: the `unicode` path corresponding to this node
1019        :param rev: requested revision (i.e. "browsing at")
1020        :param changectx: the `changectx` for the  "requested" revision
1021
1022        The following parameters are passed when creating a subnode
1023        instance:
1024
1025        :param manifest: `manifest` object from parent `MercurialNode`
1026        :param dirctx: `changectx` for a directory determined by
1027                       parent `MercurialNode`
1028        :param str_entry: entry name if node created from parent node
1029        """
1030        repo = repos.repo
1031        self.repos = repos
1032        self.changectx = changectx
1033        self.manifest = manifest or changectx.manifest()
1034        str_entries = []
1035
1036        if path == '' or path == '/':
1037            str_path = ''
1038        elif dirctx:
1039            str_path = str_entry
1040        else:
1041            # Fast path: check for existing file
1042            str_path = checked_encode(path, repos.encoding, 
1043                                      lambda s: s in self.manifest)
1044            if str_path is None:
1045                # Slow path: this might be a directory node
1046                str_files = sorted(self.manifest)
1047                idx = [-1]
1048                def has_dir_node(str_dir):
1049                    if not str_dir: # no encoding matched, i.e. not existing
1050                        return False
1051                    idx[0] = lo = bisect(str_files, str_dir)
1052                    return lo < len(str_files) \
1053                           and str_files[lo].startswith(str_dir)
1054                str_path = checked_encode(path + '/', repos.encoding,
1055                                          has_dir_node)
1056                if str_path is None:
1057                    raise NoSuchNode(path, changectx.hex())
1058                lo = idx[0]
1059                for hi in xrange(lo, len(str_files)):
1060                    if not str_files[hi].startswith(str_path):
1061                        break
1062                str_path = str_path[:-1]
1063                str_entries = str_files[lo:hi]
1064        self.str_path = str_path
1065
1066        # Determine `kind`, `rev` (requested rev) and `created_rev`
1067        # (last changed revision before requested rev)
1068
1069        kind = None
1070        rev = changectx.rev()
1071        if str_path == '':
1072            kind = Node.DIRECTORY
1073            dirctx = changectx
1074        elif str_path in self.manifest: # then it's a file
1075            kind = Node.FILE
1076            self.filectx = changectx.filectx(str_path)
1077            created_rev = self.filectx.linkrev()
1078            # FIXME (0.13) this is a hack, we should fix that at the
1079            #       Trac level, which should really show the
1080            #       created_rev value for files in the browser.
1081            rev = created_rev
1082        else: # we already know it's a dir
1083            kind = Node.DIRECTORY
1084            if not dirctx:
1085                # we need to find the most recent change for a file below dir
1086                str_dir = str_path + '/'
1087                dirctxs = self.find_dirctx(changectx.rev(), [str_dir,],
1088                                           {str_dir: str_entries})
1089                dirctx = dirctxs.values()[0]
1090
1091        if not kind:
1092            if repo.changelog.tip() == nullid or \
1093                    not (self.manifest or str_path):
1094                # empty or emptied repository
1095                kind = Node.DIRECTORY
1096                dirctx = changectx
1097            else:
1098                raise NoSuchNode(path, changectx.hex())
1099
1100        self.time = self.repos.from_hg_time(changectx.date())
1101        if dirctx is not None:
1102            # FIXME (0.13) same remark as above
1103            rev = created_rev = dirctx.rev()
1104        Node.__init__(self, self.repos, path, rev or '0', kind)
1105        self.created_path = path
1106        self.created_rev = created_rev
1107        self.data = None
1108
1109    def find_dirctx(self, max_rev, str_dirnames, str_entries):
1110        """Find most recent modification for each given directory path.
1111       
1112        :param max_rev: find no revision more recent than this one
1113        :param str_dirnames: directory paths to consider
1114                             (as `str` ending with '/')
1115        :param str_entries: optionally maps directories to their file content
1116
1117        :return: a `dict` with `str_dirnames` as keys, `changectx` as values
1118
1119        As directories are not first-class citizens in Mercurial, this
1120        operation is not trivial. There are basically two strategies:
1121
1122         - for each file below the given directories, retrieve the
1123           linkrev (most recent modification for this file), and take
1124           the max; this approach is very inefficient for repositories
1125           containing many files (#7746)
1126
1127         - retrieve the files modified when going backward through the
1128           changelog and detect the first occurrence of a change in
1129           each directory; this is much faster but can still be slow
1130           if some folders are only modified in the distant past
1131           
1132        It is possible to combine both approach, and this can yield
1133        excellent results in some cases (e.g. browsing the Linux repos
1134        @ 118733 takes several minutes with the first approach, 11s
1135        with the second, but only 1.2s with the hybrid approach)
1136
1137        Note that the specialized scan of the changelog we do below is
1138        more efficient than the general cmdutil.walkchangerevs here.
1139        """
1140        str_dirctxs = {}
1141        repo = self.repos.repo
1142        max_ctx = repo[max_rev]
1143        for r in xrange(max_rev, -1, -1):
1144            ctx = repo[r]
1145            # lookup changes to str_dirnames in current cset
1146            for str_file in ctx.files():
1147                for str_dir in str_dirnames[:]:
1148                    if str_file.startswith(str_dir):
1149                        str_dirctxs[str_dir] = ctx
1150                        str_dirnames.remove(str_dir)
1151                        if not str_dirnames: # if nothing left to find
1152                            return str_dirctxs
1153            # in parallel, try the filelog strategy (the 463, 2, 40
1154            # values below look a bit like magic numbers; actually
1155            # they were selected by testing the plugin on the Linux
1156            # and NetBeans repositories)
1157            if r % 463 == 0:
1158                k = max(2, 40 / len(str_dirnames))
1159                for str_dir in str_dirnames[:]:
1160                    str_files = str_entries[str_dir]
1161                    dr = str_dirctxs.get(str_dir, 0)
1162                    for f in str_files[:k]:
1163                        dr = max(dr, max_ctx.filectx(f).linkrev())
1164                    str_files = str_files[k:]
1165                    if str_files:
1166                        str_entries[str_dir] = str_files
1167                        str_dirctxs[str_dir] = dr
1168                    else:
1169                        str_dirctxs[str_dir] = repo[dr]
1170                        str_dirnames.remove(str_dir)
1171                        if not str_dirnames:
1172                            return str_dirctxs
1173                       
1174               
1175    def subnode(self, str_path, subctx=None):
1176        """Return a node with the same revision information but for
1177        another path
1178
1179        :param str_path: should be the an existing entry in the manifest
1180        """
1181        return MercurialNode(self.repos, self.repos.to_u(str_path),
1182                             self.changectx, self.manifest, subctx, str_path)
1183
1184    def get_content(self):
1185        if self.isdir:
1186            return None
1187        self.pos = 0 # reset the read()
1188        return self # something that can be `read()` ...
1189
1190    def read(self, size=None):
1191        if self.isdir:
1192            return TracError(_("Can't read from directory %(path)s", 
1193                               path=self.path))
1194        if self.data is None:
1195            self.data = self.filectx.data()
1196            self.pos = 0
1197        if size:
1198            prev_pos = self.pos
1199            self.pos += size
1200            return self.data[prev_pos:self.pos]
1201        return self.data
1202
1203    def get_entries(self):
1204        if self.isfile:
1205            return
1206       
1207        # dirnames are entries which are sub-directories
1208        str_entries = {}
1209        str_dirnames = []
1210        def add_entry(str_file, idx):
1211            str_entry = str_file
1212            if idx > -1: # directory
1213                str_entry = str_file[:idx + 1]
1214                str_files = str_entries.setdefault(str_entry, [])
1215                if not str_files:
1216                    str_dirnames.append(str_entry)
1217                str_files.append(str_file)
1218            else:
1219                str_entries[str_entry] = 1
1220
1221        if self.str_path:
1222            str_dir = self.str_path + '/'
1223            for str_file in self.manifest:
1224                if str_file.startswith(str_dir):
1225                    add_entry(str_file, str_file.find('/', len(str_dir)))
1226        else:
1227            for str_file in self.manifest:
1228                add_entry(str_file, str_file.find('/'))
1229
1230        # pre-computing the changectx for the last change in each sub-directory
1231        if str_dirnames:
1232            dirctxs = self.find_dirctx(self.created_rev, str_dirnames, 
1233                                       str_entries)
1234        else:
1235            dirctxs = {}
1236
1237        for str_entry in str_entries:
1238            yield self.subnode(str_entry.rstrip('/'),
1239                               dirctxs.get(str_entry, None))
1240
1241    def get_history(self, limit=None):
1242        repo = self.repos.repo
1243        pats = []
1244        if self.str_path:
1245            pats.append('path:' + self.str_path)
1246        opts = {'rev': ['%s:0' % self.changectx.hex()]}
1247        if self.isfile:
1248            opts['follow'] = True
1249        if arity(cmdutil.walkchangerevs) == 4:
1250            return self._get_history_1_4(repo, pats, opts, limit)
1251        else:
1252            return self._get_history_1_3(repo, pats, opts, limit)
1253
1254    def _get_history_1_4(self, repo, pats, opts, limit):
1255        matcher = cmdutil.match(repo, pats, opts)
1256        if self.isfile:
1257            fncache = {}
1258            def prep(ctx, fns):
1259                if self.isfile:
1260                    fncache[ctx.rev()] = self.repos.to_u(fns[0])
1261        else:
1262            def prep(ctx, fns):
1263                pass
1264
1265        # keep one lookahead entry so that we can detect renames
1266        path = self.path
1267        entry = None
1268        count = 0
1269        for ctx in cmdutil.walkchangerevs(repo, matcher, opts, prep):
1270            if self.isfile and entry:
1271                path = fncache[ctx.rev()]
1272                if path != entry[0]:
1273                    entry = entry[0:2] + (Changeset.COPY,)
1274            if entry:
1275                yield entry
1276                count += 1
1277                if limit is not None and count >= limit:
1278                    return
1279            entry = (path, ctx.hex(), Changeset.EDIT)
1280        if entry:
1281            if limit is None or count < limit:
1282                entry = entry[0:2] + (Changeset.ADD,)
1283            yield entry
1284
1285    def _get_history_1_3(self, repo, pats, opts, limit):
1286        if self.repos.version_info > (1, 3, 999):
1287            changefn = lambda r: repo[r]
1288        else:
1289            changefn = lambda r: repo[r].changeset()
1290        get = cachefunc(changefn)
1291        if self.isfile:
1292            fncache = {}
1293        chgiter, matchfn = cmdutil.walkchangerevs(self.repos.ui, repo, pats, 
1294                                                  get, opts)
1295        # keep one lookahead entry so that we can detect renames
1296        path = self.path
1297        entry = None
1298        count = 0
1299        for st, rev, fns in chgiter:
1300            if st == 'add' and self.isfile:
1301                fncache[rev] = self.repos.to_u(fns[0])
1302            elif st == 'iter':
1303                if self.isfile and entry:
1304                    path = fncache[rev]
1305                    if path != entry[0]:
1306                        entry = entry[0:2] + (Changeset.COPY,)
1307                if entry:
1308                    yield entry
1309                    count += 1
1310                    if limit is not None and count >= limit:
1311                        return
1312                n = repo.changelog.node(rev)
1313                entry = (path, hex(n), Changeset.EDIT)
1314        if entry:
1315            if limit is None or count < limit:
1316                entry = entry[0:2] + (Changeset.ADD,)
1317            yield entry
1318
1319    def get_annotations(self):
1320        annotations = []
1321        if self.filectx:
1322            for fc, line in self.filectx.annotate(follow=True):
1323                annotations.append(fc.rev() or '0')
1324        return annotations
1325       
1326    def get_properties(self):
1327        if self.isfile and 'x' in self.manifest.flags(self.str_path):
1328            return {'exe': '*'}
1329        else:
1330            return {}
1331
1332    def get_content_length(self):
1333        if self.isdir:
1334            return None
1335        return self.filectx.size()
1336
1337    def get_content_type(self):
1338        if self.isdir:
1339            return None
1340        if 'mq' in self.repos.params: # FIXME
1341            if self.str_path not in ('.hgignore', 'series'):
1342                return 'text/x-diff'
1343        return ''
1344
1345    def get_last_modified(self):
1346        return self.time
1347
1348
1349class MercurialChangeset(Changeset):
1350    """A changeset in the repository.
1351
1352    This wraps the corresponding information from the changelog.  The
1353    files changes are obtained by comparing the current manifest to
1354    the parent manifest(s).
1355    """
1356   
1357    def __init__(self, repos, ctx):
1358        self.repos = repos
1359        self.ctx = ctx
1360        self.branch = self.repos.to_u(ctx.branch())
1361        # Note: desc and time are already processed by hg's
1362        # `encoding.tolocal`; by setting $HGENCODING to latin1, we are
1363        # however guaranteed to get back the bytes as they were
1364        # stored.
1365        desc = repos.to_u(ctx.description())
1366        user = repos.to_u(ctx.user())
1367        time = repos.from_hg_time(ctx.date())
1368        Changeset.__init__(self, repos, ctx.hex(), desc, user, time)
1369
1370    hg_properties = [
1371        N_("Parents:"), N_("Children:"), N_("Branch:"), N_("Tags:")
1372    ]
1373
1374    def get_properties(self):
1375        properties = {}
1376        parents = self.ctx.parents()
1377        if len(parents) > 1:
1378            properties['hg-Parents'] = (self.repos, 
1379                                        [p.hex() for p in parents if p])
1380        children = self.ctx.children()
1381        if len(children) > 1:
1382            properties['hg-Children'] = (self.repos,
1383                                         [c.hex() for c in children])
1384        if self.branch:
1385            properties['hg-Branch'] = (self.repos, [self.branch])
1386        tags = self.ctx.tags()
1387        if len(tags):
1388            properties['hg-Tags'] = (self.repos, 
1389                                     [self.repos.to_u(t) for t in tags])
1390        for k, v in self.ctx.extra().iteritems():
1391            if k != 'branch':
1392                properties['hg-' + k] = (self.repos, v)
1393        return properties
1394
1395    def get_changes(self):
1396        u = self.repos.to_u
1397        repo = self.repos.repo
1398        manifest = self.ctx.manifest()
1399        parents = self.ctx.parents()
1400
1401        renames = []
1402        str_deletions = {}
1403        changes = []
1404        for str_file in self.ctx.files(): # added, edited and deleted files
1405            f = u(str_file)
1406            # TODO: find a way to detect conflicts and show how they were
1407            #       solved (kind of 3-way diff - theirs/mine/merged)
1408            edits = [p for p in parents if str_file in p.manifest()]
1409            edits = edits[:1]
1410
1411            if str_file not in manifest:
1412                str_deletions[str_file] = edits[0]
1413            elif edits:
1414                for p in edits:
1415                    changes.append((f, Node.FILE, Changeset.EDIT, f, p.rev()))
1416            else:
1417                renamed = repo.file(str_file).renamed(manifest[str_file])
1418                if renamed:
1419                    renames.append((f, renamed))
1420                else:
1421                    changes.append((f, Node.FILE, Changeset.ADD, '', None))
1422        # distinguish between move and copy
1423        for f, (str_base_path, base_filenode) in renames:
1424            base_ctx = repo.filectx(str_base_path, fileid=base_filenode)
1425            if str_base_path in str_deletions:
1426                del str_deletions[str_base_path]
1427                action = Changeset.MOVE
1428            else:
1429                action = Changeset.COPY
1430            changes.append((f, Node.FILE, action, u(str_base_path), 
1431                            base_ctx.rev()))
1432        # remaining str_deletions are real deletions
1433        for str_file, p in str_deletions.items():
1434            f = u(str_file)
1435            changes.append((f, Node.FILE, Changeset.DELETE, f, p.rev()))
1436        changes.sort()
1437        for change in changes:
1438            yield change
1439
1440    def get_branches(self):
1441        """Yield branch names to which this changeset belong."""
1442        return self.branch and [(self.branch, 
1443                                 len(self.ctx.children()) == 0)] or []