Edgewall Software

source: mercurial-plugin/tracext/hg/backend.py@ 3:8af21bda2b3e

0.13
Last change on this file since 3:8af21bda2b3e was 3:8af21bda2b3e, checked in by Christian Boos <christian.boos@…>, 11 years ago

#10515: rework the way we retrieve the last change on directories.

Instead of going back through all revisions prior the starting
revision, we only consider its ancestors, staying on the same
branch.

This is not only even faster, but more correct.

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