Edgewall Software

source: mercurial-plugin/tracext/hg/backend.py@ 69:1b6c6b50f742

1.0
Last change on this file since 69:1b6c6b50f742 was 69:1b6c6b50f742, checked in by Jun Omae <jun66j5@…>, 5 years ago

1.0.0.9dev: fix NoSuchNode raised when paths of MercurialRepository.get_changes() are different (closes #13077)

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