|
|
# Revision graph generator for Mercurial
|
|
|
#
|
|
|
# Copyright 2008 Dirkjan Ochtman <dirkjan@ochtman.nl>
|
|
|
# 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.
|
|
|
|
|
|
"""supports walking the history as DAGs suitable for graphical output
|
|
|
|
|
|
The most basic format we use is that of::
|
|
|
|
|
|
(id, type, data, [parentids])
|
|
|
|
|
|
The node and parent ids are arbitrary integers which identify a node in the
|
|
|
context of the graph returned. Type is a constant specifying the node type.
|
|
|
Data depends on type.
|
|
|
"""
|
|
|
|
|
|
from __future__ import absolute_import
|
|
|
|
|
|
from .node import nullrev
|
|
|
from . import (
|
|
|
dagop,
|
|
|
pycompat,
|
|
|
smartset,
|
|
|
util,
|
|
|
)
|
|
|
|
|
|
CHANGESET = 'C'
|
|
|
PARENT = 'P'
|
|
|
GRANDPARENT = 'G'
|
|
|
MISSINGPARENT = 'M'
|
|
|
# Style of line to draw. None signals a line that ends and is removed at this
|
|
|
# point. A number prefix means only the last N characters of the current block
|
|
|
# will use that style, the rest will use the PARENT style. Add a - sign
|
|
|
# (so making N negative) and all but the first N characters use that style.
|
|
|
EDGES = {PARENT: '|', GRANDPARENT: ':', MISSINGPARENT: None}
|
|
|
|
|
|
def dagwalker(repo, revs):
|
|
|
"""cset DAG generator yielding (id, CHANGESET, ctx, [parentinfo]) tuples
|
|
|
|
|
|
This generator function walks through revisions (which should be ordered
|
|
|
from bigger to lower). It returns a tuple for each node.
|
|
|
|
|
|
Each parentinfo entry is a tuple with (edgetype, parentid), where edgetype
|
|
|
is one of PARENT, GRANDPARENT or MISSINGPARENT. The node and parent ids
|
|
|
are arbitrary integers which identify a node in the context of the graph
|
|
|
returned.
|
|
|
|
|
|
"""
|
|
|
gpcache = {}
|
|
|
|
|
|
for rev in revs:
|
|
|
ctx = repo[rev]
|
|
|
# partition into parents in the rev set and missing parents, then
|
|
|
# augment the lists with markers, to inform graph drawing code about
|
|
|
# what kind of edge to draw between nodes.
|
|
|
pset = set(p.rev() for p in ctx.parents() if p.rev() in revs)
|
|
|
mpars = [p.rev() for p in ctx.parents()
|
|
|
if p.rev() != nullrev and p.rev() not in pset]
|
|
|
parents = [(PARENT, p) for p in sorted(pset)]
|
|
|
|
|
|
for mpar in mpars:
|
|
|
gp = gpcache.get(mpar)
|
|
|
if gp is None:
|
|
|
# precompute slow query as we know reachableroots() goes
|
|
|
# through all revs (issue4782)
|
|
|
if not isinstance(revs, smartset.baseset):
|
|
|
revs = smartset.baseset(revs)
|
|
|
gp = gpcache[mpar] = sorted(set(dagop.reachableroots(
|
|
|
repo, revs, [mpar])))
|
|
|
if not gp:
|
|
|
parents.append((MISSINGPARENT, mpar))
|
|
|
pset.add(mpar)
|
|
|
else:
|
|
|
parents.extend((GRANDPARENT, g) for g in gp if g not in pset)
|
|
|
pset.update(gp)
|
|
|
|
|
|
yield (ctx.rev(), CHANGESET, ctx, parents)
|
|
|
|
|
|
def nodes(repo, nodes):
|
|
|
"""cset DAG generator yielding (id, CHANGESET, ctx, [parentids]) tuples
|
|
|
|
|
|
This generator function walks the given nodes. It only returns parents
|
|
|
that are in nodes, too.
|
|
|
"""
|
|
|
include = set(nodes)
|
|
|
for node in nodes:
|
|
|
ctx = repo[node]
|
|
|
parents = set((PARENT, p.rev()) for p in ctx.parents()
|
|
|
if p.node() in include)
|
|
|
yield (ctx.rev(), CHANGESET, ctx, sorted(parents))
|
|
|
|
|
|
def colored(dag, repo):
|
|
|
"""annotates a DAG with colored edge information
|
|
|
|
|
|
For each DAG node this function emits tuples::
|
|
|
|
|
|
(id, type, data, (col, color), [(col, nextcol, color)])
|
|
|
|
|
|
with the following new elements:
|
|
|
|
|
|
- Tuple (col, color) with column and color index for the current node
|
|
|
- A list of tuples indicating the edges between the current node and its
|
|
|
parents.
|
|
|
"""
|
|
|
seen = []
|
|
|
colors = {}
|
|
|
newcolor = 1
|
|
|
config = {}
|
|
|
|
|
|
for key, val in repo.ui.configitems('graph'):
|
|
|
if '.' in key:
|
|
|
branch, setting = key.rsplit('.', 1)
|
|
|
# Validation
|
|
|
if setting == "width" and val.isdigit():
|
|
|
config.setdefault(branch, {})[setting] = int(val)
|
|
|
elif setting == "color" and val.isalnum():
|
|
|
config.setdefault(branch, {})[setting] = val
|
|
|
|
|
|
if config:
|
|
|
getconf = util.lrucachefunc(
|
|
|
lambda rev: config.get(repo[rev].branch(), {}))
|
|
|
else:
|
|
|
getconf = lambda rev: {}
|
|
|
|
|
|
for (cur, type, data, parents) in dag:
|
|
|
|
|
|
# Compute seen and next
|
|
|
if cur not in seen:
|
|
|
seen.append(cur) # new head
|
|
|
colors[cur] = newcolor
|
|
|
newcolor += 1
|
|
|
|
|
|
col = seen.index(cur)
|
|
|
color = colors.pop(cur)
|
|
|
next = seen[:]
|
|
|
|
|
|
# Add parents to next
|
|
|
addparents = [p for pt, p in parents if p not in next]
|
|
|
next[col:col + 1] = addparents
|
|
|
|
|
|
# Set colors for the parents
|
|
|
for i, p in enumerate(addparents):
|
|
|
if not i:
|
|
|
colors[p] = color
|
|
|
else:
|
|
|
colors[p] = newcolor
|
|
|
newcolor += 1
|
|
|
|
|
|
# Add edges to the graph
|
|
|
edges = []
|
|
|
for ecol, eid in enumerate(seen):
|
|
|
if eid in next:
|
|
|
bconf = getconf(eid)
|
|
|
edges.append((
|
|
|
ecol, next.index(eid), colors[eid],
|
|
|
bconf.get('width', -1),
|
|
|
bconf.get('color', '')))
|
|
|
elif eid == cur:
|
|
|
for ptype, p in parents:
|
|
|
bconf = getconf(p)
|
|
|
edges.append((
|
|
|
ecol, next.index(p), color,
|
|
|
bconf.get('width', -1),
|
|
|
bconf.get('color', '')))
|
|
|
|
|
|
# Yield and move on
|
|
|
yield (cur, type, data, (col, color), edges)
|
|
|
seen = next
|
|
|
|
|
|
def asciiedges(type, char, state, rev, parents):
|
|
|
"""adds edge info to changelog DAG walk suitable for ascii()"""
|
|
|
seen = state['seen']
|
|
|
if rev not in seen:
|
|
|
seen.append(rev)
|
|
|
nodeidx = seen.index(rev)
|
|
|
|
|
|
knownparents = []
|
|
|
newparents = []
|
|
|
for ptype, parent in parents:
|
|
|
if parent == rev:
|
|
|
# self reference (should only be seen in null rev)
|
|
|
continue
|
|
|
if parent in seen:
|
|
|
knownparents.append(parent)
|
|
|
else:
|
|
|
newparents.append(parent)
|
|
|
state['edges'][parent] = state['styles'].get(ptype, '|')
|
|
|
|
|
|
ncols = len(seen)
|
|
|
width = 1 + ncols * 2
|
|
|
nextseen = seen[:]
|
|
|
nextseen[nodeidx:nodeidx + 1] = newparents
|
|
|
edges = [(nodeidx, nextseen.index(p)) for p in knownparents]
|
|
|
|
|
|
seen[:] = nextseen
|
|
|
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
|
|
|
width += 2
|
|
|
yield (type, char, width, (nodeidx, edges, ncols, nmorecols))
|
|
|
char = '\\'
|
|
|
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
|
|
|
if nmorecols > 0:
|
|
|
width += 2
|
|
|
# remove current node from edge characters, no longer needed
|
|
|
state['edges'].pop(rev, None)
|
|
|
yield (type, char, width, (nodeidx, edges, ncols, nmorecols))
|
|
|
|
|
|
def _fixlongrightedges(edges):
|
|
|
for (i, (start, end)) in enumerate(edges):
|
|
|
if end > start:
|
|
|
edges[i] = (start, end + 1)
|
|
|
|
|
|
def _getnodelineedgestail(
|
|
|
echars, idx, pidx, ncols, coldiff, pdiff, fix_tail):
|
|
|
if fix_tail and coldiff == pdiff and coldiff != 0:
|
|
|
# Still going in the same non-vertical direction.
|
|
|
if coldiff == -1:
|
|
|
start = max(idx + 1, pidx)
|
|
|
tail = echars[idx * 2:(start - 1) * 2]
|
|
|
tail.extend(["/", " "] * (ncols - start))
|
|
|
return tail
|
|
|
else:
|
|
|
return ["\\", " "] * (ncols - idx - 1)
|
|
|
else:
|
|
|
remainder = (ncols - idx - 1)
|
|
|
return echars[-(remainder * 2):] if remainder > 0 else []
|
|
|
|
|
|
def _drawedges(echars, 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] = echars[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 _getpaddingline(echars, idx, ncols, edges):
|
|
|
# all edges up to the current node
|
|
|
line = echars[:idx * 2]
|
|
|
# an edge for the current node, if there is one
|
|
|
if (idx, idx - 1) in edges or (idx, idx) in edges:
|
|
|
# (idx, idx - 1) (idx, idx)
|
|
|
# | | | | | | | |
|
|
|
# +---o | | o---+
|
|
|
# | | X | | X | |
|
|
|
# | |/ / | |/ /
|
|
|
# | | | | | |
|
|
|
line.extend(echars[idx * 2:(idx + 1) * 2])
|
|
|
else:
|
|
|
line.extend([' ', ' '])
|
|
|
# all edges to the right of the current node
|
|
|
remainder = ncols - idx - 1
|
|
|
if remainder > 0:
|
|
|
line.extend(echars[-(remainder * 2):])
|
|
|
return line
|
|
|
|
|
|
def _drawendinglines(lines, extra, edgemap, seen, state):
|
|
|
"""Draw ending lines for missing parent edges
|
|
|
|
|
|
None indicates an edge that ends at between this node and the next
|
|
|
Replace with a short line ending in ~ and add / lines to any edges to
|
|
|
the right.
|
|
|
|
|
|
"""
|
|
|
if None not in edgemap.values():
|
|
|
return
|
|
|
|
|
|
# Check for more edges to the right of our ending edges.
|
|
|
# We need enough space to draw adjustment lines for these.
|
|
|
edgechars = extra[::2]
|
|
|
while edgechars and edgechars[-1] is None:
|
|
|
edgechars.pop()
|
|
|
shift_size = max((edgechars.count(None) * 2) - 1, 0)
|
|
|
minlines = 3 if not state['graphshorten'] else 2
|
|
|
while len(lines) < minlines + shift_size:
|
|
|
lines.append(extra[:])
|
|
|
|
|
|
if shift_size:
|
|
|
empties = []
|
|
|
toshift = []
|
|
|
first_empty = extra.index(None)
|
|
|
for i, c in enumerate(extra[first_empty::2], first_empty // 2):
|
|
|
if c is None:
|
|
|
empties.append(i * 2)
|
|
|
else:
|
|
|
toshift.append(i * 2)
|
|
|
targets = list(range(first_empty, first_empty + len(toshift) * 2, 2))
|
|
|
positions = toshift[:]
|
|
|
for line in lines[-shift_size:]:
|
|
|
line[first_empty:] = [' '] * (len(line) - first_empty)
|
|
|
for i in range(len(positions)):
|
|
|
pos = positions[i] - 1
|
|
|
positions[i] = max(pos, targets[i])
|
|
|
line[pos] = '/' if pos > targets[i] else extra[toshift[i]]
|
|
|
|
|
|
map = {1: '|', 2: '~'} if not state['graphshorten'] else {1: '~'}
|
|
|
for i, line in enumerate(lines):
|
|
|
if None not in line:
|
|
|
continue
|
|
|
line[:] = [c or map.get(i, ' ') for c in line]
|
|
|
|
|
|
# remove edges that ended
|
|
|
remove = [p for p, c in edgemap.items() if c is None]
|
|
|
for parent in remove:
|
|
|
del edgemap[parent]
|
|
|
seen.remove(parent)
|
|
|
|
|
|
def asciistate():
|
|
|
"""returns the initial value for the "state" argument to ascii()"""
|
|
|
return {
|
|
|
'seen': [],
|
|
|
'edges': {},
|
|
|
'lastcoldiff': 0,
|
|
|
'lastindex': 0,
|
|
|
'styles': EDGES.copy(),
|
|
|
'graphshorten': False,
|
|
|
}
|
|
|
|
|
|
def outputgraph(ui, graph):
|
|
|
"""outputs an ASCII graph of a DAG
|
|
|
|
|
|
this is a helper function for 'ascii' below.
|
|
|
|
|
|
takes the following arguments:
|
|
|
|
|
|
- ui to write to
|
|
|
- graph data: list of { graph nodes/edges, text }
|
|
|
|
|
|
this function can be monkey-patched by extensions to alter graph display
|
|
|
without needing to mimic all of the edge-fixup logic in ascii()
|
|
|
"""
|
|
|
for (ln, logstr) in graph:
|
|
|
ui.write((ln + logstr).rstrip() + "\n")
|
|
|
|
|
|
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, usually 'C' for changesets.
|
|
|
- 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
|
|
|
|
|
|
edgemap, seen = state['edges'], state['seen']
|
|
|
# Be tolerant of history issues; make sure we have at least ncols + coldiff
|
|
|
# elements to work with. See test-glog.t for broken history test cases.
|
|
|
echars = [c for p in seen for c in (edgemap.get(p, '|'), ' ')]
|
|
|
echars.extend(('|', ' ') * max(ncols + coldiff - len(seen), 0))
|
|
|
|
|
|
if coldiff == -1:
|
|
|
# Transform
|
|
|
#
|
|
|
# | | | | | |
|
|
|
# o | | into o---+
|
|
|
# |X / |/ /
|
|
|
# | | | |
|
|
|
_fixlongrightedges(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 = echars[:idx * 2]
|
|
|
nodeline.extend([char, " "])
|
|
|
|
|
|
nodeline.extend(
|
|
|
_getnodelineedgestail(
|
|
|
echars, idx, state['lastindex'], ncols, coldiff,
|
|
|
state['lastcoldiff'], fix_nodeline_tail))
|
|
|
|
|
|
# shift_interline is the line containing the non-vertical
|
|
|
# edges between this entry and the next
|
|
|
shift_interline = echars[:idx * 2]
|
|
|
for i in pycompat.xrange(2 + coldiff):
|
|
|
shift_interline.append(' ')
|
|
|
count = ncols - idx - 1
|
|
|
if coldiff == -1:
|
|
|
for i in pycompat.xrange(count):
|
|
|
shift_interline.extend(['/', ' '])
|
|
|
elif coldiff == 0:
|
|
|
shift_interline.extend(echars[(idx + 1) * 2:ncols * 2])
|
|
|
else:
|
|
|
for i in pycompat.xrange(count):
|
|
|
shift_interline.extend(['\\', ' '])
|
|
|
|
|
|
# draw edges from the current node to its parents
|
|
|
_drawedges(echars, edges, nodeline, shift_interline)
|
|
|
|
|
|
# lines is the list of all graph lines to print
|
|
|
lines = [nodeline]
|
|
|
if add_padding_line:
|
|
|
lines.append(_getpaddingline(echars, idx, ncols, edges))
|
|
|
|
|
|
# If 'graphshorten' config, only draw shift_interline
|
|
|
# when there is any non vertical flow in graph.
|
|
|
if state['graphshorten']:
|
|
|
if any(c in '\/' for c in shift_interline if c):
|
|
|
lines.append(shift_interline)
|
|
|
# Else, no 'graphshorten' config so draw shift_interline.
|
|
|
else:
|
|
|
lines.append(shift_interline)
|
|
|
|
|
|
# make sure that there are as many graph lines as there are
|
|
|
# log strings
|
|
|
extra_interline = echars[:(ncols + coldiff) * 2]
|
|
|
if len(lines) < len(text):
|
|
|
while len(lines) < len(text):
|
|
|
lines.append(extra_interline[:])
|
|
|
|
|
|
_drawendinglines(lines, extra_interline, edgemap, seen, state)
|
|
|
|
|
|
while len(text) < len(lines):
|
|
|
text.append("")
|
|
|
|
|
|
if any(len(char) > 1 for char in edgemap.values()):
|
|
|
# limit drawing an edge to the first or last N lines of the current
|
|
|
# section the rest of the edge is drawn like a parent line.
|
|
|
parent = state['styles'][PARENT][-1:]
|
|
|
def _drawgp(char, i):
|
|
|
# should a grandparent character be drawn for this line?
|
|
|
if len(char) < 2:
|
|
|
return True
|
|
|
num = int(char[:-1])
|
|
|
# either skip first num lines or take last num lines, based on sign
|
|
|
return -num <= i if num < 0 else (len(lines) - i) <= num
|
|
|
for i, line in enumerate(lines):
|
|
|
line[:] = [c[-1:] if _drawgp(c, i) else parent for c in line]
|
|
|
edgemap.update(
|
|
|
(e, (c if len(c) < 2 else parent)) for e, c in edgemap.items())
|
|
|
|
|
|
# print lines
|
|
|
indentation_level = max(ncols, ncols + coldiff)
|
|
|
lines = ["%-*s " % (2 * indentation_level, "".join(line)) for line in lines]
|
|
|
outputgraph(ui, zip(lines, text))
|
|
|
|
|
|
# ... and start over
|
|
|
state['lastcoldiff'] = coldiff
|
|
|
state['lastindex'] = idx
|
|
|
|