Mercurial > hg
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) |