Edgewall Software

Ticket #8417: backend.py

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

I have implemented a naive version of MecrurialCachedRepository using a backward-traversing resync which overrides the default resync algorithm.

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