drawdag.py
435 lines
| 12.8 KiB
| text/x-python
|
PythonLexer
/ tests / drawdag.py
|
r30449 | # drawdag.py - convert ASCII revision DAG to actual changesets | ||
# | ||||
# Copyright 2016 Facebook, Inc. | ||||
# | ||||
# This software may be used and distributed according to the terms of the | ||||
# GNU General Public License version 2 or any later version. | ||||
""" | ||||
create changesets from an ASCII graph for testing purpose. | ||||
For example, given the following input:: | ||||
c d | ||||
|/ | ||||
b | ||||
| | ||||
a | ||||
4 changesets and 4 local tags will be created. | ||||
`hg log -G -T "{rev} {desc} (tag: {tags})"` will output:: | ||||
o 3 d (tag: d tip) | ||||
| | ||||
| o 2 c (tag: c) | ||||
|/ | ||||
o 1 b (tag: b) | ||||
| | ||||
o 0 a (tag: a) | ||||
For root nodes (nodes without parents) in the graph, they can be revsets | ||||
pointing to existing nodes. The ASCII graph could also have disconnected | ||||
components with same names referring to the same changeset. | ||||
Therefore, given the repo having the 4 changesets (and tags) above, with the | ||||
following ASCII graph as input:: | ||||
foo bar bar foo | ||||
| / | | | ||||
ancestor(c,d) a baz | ||||
The result (`hg log -G -T "{desc}"`) will look like:: | ||||
o foo | ||||
|\ | ||||
+---o bar | ||||
| | | | ||||
| o | baz | ||||
| / | ||||
+---o d | ||||
| | | ||||
+---o c | ||||
| | | ||||
o | b | ||||
|/ | ||||
o a | ||||
Note that if you take the above `hg log` output directly as input. It will work | ||||
as expected - the result would be an isomorphic graph:: | ||||
o foo | ||||
|\ | ||||
| | o d | ||||
| |/ | ||||
| | o c | ||||
| |/ | ||||
| | o bar | ||||
| |/| | ||||
| o | b | ||||
| |/ | ||||
o / baz | ||||
/ | ||||
o a | ||||
This is because 'o' is specially handled in the input: instead of using 'o' as | ||||
the node name, the word to the right will be used. | ||||
|
r33154 | |||
Some special comments could have side effects: | ||||
- Create obsmarkers | ||||
# replace: A -> B -> C -> D # chained 1 to 1 replacements | ||||
# split: A -> B, C # 1 to many | ||||
# prune: A, B, C # many to nothing | ||||
|
r30449 | """ | ||
from __future__ import absolute_import, print_function | ||||
import collections | ||||
import itertools | ||||
|
r33785 | import re | ||
|
r30449 | |||
from mercurial.i18n import _ | ||||
from mercurial import ( | ||||
context, | ||||
error, | ||||
node, | ||||
|
r33154 | obsolete, | ||
|
r34204 | pycompat, | ||
|
r32337 | registrar, | ||
|
r30449 | scmutil, | ||
|
r31671 | tags as tagsmod, | ||
|
r30449 | ) | ||
cmdtable = {} | ||||
|
r32337 | command = registrar.command(cmdtable) | ||
|
r30449 | |||
|
r34204 | _pipechars = b'\\/+-|' | ||
_nonpipechars = b''.join(pycompat.bytechr(i) for i in range(33, 127) | ||||
if pycompat.bytechr(i) not in _pipechars) | ||||
|
r30449 | |||
def _isname(ch): | ||||
"""char -> bool. return True if ch looks like part of a name, False | ||||
otherwise""" | ||||
return ch in _nonpipechars | ||||
def _parseasciigraph(text): | ||||
|
r34203 | r"""str -> {str : [str]}. convert the ASCII graph to edges | ||
>>> import pprint | ||||
|
r34204 | >>> pprint.pprint({pycompat.sysstr(k): [pycompat.sysstr(vv) for vv in v] | ||
|
r34203 | ... for k, v in _parseasciigraph(br''' | ||
... G | ||||
... | | ||||
... I D C F # split: B -> E, F, G | ||||
... \ \| | # replace: C -> D -> H | ||||
... H B E # prune: F, I | ||||
... \|/ | ||||
... A | ||||
... ''').items()}) | ||||
{'A': [], | ||||
'B': ['A'], | ||||
'C': ['B'], | ||||
'D': ['B'], | ||||
'E': ['A'], | ||||
'F': ['E'], | ||||
'G': ['F'], | ||||
'H': ['A'], | ||||
'I': ['H']} | ||||
|
r34204 | >>> pprint.pprint({pycompat.sysstr(k): [pycompat.sysstr(vv) for vv in v] | ||
|
r34203 | ... for k, v in _parseasciigraph(br''' | ||
... o foo | ||||
... |\ | ||||
... +---o bar | ||||
... | | | | ||||
... | o | baz | ||||
... | / | ||||
... +---o d | ||||
... | | | ||||
... +---o c | ||||
... | | | ||||
... o | b | ||||
... |/ | ||||
... o a | ||||
... ''').items()}) | ||||
{'a': [], | ||||
'b': ['a'], | ||||
'bar': ['b', 'a'], | ||||
'baz': [], | ||||
'c': ['b'], | ||||
'd': ['b'], | ||||
'foo': ['baz', 'b']} | ||||
""" | ||||
|
r30449 | lines = text.splitlines() | ||
edges = collections.defaultdict(list) # {node: []} | ||||
def get(y, x): | ||||
"""(int, int) -> char. give a coordinate, return the char. return a | ||||
space for anything out of range""" | ||||
if x < 0 or y < 0: | ||||
|
r34204 | return b' ' | ||
|
r30449 | try: | ||
|
r34204 | return lines[y][x:x + 1] or b' ' | ||
|
r30449 | except IndexError: | ||
|
r34204 | return b' ' | ||
|
r30449 | |||
def getname(y, x): | ||||
"""(int, int) -> str. like get(y, x) but concatenate left and right | ||||
parts. if name is an 'o', try to replace it to the right""" | ||||
|
r34204 | result = b'' | ||
|
r30449 | for i in itertools.count(0): | ||
ch = get(y, x - i) | ||||
if not _isname(ch): | ||||
break | ||||
result = ch + result | ||||
for i in itertools.count(1): | ||||
ch = get(y, x + i) | ||||
if not _isname(ch): | ||||
break | ||||
result += ch | ||||
|
r34204 | if result == b'o': | ||
|
r30449 | # special handling, find the name to the right | ||
|
r34204 | result = b'' | ||
|
r30449 | for i in itertools.count(2): | ||
ch = get(y, x + i) | ||||
|
r34204 | if ch == b' ' or ch in _pipechars: | ||
|
r30449 | if result or x + i >= len(lines[y]): | ||
break | ||||
else: | ||||
result += ch | ||||
|
r34204 | return result or b'o' | ||
|
r30449 | return result | ||
def parents(y, x): | ||||
"""(int, int) -> [str]. follow the ASCII edges at given position, | ||||
return a list of parents""" | ||||
|
r32291 | visited = {(y, x)} | ||
|
r30449 | visit = [] | ||
result = [] | ||||
def follow(y, x, expected): | ||||
"""conditionally append (y, x) to visit array, if it's a char | ||||
in excepted. 'o' in expected means an '_isname' test. | ||||
if '-' (or '+') is not in excepted, and get(y, x) is '-' (or '+'), | ||||
the next line (y + 1, x) will be checked instead.""" | ||||
ch = get(y, x) | ||||
|
r34204 | if any(ch == c and c not in expected for c in (b'-', b'+')): | ||
|
r30449 | y += 1 | ||
return follow(y + 1, x, expected) | ||||
|
r34204 | if ch in expected or (b'o' in expected and _isname(ch)): | ||
|
r30449 | visit.append((y, x)) | ||
# -o- # starting point: | ||||
# /|\ # follow '-' (horizontally), and '/|\' (to the bottom) | ||||
|
r34204 | follow(y + 1, x, b'|') | ||
follow(y + 1, x - 1, b'/') | ||||
follow(y + 1, x + 1, b'\\') | ||||
follow(y, x - 1, b'-') | ||||
follow(y, x + 1, b'-') | ||||
|
r30449 | |||
while visit: | ||||
y, x = visit.pop() | ||||
if (y, x) in visited: | ||||
continue | ||||
visited.add((y, x)) | ||||
ch = get(y, x) | ||||
if _isname(ch): | ||||
result.append(getname(y, x)) | ||||
continue | ||||
|
r34204 | elif ch == b'|': | ||
follow(y + 1, x, b'/|o') | ||||
follow(y + 1, x - 1, b'/') | ||||
follow(y + 1, x + 1, b'\\') | ||||
elif ch == b'+': | ||||
follow(y, x - 1, b'-') | ||||
follow(y, x + 1, b'-') | ||||
follow(y + 1, x - 1, b'/') | ||||
follow(y + 1, x + 1, b'\\') | ||||
follow(y + 1, x, b'|') | ||||
elif ch == b'\\': | ||||
follow(y + 1, x + 1, b'\\|o') | ||||
elif ch == b'/': | ||||
follow(y + 1, x - 1, b'/|o') | ||||
elif ch == b'-': | ||||
follow(y, x - 1, b'-+o') | ||||
follow(y, x + 1, b'-+o') | ||||
|
r30449 | return result | ||
for y, line in enumerate(lines): | ||||
|
r34204 | for x, ch in enumerate(pycompat.bytestr(line)): | ||
if ch == b'#': # comment | ||||
|
r30449 | break | ||
if _isname(ch): | ||||
edges[getname(y, x)] += parents(y, x) | ||||
return dict(edges) | ||||
class simplefilectx(object): | ||||
def __init__(self, path, data): | ||||
self._data = data | ||||
self._path = path | ||||
def data(self): | ||||
return self._data | ||||
|
r32305 | def filenode(self): | ||
return None | ||||
|
r30449 | def path(self): | ||
return self._path | ||||
def renamed(self): | ||||
return None | ||||
def flags(self): | ||||
|
r34204 | return b'' | ||
|
r30449 | |||
class simplecommitctx(context.committablectx): | ||||
|
r33558 | def __init__(self, repo, name, parentctxs, added): | ||
|
r30449 | opts = { | ||
|
r33558 | 'changes': scmutil.status([], list(added), [], [], [], [], []), | ||
|
r34204 | 'date': b'0 0', | ||
'extra': {b'branch': b'default'}, | ||||
|
r30449 | } | ||
super(simplecommitctx, self).__init__(self, name, **opts) | ||||
self._repo = repo | ||||
|
r33558 | self._added = added | ||
|
r30449 | self._parents = parentctxs | ||
while len(self._parents) < 2: | ||||
self._parents.append(repo[node.nullid]) | ||||
def filectx(self, key): | ||||
|
r33558 | return simplefilectx(key, self._added[key]) | ||
|
r30449 | |||
def commit(self): | ||||
return self._repo.commitctx(self) | ||||
def _walkgraph(edges): | ||||
"""yield node, parents in topologically order""" | ||||
visible = set(edges.keys()) | ||||
remaining = {} # {str: [str]} | ||||
|
r34204 | for k, vs in edges.items(): | ||
|
r30449 | for v in vs: | ||
if v not in remaining: | ||||
remaining[v] = [] | ||||
remaining[k] = vs[:] | ||||
while remaining: | ||||
leafs = [k for k, v in remaining.items() if not v] | ||||
if not leafs: | ||||
raise error.Abort(_('the graph has cycles')) | ||||
for leaf in sorted(leafs): | ||||
if leaf in visible: | ||||
yield leaf, edges[leaf] | ||||
del remaining[leaf] | ||||
|
r34204 | for k, v in remaining.items(): | ||
|
r30449 | if leaf in v: | ||
v.remove(leaf) | ||||
|
r33785 | def _getcomments(text): | ||
|
r34203 | """ | ||
|
r34204 | >>> [pycompat.sysstr(s) for s in _getcomments(br''' | ||
|
r34203 | ... G | ||
... | | ||||
... I D C F # split: B -> E, F, G | ||||
... \ \| | # replace: C -> D -> H | ||||
... H B E # prune: F, I | ||||
... \|/ | ||||
... A | ||||
... ''')] | ||||
['split: B -> E, F, G', 'replace: C -> D -> H', 'prune: F, I'] | ||||
""" | ||||
|
r33785 | for line in text.splitlines(): | ||
|
r34204 | if b' # ' not in line: | ||
|
r33785 | continue | ||
|
r34204 | yield line.split(b' # ', 1)[1].split(b' # ')[0].strip() | ||
|
r33785 | |||
|
r34204 | @command(b'debugdrawdag', []) | ||
|
r30449 | def debugdrawdag(ui, repo, **opts): | ||
"""read an ASCII graph from stdin and create changesets | ||||
The ASCII graph is like what :hg:`log -G` outputs, with each `o` replaced | ||||
to the name of the node. The command will create dummy changesets and local | ||||
tags with those names to make the dummy changesets easier to be referred | ||||
to. | ||||
If the name of a node is a single character 'o', It will be replaced by the | ||||
word to the right. This makes it easier to reuse | ||||
:hg:`log -G -T '{desc}'` outputs. | ||||
For root (no parents) nodes, revset can be used to query existing repo. | ||||
Note that the revset cannot have confusing characters which can be seen as | ||||
the part of the graph edges, like `|/+-\`. | ||||
""" | ||||
text = ui.fin.read() | ||||
# parse the graph and make sure len(parents) <= 2 for each node | ||||
edges = _parseasciigraph(text) | ||||
|
r34204 | for k, v in edges.items(): | ||
|
r30449 | if len(v) > 2: | ||
raise error.Abort(_('%s: too many parents: %s') | ||||
|
r34204 | % (k, b' '.join(v))) | ||
|
r30449 | |||
|
r33785 | # parse comments to get extra file content instructions | ||
files = collections.defaultdict(dict) # {(name, path): content} | ||||
comments = list(_getcomments(text)) | ||||
|
r34204 | filere = re.compile(br'^(\w+)/([\w/]+)\s*=\s*(.*)$', re.M) | ||
for name, path, content in filere.findall(b'\n'.join(comments)): | ||||
|
r36763 | content = content.replace(br'\n', b'\n').replace(br'\1', b'\1') | ||
files[name][path] = content | ||||
|
r33785 | |||
|
r30449 | committed = {None: node.nullid} # {name: node} | ||
# for leaf nodes, try to find existing nodes in repo | ||||
|
r34204 | for name, parents in edges.items(): | ||
|
r30449 | if len(parents) == 0: | ||
try: | ||||
committed[name] = scmutil.revsingle(repo, name) | ||||
except error.RepoLookupError: | ||||
pass | ||||
# commit in topological order | ||||
for name, parents in _walkgraph(edges): | ||||
if name in committed: | ||||
continue | ||||
pctxs = [repo[committed[n]] for n in parents] | ||||
|
r33558 | pctxs.sort(key=lambda c: c.node()) | ||
added = {} | ||||
if len(parents) > 1: | ||||
# If it's a merge, take the files and contents from the parents | ||||
for f in pctxs[1].manifest(): | ||||
if f not in pctxs[0].manifest(): | ||||
added[f] = pctxs[1][f].data() | ||||
else: | ||||
# If it's not a merge, add a single file | ||||
added[name] = name | ||||
|
r33785 | # add extra file contents in comments | ||
for path, content in files.get(name, {}).items(): | ||||
added[path] = content | ||||
|
r33558 | ctx = simplecommitctx(repo, name, pctxs, added) | ||
|
r30449 | n = ctx.commit() | ||
committed[name] = n | ||||
|
r34202 | tagsmod.tag(repo, [name], n, message=None, user=None, date=None, | ||
|
r31671 | local=True) | ||
|
r33154 | |||
# handle special comments | ||||
|
r34204 | with repo.wlock(), repo.lock(), repo.transaction(b'drawdag'): | ||
|
r33154 | getctx = lambda x: repo.unfiltered()[committed[x.strip()]] | ||
|
r33785 | for comment in comments: | ||
|
r33154 | rels = [] # obsolete relationships | ||
|
r34204 | args = comment.split(b':', 1) | ||
|
r33154 | if len(args) <= 1: | ||
continue | ||||
cmd = args[0].strip() | ||||
arg = args[1].strip() | ||||
|
r34204 | if cmd in (b'replace', b'rebase', b'amend'): | ||
nodes = [getctx(m) for m in arg.split(b'->')] | ||||
|
r33154 | for i in range(len(nodes) - 1): | ||
rels.append((nodes[i], (nodes[i + 1],))) | ||||
|
r34204 | elif cmd in (b'split',): | ||
pre, succs = arg.split(b'->') | ||||
succs = succs.split(b',') | ||||
|
r33154 | rels.append((getctx(pre), (getctx(s) for s in succs))) | ||
|
r34204 | elif cmd in (b'prune',): | ||
for n in arg.split(b','): | ||||
|
r33154 | rels.append((getctx(n), ())) | ||
if rels: | ||||
obsolete.createmarkers(repo, rels, date=(0, 0), operation=cmd) | ||||