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