|
|
# 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.commands import templateopts
|
|
|
from mercurial.i18n import _
|
|
|
from mercurial.node import nullrev
|
|
|
from mercurial import cmdutil, commands, extensions, scmutil
|
|
|
from mercurial import hg, util, graphmod, templatekw
|
|
|
|
|
|
cmdtable = {}
|
|
|
command = cmdutil.command(cmdtable)
|
|
|
|
|
|
ASCIIDATA = 'ASC'
|
|
|
|
|
|
def asciiedges(type, char, lines, seen, rev, parents):
|
|
|
"""adds edge info to changelog DAG walk suitable for ascii()"""
|
|
|
if rev not in seen:
|
|
|
seen.append(rev)
|
|
|
nodeidx = seen.index(rev)
|
|
|
|
|
|
knownparents = []
|
|
|
newparents = []
|
|
|
for parent in parents:
|
|
|
if parent in seen:
|
|
|
knownparents.append(parent)
|
|
|
else:
|
|
|
newparents.append(parent)
|
|
|
|
|
|
ncols = len(seen)
|
|
|
nextseen = seen[:]
|
|
|
nextseen[nodeidx:nodeidx + 1] = newparents
|
|
|
edges = [(nodeidx, nextseen.index(p)) for p in knownparents]
|
|
|
|
|
|
while len(newparents) > 2:
|
|
|
# ascii() only knows how to add or remove a single column between two
|
|
|
# calls. Nodes with more than two parents break this constraint so we
|
|
|
# introduce intermediate expansion lines to grow the active node list
|
|
|
# slowly.
|
|
|
edges.append((nodeidx, nodeidx))
|
|
|
edges.append((nodeidx, nodeidx + 1))
|
|
|
nmorecols = 1
|
|
|
yield (type, char, lines, (nodeidx, edges, ncols, nmorecols))
|
|
|
char = '\\'
|
|
|
lines = []
|
|
|
nodeidx += 1
|
|
|
ncols += 1
|
|
|
edges = []
|
|
|
del newparents[0]
|
|
|
|
|
|
if len(newparents) > 0:
|
|
|
edges.append((nodeidx, nodeidx))
|
|
|
if len(newparents) > 1:
|
|
|
edges.append((nodeidx, nodeidx + 1))
|
|
|
nmorecols = len(nextseen) - ncols
|
|
|
seen[:] = nextseen
|
|
|
yield (type, char, lines, (nodeidx, edges, ncols, nmorecols))
|
|
|
|
|
|
def fix_long_right_edges(edges):
|
|
|
for (i, (start, end)) in enumerate(edges):
|
|
|
if end > start:
|
|
|
edges[i] = (start, end + 1)
|
|
|
|
|
|
def get_nodeline_edges_tail(
|
|
|
node_index, p_node_index, n_columns, n_columns_diff, p_diff, fix_tail):
|
|
|
if fix_tail and n_columns_diff == p_diff and n_columns_diff != 0:
|
|
|
# Still going in the same non-vertical direction.
|
|
|
if n_columns_diff == -1:
|
|
|
start = max(node_index + 1, p_node_index)
|
|
|
tail = ["|", " "] * (start - node_index - 1)
|
|
|
tail.extend(["/", " "] * (n_columns - start))
|
|
|
return tail
|
|
|
else:
|
|
|
return ["\\", " "] * (n_columns - node_index - 1)
|
|
|
else:
|
|
|
return ["|", " "] * (n_columns - node_index - 1)
|
|
|
|
|
|
def draw_edges(edges, nodeline, interline):
|
|
|
for (start, end) in edges:
|
|
|
if start == end + 1:
|
|
|
interline[2 * end + 1] = "/"
|
|
|
elif start == end - 1:
|
|
|
interline[2 * start + 1] = "\\"
|
|
|
elif start == end:
|
|
|
interline[2 * start] = "|"
|
|
|
else:
|
|
|
if 2 * end >= len(nodeline):
|
|
|
continue
|
|
|
nodeline[2 * end] = "+"
|
|
|
if start > end:
|
|
|
(start, end) = (end, start)
|
|
|
for i in range(2 * start + 1, 2 * end):
|
|
|
if nodeline[i] != "+":
|
|
|
nodeline[i] = "-"
|
|
|
|
|
|
def get_padding_line(ni, n_columns, edges):
|
|
|
line = []
|
|
|
line.extend(["|", " "] * ni)
|
|
|
if (ni, ni - 1) in edges or (ni, ni) in edges:
|
|
|
# (ni, ni - 1) (ni, ni)
|
|
|
# | | | | | | | |
|
|
|
# +---o | | o---+
|
|
|
# | | c | | c | |
|
|
|
# | |/ / | |/ /
|
|
|
# | | | | | |
|
|
|
c = "|"
|
|
|
else:
|
|
|
c = " "
|
|
|
line.extend([c, " "])
|
|
|
line.extend(["|", " "] * (n_columns - ni - 1))
|
|
|
return line
|
|
|
|
|
|
def asciistate():
|
|
|
"""returns the initial value for the "state" argument to ascii()"""
|
|
|
return [0, 0]
|
|
|
|
|
|
def ascii(ui, state, type, char, text, coldata):
|
|
|
"""prints an ASCII graph of the DAG
|
|
|
|
|
|
takes the following arguments (one call per node in the graph):
|
|
|
|
|
|
- ui to write to
|
|
|
- Somewhere to keep the needed state in (init to asciistate())
|
|
|
- Column of the current node in the set of ongoing edges.
|
|
|
- Type indicator of node data == ASCIIDATA.
|
|
|
- Payload: (char, lines):
|
|
|
- Character to use as node's symbol.
|
|
|
- List of lines to display as the node's text.
|
|
|
- Edges; a list of (col, next_col) indicating the edges between
|
|
|
the current node and its parents.
|
|
|
- Number of columns (ongoing edges) in the current revision.
|
|
|
- The difference between the number of columns (ongoing edges)
|
|
|
in the next revision and the number of columns (ongoing edges)
|
|
|
in the current revision. That is: -1 means one column removed;
|
|
|
0 means no columns added or removed; 1 means one column added.
|
|
|
"""
|
|
|
|
|
|
idx, edges, ncols, coldiff = coldata
|
|
|
assert -2 < coldiff < 2
|
|
|
if coldiff == -1:
|
|
|
# Transform
|
|
|
#
|
|
|
# | | | | | |
|
|
|
# o | | into o---+
|
|
|
# |X / |/ /
|
|
|
# | | | |
|
|
|
fix_long_right_edges(edges)
|
|
|
|
|
|
# add_padding_line says whether to rewrite
|
|
|
#
|
|
|
# | | | | | | | |
|
|
|
# | o---+ into | o---+
|
|
|
# | / / | | | # <--- padding line
|
|
|
# o | | | / /
|
|
|
# o | |
|
|
|
add_padding_line = (len(text) > 2 and coldiff == -1 and
|
|
|
[x for (x, y) in edges if x + 1 < y])
|
|
|
|
|
|
# fix_nodeline_tail says whether to rewrite
|
|
|
#
|
|
|
# | | o | | | | o | |
|
|
|
# | | |/ / | | |/ /
|
|
|
# | o | | into | o / / # <--- fixed nodeline tail
|
|
|
# | |/ / | |/ /
|
|
|
# o | | o | |
|
|
|
fix_nodeline_tail = len(text) <= 2 and not add_padding_line
|
|
|
|
|
|
# nodeline is the line containing the node character (typically o)
|
|
|
nodeline = ["|", " "] * idx
|
|
|
nodeline.extend([char, " "])
|
|
|
|
|
|
nodeline.extend(
|
|
|
get_nodeline_edges_tail(idx, state[1], ncols, coldiff,
|
|
|
state[0], fix_nodeline_tail))
|
|
|
|
|
|
# shift_interline is the line containing the non-vertical
|
|
|
# edges between this entry and the next
|
|
|
shift_interline = ["|", " "] * idx
|
|
|
if coldiff == -1:
|
|
|
n_spaces = 1
|
|
|
edge_ch = "/"
|
|
|
elif coldiff == 0:
|
|
|
n_spaces = 2
|
|
|
edge_ch = "|"
|
|
|
else:
|
|
|
n_spaces = 3
|
|
|
edge_ch = "\\"
|
|
|
shift_interline.extend(n_spaces * [" "])
|
|
|
shift_interline.extend([edge_ch, " "] * (ncols - idx - 1))
|
|
|
|
|
|
# draw edges from the current node to its parents
|
|
|
draw_edges(edges, nodeline, shift_interline)
|
|
|
|
|
|
# lines is the list of all graph lines to print
|
|
|
lines = [nodeline]
|
|
|
if add_padding_line:
|
|
|
lines.append(get_padding_line(idx, ncols, edges))
|
|
|
lines.append(shift_interline)
|
|
|
|
|
|
# make sure that there are as many graph lines as there are
|
|
|
# log strings
|
|
|
while len(text) < len(lines):
|
|
|
text.append("")
|
|
|
if len(lines) < len(text):
|
|
|
extra_interline = ["|", " "] * (ncols + coldiff)
|
|
|
while len(lines) < len(text):
|
|
|
lines.append(extra_interline)
|
|
|
|
|
|
# print lines
|
|
|
indentation_level = max(ncols, ncols + coldiff)
|
|
|
for (line, logstr) in zip(lines, text):
|
|
|
ln = "%-*s %s" % (2 * indentation_level, "".join(line), logstr)
|
|
|
ui.write(ln.rstrip() + '\n')
|
|
|
|
|
|
# ... and start over
|
|
|
state[0] = coldiff
|
|
|
state[1] = idx
|
|
|
|
|
|
def get_revs(repo, rev_opt):
|
|
|
if rev_opt:
|
|
|
revs = scmutil.revrange(repo, rev_opt)
|
|
|
if len(revs) == 0:
|
|
|
return (nullrev, nullrev)
|
|
|
return (max(revs), min(revs))
|
|
|
else:
|
|
|
return (len(repo) - 1, 0)
|
|
|
|
|
|
def check_unsupported_flags(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 revset(repo, pats, opts):
|
|
|
"""Return revset str built of revisions, log options and file patterns.
|
|
|
"""
|
|
|
opt2revset = {
|
|
|
'follow': ('follow()', None),
|
|
|
'follow_first': ('_followfirst()', None),
|
|
|
'no_merges': ('not merge()', None),
|
|
|
'only_merges': ('merge()', 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 '),
|
|
|
'rev': ('%(val)s', ' or '),
|
|
|
}
|
|
|
|
|
|
opts = dict(opts)
|
|
|
# branch and only_branch are really aliases and must be handled at
|
|
|
# the same time
|
|
|
if 'branch' in opts and 'only_branch' in opts:
|
|
|
opts['branch'] = opts['branch'] + opts.pop('only_branch')
|
|
|
|
|
|
follow = opts.get('follow') or opts.get('follow_first')
|
|
|
followfirst = opts.get('follow_first')
|
|
|
if 'follow' in opts:
|
|
|
del opts['follow']
|
|
|
if 'follow_first' in opts:
|
|
|
del opts['follow_first']
|
|
|
# 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:']
|
|
|
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['rev'] = opts.get('rev', []) + ['_matchfiles(%s)' % matchargs]
|
|
|
else:
|
|
|
if follow:
|
|
|
if followfirst:
|
|
|
if pats:
|
|
|
opts['_patsfollowfirst'] = list(pats)
|
|
|
else:
|
|
|
opts['follow_first'] = True
|
|
|
else:
|
|
|
if pats:
|
|
|
opts['_patsfollow'] = list(pats)
|
|
|
else:
|
|
|
opts['follow'] = True
|
|
|
else:
|
|
|
opts['_patslog'] = list(pats)
|
|
|
|
|
|
revset = []
|
|
|
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:
|
|
|
revset.append(revop)
|
|
|
else:
|
|
|
if not isinstance(val, list):
|
|
|
expr = revop % {'val': val}
|
|
|
else:
|
|
|
expr = '(' + andor.join((revop % {'val': v}) for v in val) + ')'
|
|
|
revset.append(expr)
|
|
|
|
|
|
if revset:
|
|
|
revset = '(' + ' and '.join(revset) + ')'
|
|
|
else:
|
|
|
revset = 'all()'
|
|
|
return revset
|
|
|
|
|
|
def generate(ui, dag, displayer, showparents, edgefn, getrenamed=None):
|
|
|
seen, state = [], asciistate()
|
|
|
for rev, type, ctx, parents in dag:
|
|
|
char = ctx.node() in showparents and '@' or 'o'
|
|
|
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]))
|
|
|
displayer.show(ctx, copies=copies)
|
|
|
lines = displayer.hunk.pop(rev).split('\n')[:-1]
|
|
|
displayer.flush(rev)
|
|
|
edges = edgefn(type, char, lines, seen, rev, parents)
|
|
|
for type, char, lines, coldata in edges:
|
|
|
ascii(ui, state, type, char, lines, coldata)
|
|
|
displayer.close()
|
|
|
|
|
|
@command('glog',
|
|
|
[('l', 'limit', '',
|
|
|
_('limit number of changes displayed'), _('NUM')),
|
|
|
('p', 'patch', False, _('show patch')),
|
|
|
('r', 'rev', [], _('show the specified revision or range'), _('REV')),
|
|
|
] + templateopts,
|
|
|
_('hg glog [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.
|
|
|
"""
|
|
|
|
|
|
check_unsupported_flags(pats, opts)
|
|
|
|
|
|
revs = sorted(scmutil.revrange(repo, [revset(repo, pats, opts)]), 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, asciiedges, getrenamed)
|
|
|
|
|
|
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.
|
|
|
"""
|
|
|
|
|
|
check_unsupported_flags([], 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, 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
|
|
|
|
|
|
check_unsupported_flags([], 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, 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")))
|
|
|
|