Edgewall Software

source: mercurial-plugin/tracext/hg/backend.py@ 59:e5f80d909b3f

1.0
Last change on this file since 59:e5f80d909b3f was 59:e5f80d909b3f, checked in by Peter Suter <petsuter@…>, 5 years ago

1.0.0.8: Log import error.
see #12176

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