Mercurial > public > mercurial-scm > hg
view hgext/graphlog.py @ 17179:0849d725e2f9
graphlog: extract ascii drawing code into graphmod
author | Patrick Mezard <patrick@mezard.eu> |
---|---|
date | Wed, 11 Jul 2012 17:13:39 +0200 |
parents | 8299a9ad48dd |
children | ae0629161090 |
line wrap: on
line source
# ASCII graph log extension for Mercurial # # Copyright 2007 Joel Rosdahl <joel@rosdahl.net> # # This software may be used and distributed according to the terms of the # GNU General Public License version 2 or any later version. '''command to view revision graphs from a shell This extension adds a --graph option to the incoming, outgoing and log commands. When this options is given, an ASCII representation of the revision graph is also shown. ''' from mercurial.cmdutil import show_changeset from mercurial.i18n import _ from mercurial import cmdutil, commands, extensions, scmutil from mercurial import hg, util, graphmod, templatekw, revset cmdtable = {} command = cmdutil.command(cmdtable) testedwith = 'internal' def _checkunsupportedflags(pats, opts): for op in ["newest_first"]: if op in opts and opts[op]: raise util.Abort(_("-G/--graph option is incompatible with --%s") % op.replace("_", "-")) def _makefilematcher(repo, pats, followfirst): # When displaying a revision with --patch --follow FILE, we have # to know which file of the revision must be diffed. With # --follow, we want the names of the ancestors of FILE in the # revision, stored in "fcache". "fcache" is populated by # reproducing the graph traversal already done by --follow revset # and relating linkrevs to file names (which is not "correct" but # good enough). fcache = {} fcacheready = [False] pctx = repo['.'] wctx = repo[None] def populate(): for fn in pats: for i in ((pctx[fn],), pctx[fn].ancestors(followfirst=followfirst)): for c in i: fcache.setdefault(c.linkrev(), set()).add(c.path()) def filematcher(rev): if not fcacheready[0]: # Lazy initialization fcacheready[0] = True populate() return scmutil.match(wctx, fcache.get(rev, []), default='path') return filematcher def _makelogrevset(repo, pats, opts, revs): """Return (expr, filematcher) where expr is a revset string built from log options and file patterns or None. If --stat or --patch are not passed filematcher is None. Otherwise it is a callable taking a revision number and returning a match objects filtering the files to be detailed when displaying the revision. """ opt2revset = { 'no_merges': ('not merge()', None), 'only_merges': ('merge()', None), '_ancestors': ('ancestors(%(val)s)', None), '_fancestors': ('_firstancestors(%(val)s)', None), '_descendants': ('descendants(%(val)s)', None), '_fdescendants': ('_firstdescendants(%(val)s)', None), '_matchfiles': ('_matchfiles(%(val)s)', None), 'date': ('date(%(val)r)', None), 'branch': ('branch(%(val)r)', ' or '), '_patslog': ('filelog(%(val)r)', ' or '), '_patsfollow': ('follow(%(val)r)', ' or '), '_patsfollowfirst': ('_followfirst(%(val)r)', ' or '), 'keyword': ('keyword(%(val)r)', ' or '), 'prune': ('not (%(val)r or ancestors(%(val)r))', ' and '), 'user': ('user(%(val)r)', ' or '), } opts = dict(opts) # follow or not follow? follow = opts.get('follow') or opts.get('follow_first') followfirst = opts.get('follow_first') and 1 or 0 # --follow with FILE behaviour depends on revs... startrev = revs[0] followdescendants = (len(revs) > 1 and revs[0] < revs[1]) and 1 or 0 # branch and only_branch are really aliases and must be handled at # the same time opts['branch'] = opts.get('branch', []) + opts.get('only_branch', []) opts['branch'] = [repo.lookupbranch(b) for b in opts['branch']] # pats/include/exclude are passed to match.match() directly in # _matchfile() revset but walkchangerevs() builds its matcher with # scmutil.match(). The difference is input pats are globbed on # platforms without shell expansion (windows). pctx = repo[None] match, pats = scmutil.matchandpats(pctx, pats, opts) slowpath = match.anypats() or (match.files() and opts.get('removed')) if not slowpath: for f in match.files(): if follow and f not in pctx: raise util.Abort(_('cannot follow file not in parent ' 'revision: "%s"') % f) filelog = repo.file(f) if not len(filelog): # A zero count may be a directory or deleted file, so # try to find matching entries on the slow path. if follow: raise util.Abort( _('cannot follow nonexistent file: "%s"') % f) slowpath = True if slowpath: # See cmdutil.walkchangerevs() slow path. # if follow: raise util.Abort(_('can only follow copies/renames for explicit ' 'filenames')) # pats/include/exclude cannot be represented as separate # revset expressions as their filtering logic applies at file # level. For instance "-I a -X a" matches a revision touching # "a" and "b" while "file(a) and not file(b)" does # not. Besides, filesets are evaluated against the working # directory. matchargs = ['r:', 'd:relpath'] for p in pats: matchargs.append('p:' + p) for p in opts.get('include', []): matchargs.append('i:' + p) for p in opts.get('exclude', []): matchargs.append('x:' + p) matchargs = ','.join(('%r' % p) for p in matchargs) opts['_matchfiles'] = matchargs else: if follow: fpats = ('_patsfollow', '_patsfollowfirst') fnopats = (('_ancestors', '_fancestors'), ('_descendants', '_fdescendants')) if pats: # follow() revset inteprets its file argument as a # manifest entry, so use match.files(), not pats. opts[fpats[followfirst]] = list(match.files()) else: opts[fnopats[followdescendants][followfirst]] = str(startrev) else: opts['_patslog'] = list(pats) filematcher = None if opts.get('patch') or opts.get('stat'): if follow: filematcher = _makefilematcher(repo, pats, followfirst) else: filematcher = lambda rev: match expr = [] for op, val in opts.iteritems(): if not val: continue if op not in opt2revset: continue revop, andor = opt2revset[op] if '%(val)' not in revop: expr.append(revop) else: if not isinstance(val, list): e = revop % {'val': val} else: e = '(' + andor.join((revop % {'val': v}) for v in val) + ')' expr.append(e) if expr: expr = '(' + ' and '.join(expr) + ')' else: expr = None return expr, filematcher def getlogrevs(repo, pats, opts): """Return (revs, expr, filematcher) where revs is an iterable of revision numbers, expr is a revset string built from log options and file patterns or None, and used to filter 'revs'. If --stat or --patch are not passed filematcher is None. Otherwise it is a callable taking a revision number and returning a match objects filtering the files to be detailed when displaying the revision. """ def increasingrevs(repo, revs, matcher): # The sorted input rev sequence is chopped in sub-sequences # which are sorted in ascending order and passed to the # matcher. The filtered revs are sorted again as they were in # the original sub-sequence. This achieve several things: # # - getlogrevs() now returns a generator which behaviour is # adapted to log need. First results come fast, last ones # are batched for performances. # # - revset matchers often operate faster on revision in # changelog order, because most filters deal with the # changelog. # # - revset matchers can reorder revisions. "A or B" typically # returns returns the revision matching A then the revision # matching B. We want to hide this internal implementation # detail from the caller, and sorting the filtered revision # again achieves this. for i, window in cmdutil.increasingwindows(0, len(revs), windowsize=1): orevs = revs[i:i + window] nrevs = set(matcher(repo, sorted(orevs))) for rev in orevs: if rev in nrevs: yield rev if not len(repo): return iter([]), None, None # Default --rev value depends on --follow but --follow behaviour # depends on revisions resolved from --rev... follow = opts.get('follow') or opts.get('follow_first') if opts.get('rev'): revs = scmutil.revrange(repo, opts['rev']) else: if follow and len(repo) > 0: revs = scmutil.revrange(repo, ['.:0']) else: revs = range(len(repo) - 1, -1, -1) if not revs: return iter([]), None, None expr, filematcher = _makelogrevset(repo, pats, opts, revs) if expr: matcher = revset.match(repo.ui, expr) revs = increasingrevs(repo, revs, matcher) if not opts.get('hidden'): # --hidden is still experimental and not worth a dedicated revset # yet. Fortunately, filtering revision number is fast. revs = (r for r in revs if r not in repo.changelog.hiddenrevs) else: revs = iter(revs) return revs, expr, filematcher def generate(ui, dag, displayer, showparents, edgefn, getrenamed=None, filematcher=None): seen, state = [], graphmod.asciistate() for rev, type, ctx, parents in dag: char = 'o' if ctx.node() in showparents: char = '@' elif ctx.obsolete(): char = 'x' copies = None if getrenamed and ctx.rev(): copies = [] for fn in ctx.files(): rename = getrenamed(fn, ctx.rev()) if rename: copies.append((fn, rename[0])) revmatchfn = None if filematcher is not None: revmatchfn = filematcher(ctx.rev()) displayer.show(ctx, copies=copies, matchfn=revmatchfn) lines = displayer.hunk.pop(rev).split('\n') if not lines[-1]: del lines[-1] displayer.flush(rev) edges = edgefn(type, char, lines, seen, rev, parents) for type, char, lines, coldata in edges: graphmod.ascii(ui, state, type, char, lines, coldata) displayer.close() @command('glog', [('f', 'follow', None, _('follow changeset history, or file history across copies and renames')), ('', 'follow-first', None, _('only follow the first parent of merge changesets (DEPRECATED)')), ('d', 'date', '', _('show revisions matching date spec'), _('DATE')), ('C', 'copies', None, _('show copied files')), ('k', 'keyword', [], _('do case-insensitive search for a given text'), _('TEXT')), ('r', 'rev', [], _('show the specified revision or range'), _('REV')), ('', 'removed', None, _('include revisions where files were removed')), ('m', 'only-merges', None, _('show only merges (DEPRECATED)')), ('u', 'user', [], _('revisions committed by user'), _('USER')), ('', 'only-branch', [], _('show only changesets within the given named branch (DEPRECATED)'), _('BRANCH')), ('b', 'branch', [], _('show changesets within the given named branch'), _('BRANCH')), ('P', 'prune', [], _('do not display revision or any of its ancestors'), _('REV')), ('', 'hidden', False, _('show hidden changesets (DEPRECATED)')), ] + commands.logopts + commands.walkopts, _('[OPTION]... [FILE]')) def graphlog(ui, repo, *pats, **opts): """show revision history alongside an ASCII revision graph Print a revision history alongside a revision graph drawn with ASCII characters. Nodes printed as an @ character are parents of the working directory. """ revs, expr, filematcher = getlogrevs(repo, pats, opts) revs = sorted(revs, reverse=1) limit = cmdutil.loglimit(opts) if limit is not None: revs = revs[:limit] revdag = graphmod.dagwalker(repo, revs) getrenamed = None if opts.get('copies'): endrev = None if opts.get('rev'): endrev = max(scmutil.revrange(repo, opts.get('rev'))) + 1 getrenamed = templatekw.getrenamedfn(repo, endrev=endrev) displayer = show_changeset(ui, repo, opts, buffered=True) showparents = [ctx.node() for ctx in repo[None].parents()] generate(ui, revdag, displayer, showparents, graphmod.asciiedges, getrenamed, filematcher) def graphrevs(repo, nodes, opts): limit = cmdutil.loglimit(opts) nodes.reverse() if limit is not None: nodes = nodes[:limit] return graphmod.nodes(repo, nodes) def goutgoing(ui, repo, dest=None, **opts): """show the outgoing changesets alongside an ASCII revision graph Print the outgoing changesets alongside a revision graph drawn with ASCII characters. Nodes printed as an @ character are parents of the working directory. """ _checkunsupportedflags([], opts) o = hg._outgoing(ui, repo, dest, opts) if o is None: return revdag = graphrevs(repo, o, opts) displayer = show_changeset(ui, repo, opts, buffered=True) showparents = [ctx.node() for ctx in repo[None].parents()] generate(ui, revdag, displayer, showparents, graphmod.asciiedges) def gincoming(ui, repo, source="default", **opts): """show the incoming changesets alongside an ASCII revision graph Print the incoming changesets alongside a revision graph drawn with ASCII characters. Nodes printed as an @ character are parents of the working directory. """ def subreporecurse(): return 1 _checkunsupportedflags([], opts) def display(other, chlist, displayer): revdag = graphrevs(other, chlist, opts) showparents = [ctx.node() for ctx in repo[None].parents()] generate(ui, revdag, displayer, showparents, graphmod.asciiedges) hg._incoming(display, subreporecurse, ui, repo, source, opts, buffered=True) def uisetup(ui): '''Initialize the extension.''' _wrapcmd('log', commands.table, graphlog) _wrapcmd('incoming', commands.table, gincoming) _wrapcmd('outgoing', commands.table, goutgoing) def _wrapcmd(cmd, table, wrapfn): '''wrap the command''' def graph(orig, *args, **kwargs): if kwargs['graph']: return wrapfn(*args, **kwargs) return orig(*args, **kwargs) entry = extensions.wrapcommand(table, cmd, graph) entry[1].append(('G', 'graph', None, _("show the revision DAG")))