graphlog.py
416 lines
| 14.4 KiB
| text/x-python
|
PythonLexer
/ hgext / graphlog.py
|
r4344 | # ASCII graph log extension for Mercurial | ||
# | ||||
# Copyright 2007 Joel Rosdahl <joel@rosdahl.net> | ||||
|
r4516 | # | ||
|
r8225 | # This software may be used and distributed according to the terms of the | ||
# GNU General Public License version 2, incorporated herein by reference. | ||||
|
r8228 | |||
|
r7426 | '''show revision graphs in terminal windows | ||
This extension adds a --graph option to the incoming, outgoing and log | ||||
|
r8667 | commands. When this options is given, an ASCII representation of the | ||
|
r7426 | revision graph is also shown. | ||
''' | ||||
|
r4344 | |||
|
r5938 | import os | ||
|
r4344 | from mercurial.cmdutil import revrange, show_changeset | ||
|
r7873 | from mercurial.commands import templateopts | ||
|
r4344 | from mercurial.i18n import _ | ||
|
r6212 | from mercurial.node import nullrev | ||
|
r7426 | from mercurial import bundlerepo, changegroup, cmdutil, commands, extensions | ||
|
r7873 | from mercurial import hg, url, util | ||
|
r4344 | |||
|
r7370 | def revisions(repo, start, stop): | ||
"""cset DAG generator yielding (rev, node, [parents]) tuples | ||||
|
r7374 | |||
|
r7370 | This generator function walks through the revision history from revision | ||
start to revision stop (which must be less than or equal to start). | ||||
""" | ||||
assert start >= stop | ||||
cur = start | ||||
while cur >= stop: | ||||
ctx = repo[cur] | ||||
|
r7379 | parents = [p.rev() for p in ctx.parents() if p.rev() != nullrev] | ||
parents.sort() | ||||
|
r7370 | yield (ctx, parents) | ||
cur -= 1 | ||||
|
r4344 | |||
|
r7370 | def filerevs(repo, path, start, stop): | ||
"""file cset DAG generator yielding (rev, node, [parents]) tuples | ||||
|
r7374 | |||
|
r7370 | This generator function walks through the revision history of a single | ||
file from revision start to revision stop (which must be less than or | ||||
equal to start). | ||||
|
r5938 | """ | ||
|
r7370 | assert start >= stop | ||
|
r6750 | filerev = len(repo.file(path)) - 1 | ||
|
r5938 | while filerev >= 0: | ||
fctx = repo.filectx(path, fileid=filerev) | ||||
|
r7383 | parents = [f.linkrev() for f in fctx.parents() if f.path() == path] | ||
|
r7370 | parents.sort() | ||
if fctx.rev() <= start: | ||||
yield (fctx, parents) | ||||
if fctx.rev() <= stop: | ||||
break | ||||
filerev -= 1 | ||||
|
r5938 | |||
|
r7370 | def grapher(nodes): | ||
"""grapher for asciigraph on a list of nodes and their parents | ||||
|
r7374 | |||
|
r7370 | nodes must generate tuples (node, parents, char, lines) where | ||
- parents must generate the parents of node, in sorted order, | ||||
and max length 2, | ||||
- char is the char to print as the node symbol, and | ||||
|
r7374 | - lines are the lines to display next to the node. | ||
|
r7370 | """ | ||
seen = [] | ||||
for node, parents, char, lines in nodes: | ||||
if node not in seen: | ||||
seen.append(node) | ||||
nodeidx = seen.index(node) | ||||
|
r5938 | |||
|
r7370 | knownparents = [] | ||
newparents = [] | ||||
|
r5938 | for parent in parents: | ||
|
r7370 | if parent in seen: | ||
knownparents.append(parent) | ||||
else: | ||||
newparents.append(parent) | ||||
|
r5938 | |||
|
r7370 | ncols = len(seen) | ||
nextseen = seen[:] | ||||
nextseen[nodeidx:nodeidx + 1] = newparents | ||||
edges = [(nodeidx, nextseen.index(p)) for p in knownparents] | ||||
if len(newparents) > 0: | ||||
edges.append((nodeidx, nodeidx)) | ||||
if len(newparents) > 1: | ||||
edges.append((nodeidx, nodeidx + 1)) | ||||
nmorecols = len(nextseen) - ncols | ||||
seen = nextseen | ||||
yield (char, lines, nodeidx, edges, ncols, nmorecols) | ||||
|
r5938 | |||
|
r4344 | def fix_long_right_edges(edges): | ||
for (i, (start, end)) in enumerate(edges): | ||||
if end > start: | ||||
edges[i] = (start, end + 1) | ||||
|
r7326 | 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) | ||||
|
r4344 | 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: | ||||
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 | ||||
|
r7325 | def ascii(ui, grapher): | ||
"""prints an ASCII graph of the DAG returned by the grapher | ||||
|
r4344 | |||
|
r7325 | grapher is a generator that emits tuples with the following elements: | ||
|
r4344 | |||
|
r7325 | - Character to use as node's symbol. | ||
- List of lines to display as the node's text. | ||||
- Column of the current node in the set of ongoing edges. | ||||
- 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. | ||||
""" | ||||
|
r4344 | prev_n_columns_diff = 0 | ||
prev_node_index = 0 | ||||
|
r7325 | for (node_ch, node_lines, node_index, edges, n_columns, n_columns_diff) in grapher: | ||
|
r4344 | |||
|
r7356 | assert -2 < n_columns_diff < 2 | ||
|
r4344 | if n_columns_diff == -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 | | | ||||
|
r7324 | add_padding_line = (len(node_lines) > 2 and | ||
|
r4633 | n_columns_diff == -1 and | ||
[x for (x, y) in edges if x + 1 < y]) | ||||
|
r4344 | |||
# fix_nodeline_tail says whether to rewrite | ||||
# | ||||
# | | o | | | | o | | | ||||
# | | |/ / | | |/ / | ||||
# | o | | into | o / / # <--- fixed nodeline tail | ||||
# | |/ / | |/ / | ||||
# o | | o | | | ||||
|
r7324 | fix_nodeline_tail = len(node_lines) <= 2 and not add_padding_line | ||
|
r4344 | |||
|
r7323 | # nodeline is the line containing the node character (typically o) | ||
|
r4344 | nodeline = ["|", " "] * node_index | ||
nodeline.extend([node_ch, " "]) | ||||
nodeline.extend( | ||||
get_nodeline_edges_tail( | ||||
node_index, prev_node_index, n_columns, n_columns_diff, | ||||
prev_n_columns_diff, fix_nodeline_tail)) | ||||
# shift_interline is the line containing the non-vertical | ||||
|
r7323 | # edges between this entry and the next | ||
|
r4344 | shift_interline = ["|", " "] * node_index | ||
if n_columns_diff == -1: | ||||
n_spaces = 1 | ||||
edge_ch = "/" | ||||
elif n_columns_diff == 0: | ||||
n_spaces = 2 | ||||
edge_ch = "|" | ||||
else: | ||||
n_spaces = 3 | ||||
edge_ch = "\\" | ||||
shift_interline.extend(n_spaces * [" "]) | ||||
shift_interline.extend([edge_ch, " "] * (n_columns - node_index - 1)) | ||||
|
r7323 | # draw edges from the current node to its parents | ||
|
r4344 | draw_edges(edges, nodeline, shift_interline) | ||
|
r7323 | # lines is the list of all graph lines to print | ||
|
r4344 | lines = [nodeline] | ||
if add_padding_line: | ||||
lines.append(get_padding_line(node_index, n_columns, edges)) | ||||
lines.append(shift_interline) | ||||
|
r7323 | # make sure that there are as many graph lines as there are | ||
# log strings | ||||
|
r7324 | while len(node_lines) < len(lines): | ||
node_lines.append("") | ||||
if len(lines) < len(node_lines): | ||||
|
r4344 | extra_interline = ["|", " "] * (n_columns + n_columns_diff) | ||
|
r7324 | while len(lines) < len(node_lines): | ||
|
r4344 | lines.append(extra_interline) | ||
|
r7323 | # print lines | ||
|
r4344 | indentation_level = max(n_columns, n_columns + n_columns_diff) | ||
|
r7324 | for (line, logstr) in zip(lines, node_lines): | ||
|
r7326 | ln = "%-*s %s" % (2 * indentation_level, "".join(line), logstr) | ||
ui.write(ln.rstrip() + '\n') | ||||
|
r4344 | |||
|
r7323 | # ... and start over | ||
|
r4344 | prev_node_index = node_index | ||
prev_n_columns_diff = n_columns_diff | ||||
|
r7326 | def get_revs(repo, rev_opt): | ||
if rev_opt: | ||||
revs = revrange(repo, rev_opt) | ||||
return (max(revs), min(revs)) | ||||
else: | ||||
return (len(repo) - 1, 0) | ||||
|
r7426 | def check_unsupported_flags(opts): | ||
for op in ["follow", "follow_first", "date", "copies", "keyword", "remove", | ||||
"only_merges", "user", "only_branch", "prune", "newest_first", | ||||
"no_merges", "include", "exclude"]: | ||||
if op in opts and opts[op]: | ||||
|
r7713 | raise util.Abort(_("--graph option is incompatible with --%s") % op) | ||
|
r7426 | |||
|
r7325 | def graphlog(ui, repo, path=None, **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. | ||||
""" | ||||
|
r7426 | check_unsupported_flags(opts) | ||
|
r7715 | limit = cmdutil.loglimit(opts) | ||
|
r7370 | start, stop = get_revs(repo, opts["rev"]) | ||
stop = max(stop, start - limit + 1) | ||||
if start == nullrev: | ||||
|
r7325 | return | ||
|
r7370 | |||
|
r7325 | if path: | ||
|
r7713 | path = util.canonpath(repo.root, os.getcwd(), path) | ||
|
r7370 | if path: # could be reset in canonpath | ||
revdag = filerevs(repo, path, start, stop) | ||||
|
r7325 | else: | ||
|
r7370 | revdag = revisions(repo, start, stop) | ||
|
r7325 | |||
|
r7716 | graphdag = graphabledag(ui, repo, revdag, opts) | ||
ascii(ui, grapher(graphdag)) | ||||
def graphrevs(repo, nodes, opts): | ||||
|
r8150 | include = set(nodes) | ||
|
r7716 | limit = cmdutil.loglimit(opts) | ||
count = 0 | ||||
|
r8210 | for node in reversed(nodes): | ||
|
r7716 | if count >= limit: | ||
break | ||||
ctx = repo[node] | ||||
parents = [p.rev() for p in ctx.parents() if p.node() in include] | ||||
parents.sort() | ||||
yield (ctx, parents) | ||||
count += 1 | ||||
def graphabledag(ui, repo, revdag, opts): | ||||
showparents = [ctx.node() for ctx in repo[None].parents()] | ||||
|
r7371 | displayer = show_changeset(ui, repo, opts, buffered=True) | ||
|
r7716 | for (ctx, parents) in revdag: | ||
displayer.show(ctx) | ||||
lines = displayer.hunk.pop(ctx.rev()).split('\n')[:-1] | ||||
char = ctx.node() in showparents and '@' or 'o' | ||||
yield (ctx.rev(), parents, char, lines) | ||||
def goutgoing(ui, repo, dest=None, **opts): | ||||
"""show the outgoing changesets alongside an ASCII revision graph | ||||
|
r7325 | |||
|
r7716 | Print the outgoing changesets alongside a revision graph drawn with | ||
ASCII characters. | ||||
|
r7426 | |||
|
r7716 | Nodes printed as an @ character are parents of the working | ||
directory. | ||||
|
r7426 | """ | ||
|
r7716 | |||
check_unsupported_flags(opts) | ||||
|
r7426 | dest, revs, checkout = hg.parseurl( | ||
ui.expandpath(dest or 'default-push', dest or 'default'), | ||||
opts.get('rev')) | ||||
if revs: | ||||
revs = [repo.lookup(rev) for rev in revs] | ||||
|
r8188 | other = hg.repository(cmdutil.remoteui(ui, opts), dest) | ||
|
r7426 | ui.status(_('comparing with %s\n') % url.hidepassword(dest)) | ||
o = repo.findoutgoing(other, force=opts.get('force')) | ||||
if not o: | ||||
ui.status(_("no changes found\n")) | ||||
return | ||||
|
r7716 | |||
|
r7426 | o = repo.changelog.nodesbetween(o, revs)[0] | ||
|
r7716 | revdag = graphrevs(repo, o, opts) | ||
graphdag = graphabledag(ui, repo, revdag, opts) | ||||
ascii(ui, grapher(graphdag)) | ||||
|
r7426 | |||
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. | ||||
""" | ||||
check_unsupported_flags(opts) | ||||
source, revs, checkout = hg.parseurl(ui.expandpath(source), opts.get('rev')) | ||||
|
r8188 | other = hg.repository(cmdutil.remoteui(repo, opts), source) | ||
|
r7426 | ui.status(_('comparing with %s\n') % url.hidepassword(source)) | ||
if revs: | ||||
revs = [other.lookup(rev) for rev in revs] | ||||
incoming = repo.findincoming(other, heads=revs, force=opts["force"]) | ||||
if not incoming: | ||||
try: | ||||
os.unlink(opts["bundle"]) | ||||
except: | ||||
pass | ||||
ui.status(_("no changes found\n")) | ||||
return | ||||
cleanup = None | ||||
try: | ||||
|
r7716 | |||
|
r7426 | fname = opts["bundle"] | ||
if fname or not other.local(): | ||||
# create a bundle (uncompressed if other repo is not local) | ||||
if revs is None: | ||||
cg = other.changegroup(incoming, "incoming") | ||||
else: | ||||
cg = other.changegroupsubset(incoming, revs, 'incoming') | ||||
bundletype = other.local() and "HG10BZ" or "HG10UN" | ||||
fname = cleanup = changegroup.writebundle(cg, fname, bundletype) | ||||
# keep written bundle? | ||||
if opts["bundle"]: | ||||
cleanup = None | ||||
if not other.local(): | ||||
# use the created uncompressed bundlerepo | ||||
other = bundlerepo.bundlerepository(ui, repo.root, fname) | ||||
chlist = other.changelog.nodesbetween(incoming, revs)[0] | ||||
|
r7716 | revdag = graphrevs(other, chlist, opts) | ||
graphdag = graphabledag(ui, repo, revdag, opts) | ||||
ascii(ui, grapher(graphdag)) | ||||
|
r7426 | |||
finally: | ||||
if hasattr(other, 'close'): | ||||
other.close() | ||||
if cleanup: | ||||
os.unlink(cleanup) | ||||
def uisetup(ui): | ||||
'''Initialize the extension.''' | ||||
_wrapcmd(ui, 'log', commands.table, graphlog) | ||||
_wrapcmd(ui, 'incoming', commands.table, gincoming) | ||||
_wrapcmd(ui, 'outgoing', commands.table, goutgoing) | ||||
def _wrapcmd(ui, 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) | ||||
|
r7763 | entry[1].append(('G', 'graph', None, _("show the revision DAG"))) | ||
|
r7426 | |||
|
r4344 | cmdtable = { | ||
"glog": | ||||
|
r4730 | (graphlog, | ||
[('l', 'limit', '', _('limit number of changes displayed')), | ||||
('p', 'patch', False, _('show patch')), | ||||
('r', 'rev', [], _('show the specified revision or range')), | ||||
|
r6192 | ] + templateopts, | ||
|
r5942 | _('hg glog [OPTION]... [FILE]')), | ||
|
r4344 | } | ||