Edgewall Software

Ticket #8417: backend.2.py

File backend.2.py, 54.6 KB (added by me@…, 12 years ago)

Revised patch: applied the set diff algorithm by miguel.araujo.perez and now ticket commit updater works correctly

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