comparison tests/drawdag.py @ 30449:a31634336471

drawdag: update test repos by drawing the changelog DAG in ASCII Currently, we have "debugbuilddag" which is a powerful tool to build test cases but not intuitive. We may end up running "hg log" in the test to make the test more readable. This patch adds a "drawdag" extension with a "debugdrawdag" command for similar testing purpose. Unlike the cryptic "debugbuilddag" command, it reads an ASCII graph that is intuitive to human, so the test case can be more readable. Unlike "debugbuilddag", "drawdag" does not require an empty repo. So it can be used to add new changesets to an existing repo. Since the "drawdag" logic is not that trivial and only makes sense for testing purpose, the extension is added to the "tests" directory, to make the core logic clean. If we find it useful (for example, to demonstrate cases and help user understand some cases) and want to ship it by default in the future, we can move it to a ship-by-default "debugdrawdag" at that time.
author Jun Wu <quark@fb.com>
date Wed, 09 Nov 2016 16:01:34 +0000
parents
children d761ef24d6e1
comparison
equal deleted inserted replaced
30448:8836f13e3c5b 30449:a31634336471
1 # drawdag.py - convert ASCII revision DAG to actual changesets
2 #
3 # Copyright 2016 Facebook, Inc.
4 #
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2 or any later version.
7 """
8 create changesets from an ASCII graph for testing purpose.
9
10 For example, given the following input::
11
12 c d
13 |/
14 b
15 |
16 a
17
18 4 changesets and 4 local tags will be created.
19 `hg log -G -T "{rev} {desc} (tag: {tags})"` will output::
20
21 o 3 d (tag: d tip)
22 |
23 | o 2 c (tag: c)
24 |/
25 o 1 b (tag: b)
26 |
27 o 0 a (tag: a)
28
29 For root nodes (nodes without parents) in the graph, they can be revsets
30 pointing to existing nodes. The ASCII graph could also have disconnected
31 components with same names referring to the same changeset.
32
33 Therefore, given the repo having the 4 changesets (and tags) above, with the
34 following ASCII graph as input::
35
36 foo bar bar foo
37 | / | |
38 ancestor(c,d) a baz
39
40 The result (`hg log -G -T "{desc}"`) will look like::
41
42 o foo
43 |\
44 +---o bar
45 | | |
46 | o | baz
47 | /
48 +---o d
49 | |
50 +---o c
51 | |
52 o | b
53 |/
54 o a
55
56 Note that if you take the above `hg log` output directly as input. It will work
57 as expected - the result would be an isomorphic graph::
58
59 o foo
60 |\
61 | | o d
62 | |/
63 | | o c
64 | |/
65 | | o bar
66 | |/|
67 | o | b
68 | |/
69 o / baz
70 /
71 o a
72
73 This is because 'o' is specially handled in the input: instead of using 'o' as
74 the node name, the word to the right will be used.
75 """
76 from __future__ import absolute_import, print_function
77
78 import collections
79 import itertools
80
81 from mercurial.i18n import _
82 from mercurial import (
83 cmdutil,
84 context,
85 error,
86 node,
87 scmutil,
88 )
89
90 cmdtable = {}
91 command = cmdutil.command(cmdtable)
92
93 _pipechars = '\\/+-|'
94 _nonpipechars = ''.join(chr(i) for i in xrange(33, 127)
95 if chr(i) not in _pipechars)
96
97 def _isname(ch):
98 """char -> bool. return True if ch looks like part of a name, False
99 otherwise"""
100 return ch in _nonpipechars
101
102 def _parseasciigraph(text):
103 """str -> {str : [str]}. convert the ASCII graph to edges"""
104 lines = text.splitlines()
105 edges = collections.defaultdict(list) # {node: []}
106
107 def get(y, x):
108 """(int, int) -> char. give a coordinate, return the char. return a
109 space for anything out of range"""
110 if x < 0 or y < 0:
111 return ' '
112 try:
113 return lines[y][x]
114 except IndexError:
115 return ' '
116
117 def getname(y, x):
118 """(int, int) -> str. like get(y, x) but concatenate left and right
119 parts. if name is an 'o', try to replace it to the right"""
120 result = ''
121 for i in itertools.count(0):
122 ch = get(y, x - i)
123 if not _isname(ch):
124 break
125 result = ch + result
126 for i in itertools.count(1):
127 ch = get(y, x + i)
128 if not _isname(ch):
129 break
130 result += ch
131 if result == 'o':
132 # special handling, find the name to the right
133 result = ''
134 for i in itertools.count(2):
135 ch = get(y, x + i)
136 if ch == ' ' or ch in _pipechars:
137 if result or x + i >= len(lines[y]):
138 break
139 else:
140 result += ch
141 return result or 'o'
142 return result
143
144 def parents(y, x):
145 """(int, int) -> [str]. follow the ASCII edges at given position,
146 return a list of parents"""
147 visited = set([(y, x)])
148 visit = []
149 result = []
150
151 def follow(y, x, expected):
152 """conditionally append (y, x) to visit array, if it's a char
153 in excepted. 'o' in expected means an '_isname' test.
154 if '-' (or '+') is not in excepted, and get(y, x) is '-' (or '+'),
155 the next line (y + 1, x) will be checked instead."""
156 ch = get(y, x)
157 if any(ch == c and c not in expected for c in '-+'):
158 y += 1
159 return follow(y + 1, x, expected)
160 if ch in expected or ('o' in expected and _isname(ch)):
161 visit.append((y, x))
162
163 # -o- # starting point:
164 # /|\ # follow '-' (horizontally), and '/|\' (to the bottom)
165 follow(y + 1, x, '|')
166 follow(y + 1, x - 1, '/')
167 follow(y + 1, x + 1, '\\')
168 follow(y, x - 1, '-')
169 follow(y, x + 1, '-')
170
171 while visit:
172 y, x = visit.pop()
173 if (y, x) in visited:
174 continue
175 visited.add((y, x))
176 ch = get(y, x)
177 if _isname(ch):
178 result.append(getname(y, x))
179 continue
180 elif ch == '|':
181 follow(y + 1, x, '/|o')
182 follow(y + 1, x - 1, '/')
183 follow(y + 1, x + 1, '\\')
184 elif ch == '+':
185 follow(y, x - 1, '-')
186 follow(y, x + 1, '-')
187 follow(y + 1, x - 1, '/')
188 follow(y + 1, x + 1, '\\')
189 follow(y + 1, x, '|')
190 elif ch == '\\':
191 follow(y + 1, x + 1, '\\|o')
192 elif ch == '/':
193 follow(y + 1, x - 1, '/|o')
194 elif ch == '-':
195 follow(y, x - 1, '-+o')
196 follow(y, x + 1, '-+o')
197 return result
198
199 for y, line in enumerate(lines):
200 for x, ch in enumerate(line):
201 if ch == '#': # comment
202 break
203 if _isname(ch):
204 edges[getname(y, x)] += parents(y, x)
205
206 return dict(edges)
207
208 class simplefilectx(object):
209 def __init__(self, path, data):
210 self._data = data
211 self._path = path
212
213 def data(self):
214 return self._data
215
216 def path(self):
217 return self._path
218
219 def renamed(self):
220 return None
221
222 def flags(self):
223 return ''
224
225 class simplecommitctx(context.committablectx):
226 def __init__(self, repo, name, parentctxs, added=None):
227 opts = {
228 'changes': scmutil.status([], added or [], [], [], [], [], []),
229 'date': '0 0',
230 'extra': {'branch': 'default'},
231 }
232 super(simplecommitctx, self).__init__(self, name, **opts)
233 self._repo = repo
234 self._name = name
235 self._parents = parentctxs
236 self._parents.sort(key=lambda c: c.node())
237 while len(self._parents) < 2:
238 self._parents.append(repo[node.nullid])
239
240 def filectx(self, key):
241 return simplefilectx(key, self._name)
242
243 def commit(self):
244 return self._repo.commitctx(self)
245
246 def _walkgraph(edges):
247 """yield node, parents in topologically order"""
248 visible = set(edges.keys())
249 remaining = {} # {str: [str]}
250 for k, vs in edges.iteritems():
251 for v in vs:
252 if v not in remaining:
253 remaining[v] = []
254 remaining[k] = vs[:]
255 while remaining:
256 leafs = [k for k, v in remaining.items() if not v]
257 if not leafs:
258 raise error.Abort(_('the graph has cycles'))
259 for leaf in sorted(leafs):
260 if leaf in visible:
261 yield leaf, edges[leaf]
262 del remaining[leaf]
263 for k, v in remaining.iteritems():
264 if leaf in v:
265 v.remove(leaf)
266
267 @command('debugdrawdag', [])
268 def debugdrawdag(ui, repo, **opts):
269 """read an ASCII graph from stdin and create changesets
270
271 The ASCII graph is like what :hg:`log -G` outputs, with each `o` replaced
272 to the name of the node. The command will create dummy changesets and local
273 tags with those names to make the dummy changesets easier to be referred
274 to.
275
276 If the name of a node is a single character 'o', It will be replaced by the
277 word to the right. This makes it easier to reuse
278 :hg:`log -G -T '{desc}'` outputs.
279
280 For root (no parents) nodes, revset can be used to query existing repo.
281 Note that the revset cannot have confusing characters which can be seen as
282 the part of the graph edges, like `|/+-\`.
283 """
284 text = ui.fin.read()
285
286 # parse the graph and make sure len(parents) <= 2 for each node
287 edges = _parseasciigraph(text)
288 for k, v in edges.iteritems():
289 if len(v) > 2:
290 raise error.Abort(_('%s: too many parents: %s')
291 % (k, ' '.join(v)))
292
293 committed = {None: node.nullid} # {name: node}
294
295 # for leaf nodes, try to find existing nodes in repo
296 for name, parents in edges.iteritems():
297 if len(parents) == 0:
298 try:
299 committed[name] = scmutil.revsingle(repo, name)
300 except error.RepoLookupError:
301 pass
302
303 # commit in topological order
304 for name, parents in _walkgraph(edges):
305 if name in committed:
306 continue
307 pctxs = [repo[committed[n]] for n in parents]
308 ctx = simplecommitctx(repo, name, pctxs, [name])
309 n = ctx.commit()
310 committed[name] = n
311 repo.tag(name, n, message=None, user=None, date=None, local=True)