Mercurial > hg
comparison hgext/histedit.py @ 17064:168cc52ad7c2
histedit: new extension for interactive history editing
author | Augie Fackler <raf@durin42.com> |
---|---|
date | Wed, 27 Jun 2012 17:52:54 -0500 |
parents | |
children | baf8887d40e2 |
comparison
equal
deleted
inserted
replaced
17063:3fbc6e3abdbd | 17064:168cc52ad7c2 |
---|---|
1 # histedit.py - interactive history editing for mercurial | |
2 # | |
3 # Copyright 2009 Augie Fackler <raf@durin42.com> | |
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 """Interactive history editing. | |
8 | |
9 Inspired by git rebase --interactive. | |
10 """ | |
11 from inspect import getargspec | |
12 try: | |
13 import cPickle as pickle | |
14 except ImportError: | |
15 import pickle | |
16 import tempfile | |
17 import os | |
18 | |
19 from mercurial import bookmarks | |
20 from mercurial import cmdutil | |
21 from mercurial import discovery | |
22 from mercurial import error | |
23 from mercurial import hg | |
24 from mercurial import node | |
25 from mercurial import patch | |
26 from mercurial import repair | |
27 from mercurial import scmutil | |
28 from mercurial import url | |
29 from mercurial import util | |
30 from mercurial.i18n import _ | |
31 | |
32 | |
33 editcomment = """ | |
34 | |
35 # Edit history between %s and %s | |
36 # | |
37 # Commands: | |
38 # p, pick = use commit | |
39 # e, edit = use commit, but stop for amending | |
40 # f, fold = use commit, but fold into previous commit (combines N and N-1) | |
41 # d, drop = remove commit from history | |
42 # m, mess = edit message without changing commit content | |
43 # | |
44 """ | |
45 | |
46 def between(repo, old, new, keep): | |
47 revs = [old, ] | |
48 current = old | |
49 while current != new: | |
50 ctx = repo[current] | |
51 if not keep and len(ctx.children()) > 1: | |
52 raise util.Abort(_('cannot edit history that would orphan nodes')) | |
53 if len(ctx.parents()) != 1 and ctx.parents()[1] != node.nullid: | |
54 raise util.Abort(_("can't edit history with merges")) | |
55 if not ctx.children(): | |
56 current = new | |
57 else: | |
58 current = ctx.children()[0].node() | |
59 revs.append(current) | |
60 if len(repo[current].children()) and not keep: | |
61 raise util.Abort(_('cannot edit history that would orphan nodes')) | |
62 return revs | |
63 | |
64 | |
65 def pick(ui, repo, ctx, ha, opts): | |
66 oldctx = repo[ha] | |
67 if oldctx.parents()[0] == ctx: | |
68 ui.debug('node %s unchanged\n' % ha) | |
69 return oldctx, [], [], [] | |
70 hg.update(repo, ctx.node()) | |
71 fd, patchfile = tempfile.mkstemp(prefix='hg-histedit-') | |
72 fp = os.fdopen(fd, 'w') | |
73 diffopts = patch.diffopts(ui, opts) | |
74 diffopts.git = True | |
75 diffopts.ignorews = False | |
76 diffopts.ignorewsamount = False | |
77 diffopts.ignoreblanklines = False | |
78 gen = patch.diff(repo, oldctx.parents()[0].node(), ha, opts=diffopts) | |
79 for chunk in gen: | |
80 fp.write(chunk) | |
81 fp.close() | |
82 try: | |
83 files = set() | |
84 try: | |
85 patch.patch(ui, repo, patchfile, files=files, eolmode=None) | |
86 if not files: | |
87 ui.warn(_('%s: empty changeset') | |
88 % node.hex(ha)) | |
89 return ctx, [], [], [] | |
90 finally: | |
91 os.unlink(patchfile) | |
92 except Exception, inst: | |
93 raise util.Abort(_('Fix up the change and run ' | |
94 'hg histedit --continue')) | |
95 n = repo.commit(text=oldctx.description(), user=oldctx.user(), date=oldctx.date(), | |
96 extra=oldctx.extra()) | |
97 return repo[n], [n, ], [oldctx.node(), ], [] | |
98 | |
99 | |
100 def edit(ui, repo, ctx, ha, opts): | |
101 oldctx = repo[ha] | |
102 hg.update(repo, ctx.node()) | |
103 fd, patchfile = tempfile.mkstemp(prefix='hg-histedit-') | |
104 fp = os.fdopen(fd, 'w') | |
105 diffopts = patch.diffopts(ui, opts) | |
106 diffopts.git = True | |
107 diffopts.ignorews = False | |
108 diffopts.ignorewsamount = False | |
109 diffopts.ignoreblanklines = False | |
110 gen = patch.diff(repo, oldctx.parents()[0].node(), ha, opts=diffopts) | |
111 for chunk in gen: | |
112 fp.write(chunk) | |
113 fp.close() | |
114 try: | |
115 files = set() | |
116 try: | |
117 patch.patch(ui, repo, patchfile, files=files, eolmode=None) | |
118 finally: | |
119 os.unlink(patchfile) | |
120 except Exception, inst: | |
121 pass | |
122 raise util.Abort(_('Make changes as needed, you may commit or record as ' | |
123 'needed now.\nWhen you are finished, run hg' | |
124 ' histedit --continue to resume.')) | |
125 | |
126 def fold(ui, repo, ctx, ha, opts): | |
127 oldctx = repo[ha] | |
128 hg.update(repo, ctx.node()) | |
129 fd, patchfile = tempfile.mkstemp(prefix='hg-histedit-') | |
130 fp = os.fdopen(fd, 'w') | |
131 diffopts = patch.diffopts(ui, opts) | |
132 diffopts.git = True | |
133 diffopts.ignorews = False | |
134 diffopts.ignorewsamount = False | |
135 diffopts.ignoreblanklines = False | |
136 gen = patch.diff(repo, oldctx.parents()[0].node(), ha, opts=diffopts) | |
137 for chunk in gen: | |
138 fp.write(chunk) | |
139 fp.close() | |
140 try: | |
141 files = set() | |
142 try: | |
143 patch.patch(ui, repo, patchfile, files=files, eolmode=None) | |
144 if not files: | |
145 ui.warn(_('%s: empty changeset') | |
146 % node.hex(ha)) | |
147 return ctx, [], [], [] | |
148 finally: | |
149 os.unlink(patchfile) | |
150 except Exception, inst: | |
151 raise util.Abort(_('Fix up the change and run ' | |
152 'hg histedit --continue')) | |
153 n = repo.commit(text='fold-temp-revision %s' % ha, user=oldctx.user(), date=oldctx.date(), | |
154 extra=oldctx.extra()) | |
155 return finishfold(ui, repo, ctx, oldctx, n, opts, []) | |
156 | |
157 def finishfold(ui, repo, ctx, oldctx, newnode, opts, internalchanges): | |
158 parent = ctx.parents()[0].node() | |
159 hg.update(repo, parent) | |
160 fd, patchfile = tempfile.mkstemp(prefix='hg-histedit-') | |
161 fp = os.fdopen(fd, 'w') | |
162 diffopts = patch.diffopts(ui, opts) | |
163 diffopts.git = True | |
164 diffopts.ignorews = False | |
165 diffopts.ignorewsamount = False | |
166 diffopts.ignoreblanklines = False | |
167 gen = patch.diff(repo, parent, newnode, opts=diffopts) | |
168 for chunk in gen: | |
169 fp.write(chunk) | |
170 fp.close() | |
171 files = set() | |
172 try: | |
173 patch.patch(ui, repo, patchfile, files=files, eolmode=None) | |
174 finally: | |
175 os.unlink(patchfile) | |
176 newmessage = '\n***\n'.join( | |
177 [ctx.description(), ] + | |
178 [repo[r].description() for r in internalchanges] + | |
179 [oldctx.description(), ]) | |
180 # If the changesets are from the same author, keep it. | |
181 if ctx.user() == oldctx.user(): | |
182 username = ctx.user() | |
183 else: | |
184 username = ui.username() | |
185 newmessage = ui.edit(newmessage, username) | |
186 n = repo.commit(text=newmessage, user=username, date=max(ctx.date(), oldctx.date()), | |
187 extra=oldctx.extra()) | |
188 return repo[n], [n, ], [oldctx.node(), ctx.node() ], [newnode, ] | |
189 | |
190 def drop(ui, repo, ctx, ha, opts): | |
191 return ctx, [], [repo[ha].node(), ], [] | |
192 | |
193 | |
194 def message(ui, repo, ctx, ha, opts): | |
195 oldctx = repo[ha] | |
196 hg.update(repo, ctx.node()) | |
197 fd, patchfile = tempfile.mkstemp(prefix='hg-histedit-') | |
198 fp = os.fdopen(fd, 'w') | |
199 diffopts = patch.diffopts(ui, opts) | |
200 diffopts.git = True | |
201 diffopts.ignorews = False | |
202 diffopts.ignorewsamount = False | |
203 diffopts.ignoreblanklines = False | |
204 gen = patch.diff(repo, oldctx.parents()[0].node(), ha, opts=diffopts) | |
205 for chunk in gen: | |
206 fp.write(chunk) | |
207 fp.close() | |
208 try: | |
209 files = set() | |
210 try: | |
211 patch.patch(ui, repo, patchfile, files=files, eolmode=None) | |
212 finally: | |
213 os.unlink(patchfile) | |
214 except Exception, inst: | |
215 raise util.Abort(_('Fix up the change and run ' | |
216 'hg histedit --continue')) | |
217 message = oldctx.description() | |
218 message = ui.edit(message, ui.username()) | |
219 new = repo.commit(text=message, user=oldctx.user(), date=oldctx.date(), | |
220 extra=oldctx.extra()) | |
221 newctx = repo[new] | |
222 if oldctx.node() != newctx.node(): | |
223 return newctx, [new], [oldctx.node()], [] | |
224 # We didn't make an edit, so just indicate no replaced nodes | |
225 return newctx, [new], [], [] | |
226 | |
227 | |
228 def makedesc(c): | |
229 summary = '' | |
230 if c.description(): | |
231 summary = c.description().splitlines()[0] | |
232 line = 'pick %s %d %s' % (c.hex()[:12], c.rev(), summary) | |
233 return line[:80] # trim to 80 chars so it's not stupidly wide in my editor | |
234 | |
235 actiontable = {'p': pick, | |
236 'pick': pick, | |
237 'e': edit, | |
238 'edit': edit, | |
239 'f': fold, | |
240 'fold': fold, | |
241 'd': drop, | |
242 'drop': drop, | |
243 'm': message, | |
244 'mess': message, | |
245 } | |
246 def histedit(ui, repo, *parent, **opts): | |
247 """hg histedit <parent> | |
248 """ | |
249 # TODO only abort if we try and histedit mq patches, not just | |
250 # blanket if mq patches are applied somewhere | |
251 mq = getattr(repo, 'mq', None) | |
252 if mq and mq.applied: | |
253 raise util.Abort(_('source has mq patches applied')) | |
254 | |
255 parent = list(parent) + opts.get('rev', []) | |
256 if opts.get('outgoing'): | |
257 if len(parent) > 1: | |
258 raise util.Abort(_('only one repo argument allowed with --outgoing')) | |
259 elif parent: | |
260 parent = parent[0] | |
261 | |
262 dest = ui.expandpath(parent or 'default-push', parent or 'default') | |
263 dest, revs = hg.parseurl(dest, None)[:2] | |
264 ui.status(_('comparing with %s\n') % util.hidepassword(dest)) | |
265 | |
266 revs, checkout = hg.addbranchrevs(repo, repo, revs, None) | |
267 other = hg.repository(hg.remoteui(repo, opts), dest) | |
268 | |
269 if revs: | |
270 revs = [repo.lookup(rev) for rev in revs] | |
271 | |
272 parent = discovery.findcommonoutgoing( | |
273 repo, other, [], force=opts.get('force')).missing[0:1] | |
274 else: | |
275 if opts.get('force'): | |
276 raise util.Abort(_('--force only allowed with --outgoing')) | |
277 | |
278 if opts.get('continue', False): | |
279 if len(parent) != 0: | |
280 raise util.Abort(_('no arguments allowed with --continue')) | |
281 (parentctxnode, created, replaced, | |
282 tmpnodes, existing, rules, keep, tip, replacemap ) = readstate(repo) | |
283 currentparent, wantnull = repo.dirstate.parents() | |
284 parentctx = repo[parentctxnode] | |
285 # discover any nodes the user has added in the interim | |
286 newchildren = [c for c in parentctx.children() | |
287 if c.node() not in existing] | |
288 action, currentnode = rules.pop(0) | |
289 while newchildren: | |
290 if action in ['f', 'fold', ]: | |
291 tmpnodes.extend([n.node() for n in newchildren]) | |
292 else: | |
293 created.extend([n.node() for n in newchildren]) | |
294 newchildren = filter(lambda x: x.node() not in existing, | |
295 reduce(lambda x, y: x + y, | |
296 map(lambda r: r.children(), | |
297 newchildren))) | |
298 m, a, r, d = repo.status()[:4] | |
299 oldctx = repo[currentnode] | |
300 message = oldctx.description() | |
301 if action in ('e', 'edit', 'm', 'mess'): | |
302 message = ui.edit(message, ui.username()) | |
303 elif action in ('f', 'fold', ): | |
304 message = 'fold-temp-revision %s' % currentnode | |
305 new = None | |
306 if m or a or r or d: | |
307 new = repo.commit(text=message, user=oldctx.user(), date=oldctx.date(), | |
308 extra=oldctx.extra()) | |
309 | |
310 if action in ('f', 'fold'): | |
311 if new: | |
312 tmpnodes.append(new) | |
313 else: | |
314 new = newchildren[-1] | |
315 (parentctx, created_, | |
316 replaced_, tmpnodes_, ) = finishfold(ui, repo, | |
317 parentctx, oldctx, new, | |
318 opts, newchildren) | |
319 replaced.extend(replaced_) | |
320 created.extend(created_) | |
321 tmpnodes.extend(tmpnodes_) | |
322 elif action not in ('d', 'drop'): | |
323 if new != oldctx.node(): | |
324 replaced.append(oldctx.node()) | |
325 if new: | |
326 if new != oldctx.node(): | |
327 created.append(new) | |
328 parentctx = repo[new] | |
329 | |
330 elif opts.get('abort', False): | |
331 if len(parent) != 0: | |
332 raise util.Abort(_('no arguments allowed with --abort')) | |
333 (parentctxnode, created, replaced, tmpnodes, | |
334 existing, rules, keep, tip, replacemap) = readstate(repo) | |
335 ui.debug('restore wc to old tip %s\n' % node.hex(tip)) | |
336 hg.clean(repo, tip) | |
337 ui.debug('should strip created nodes %s\n' % | |
338 ', '.join([node.hex(n)[:12] for n in created])) | |
339 ui.debug('should strip temp nodes %s\n' % | |
340 ', '.join([node.hex(n)[:12] for n in tmpnodes])) | |
341 for nodes in (created, tmpnodes, ): | |
342 for n in reversed(nodes): | |
343 try: | |
344 repair.strip(ui, repo, n) | |
345 except error.LookupError: | |
346 pass | |
347 os.unlink(os.path.join(repo.path, 'histedit-state')) | |
348 return | |
349 else: | |
350 cmdutil.bailifchanged(repo) | |
351 if os.path.exists(os.path.join(repo.path, 'histedit-state')): | |
352 raise util.Abort(_('history edit already in progress, try ' | |
353 '--continue or --abort')) | |
354 | |
355 tip, empty = repo.dirstate.parents() | |
356 | |
357 | |
358 if len(parent) != 1: | |
359 raise util.Abort(_('histedit requires exactly one parent revision')) | |
360 parent = scmutil.revsingle(repo, parent[0]).node() | |
361 | |
362 keep = opts.get('keep', False) | |
363 revs = between(repo, parent, tip, keep) | |
364 | |
365 ctxs = [repo[r] for r in revs] | |
366 existing = [r.node() for r in ctxs] | |
367 rules = opts.get('commands', '') | |
368 if not rules: | |
369 rules = '\n'.join([makedesc(c) for c in ctxs]) | |
370 rules += editcomment % (node.hex(parent)[:12], node.hex(tip)[:12], ) | |
371 rules = ui.edit(rules, ui.username()) | |
372 # Save edit rules in .hg/histedit-last-edit.txt in case | |
373 # the user needs to ask for help after something | |
374 # surprising happens. | |
375 f = open(repo.join('histedit-last-edit.txt'), 'w') | |
376 f.write(rules) | |
377 f.close() | |
378 else: | |
379 f = open(rules) | |
380 rules = f.read() | |
381 f.close() | |
382 rules = [l for l in (r.strip() for r in rules.splitlines()) | |
383 if l and not l[0] == '#'] | |
384 rules = verifyrules(rules, repo, ctxs) | |
385 | |
386 parentctx = repo[parent].parents()[0] | |
387 keep = opts.get('keep', False) | |
388 replaced = [] | |
389 replacemap = {} | |
390 tmpnodes = [] | |
391 created = [] | |
392 | |
393 | |
394 while rules: | |
395 writestate(repo, parentctx.node(), created, replaced, tmpnodes, existing, | |
396 rules, keep, tip, replacemap) | |
397 action, ha = rules.pop(0) | |
398 (parentctx, created_, | |
399 replaced_, tmpnodes_, ) = actiontable[action](ui, repo, | |
400 parentctx, ha, | |
401 opts) | |
402 | |
403 hexshort = lambda x: node.hex(x)[:12] | |
404 | |
405 if replaced_: | |
406 clen, rlen = len(created_), len(replaced_) | |
407 if clen == rlen == 1: | |
408 ui.debug('histedit: exact replacement of %s with %s\n' % ( | |
409 hexshort(replaced_[0]), hexshort(created_[0]))) | |
410 | |
411 replacemap[replaced_[0]] = created_[0] | |
412 elif clen > rlen: | |
413 assert rlen == 1, ('unexpected replacement of ' | |
414 '%d changes with %d changes' % (rlen, clen)) | |
415 # made more changesets than we're replacing | |
416 # TODO synthesize patch names for created patches | |
417 replacemap[replaced_[0]] = created_[-1] | |
418 ui.debug('histedit: created many, assuming %s replaced by %s' % ( | |
419 hexshort(replaced_[0]), hexshort(created_[-1]))) | |
420 elif rlen > clen: | |
421 if not created_: | |
422 # This must be a drop. Try and put our metadata on | |
423 # the parent change. | |
424 assert rlen == 1 | |
425 r = replaced_[0] | |
426 ui.debug('histedit: %s seems replaced with nothing, ' | |
427 'finding a parent\n' % (hexshort(r))) | |
428 pctx = repo[r].parents()[0] | |
429 if pctx.node() in replacemap: | |
430 ui.debug('histedit: parent is already replaced\n') | |
431 replacemap[r] = replacemap[pctx.node()] | |
432 else: | |
433 replacemap[r] = pctx.node() | |
434 ui.debug('histedit: %s best replaced by %s\n' % ( | |
435 hexshort(r), hexshort(replacemap[r]))) | |
436 else: | |
437 assert len(created_) == 1 | |
438 for r in replaced_: | |
439 ui.debug('histedit: %s replaced by %s\n' % ( | |
440 hexshort(r), hexshort(created_[0]))) | |
441 replacemap[r] = created_[0] | |
442 else: | |
443 assert False, ( | |
444 'Unhandled case in replacement mapping! ' | |
445 'replacing %d changes with %d changes' % (rlen, clen)) | |
446 created.extend(created_) | |
447 replaced.extend(replaced_) | |
448 tmpnodes.extend(tmpnodes_) | |
449 | |
450 hg.update(repo, parentctx.node()) | |
451 | |
452 if not keep: | |
453 if replacemap: | |
454 ui.note('histedit: Should update metadata for the following ' | |
455 'changes:\n') | |
456 | |
457 def copybms(old, new): | |
458 if old in tmpnodes or old in created: | |
459 # can't have any metadata we'd want to update | |
460 return | |
461 while new in replacemap: | |
462 new = replacemap[new] | |
463 ui.note('histedit: %s to %s\n' % (hexshort(old), hexshort(new))) | |
464 octx = repo[old] | |
465 marks = octx.bookmarks() | |
466 if marks: | |
467 ui.note('histedit: moving bookmarks %s\n' % | |
468 ', '.join(marks)) | |
469 for mark in marks: | |
470 repo._bookmarks[mark] = new | |
471 bookmarks.write(repo) | |
472 | |
473 # We assume that bookmarks on the tip should remain | |
474 # tipmost, but bookmarks on non-tip changesets should go | |
475 # to their most reasonable successor. As a result, find | |
476 # the old tip and new tip and copy those bookmarks first, | |
477 # then do the rest of the bookmark copies. | |
478 oldtip = sorted(replacemap.keys(), key=repo.changelog.rev)[-1] | |
479 newtip = sorted(replacemap.values(), key=repo.changelog.rev)[-1] | |
480 copybms(oldtip, newtip) | |
481 | |
482 for old, new in replacemap.iteritems(): | |
483 copybms(old, new) | |
484 # TODO update mq state | |
485 | |
486 ui.debug('should strip replaced nodes %s\n' % | |
487 ', '.join([node.hex(n)[:12] for n in replaced])) | |
488 for n in sorted(replaced, key=lambda x: repo[x].rev()): | |
489 try: | |
490 repair.strip(ui, repo, n) | |
491 except error.LookupError: | |
492 pass | |
493 | |
494 ui.debug('should strip temp nodes %s\n' % | |
495 ', '.join([node.hex(n)[:12] for n in tmpnodes])) | |
496 for n in reversed(tmpnodes): | |
497 try: | |
498 repair.strip(ui, repo, n) | |
499 except error.LookupError: | |
500 pass | |
501 os.unlink(os.path.join(repo.path, 'histedit-state')) | |
502 if os.path.exists(repo.sjoin('undo')): | |
503 os.unlink(repo.sjoin('undo')) | |
504 | |
505 | |
506 def writestate(repo, parentctxnode, created, replaced, | |
507 tmpnodes, existing, rules, keep, oldtip, replacemap): | |
508 fp = open(os.path.join(repo.path, 'histedit-state'), 'w') | |
509 pickle.dump((parentctxnode, created, replaced, | |
510 tmpnodes, existing, rules, keep, oldtip, replacemap), | |
511 fp) | |
512 fp.close() | |
513 | |
514 def readstate(repo): | |
515 """Returns a tuple of (parentnode, created, replaced, tmp, existing, rules, | |
516 keep, oldtip, replacemap ). | |
517 """ | |
518 fp = open(os.path.join(repo.path, 'histedit-state')) | |
519 return pickle.load(fp) | |
520 | |
521 | |
522 def verifyrules(rules, repo, ctxs): | |
523 """Verify that there exists exactly one edit rule per given changeset. | |
524 | |
525 Will abort if there are to many or too few rules, a malformed rule, | |
526 or a rule on a changeset outside of the user-given range. | |
527 """ | |
528 parsed = [] | |
529 first = True | |
530 if len(rules) != len(ctxs): | |
531 raise util.Abort(_('must specify a rule for each changeset once')) | |
532 for r in rules: | |
533 if ' ' not in r: | |
534 raise util.Abort(_('malformed line "%s"') % r) | |
535 action, rest = r.split(' ', 1) | |
536 if ' ' in rest.strip(): | |
537 ha, rest = rest.split(' ', 1) | |
538 else: | |
539 ha = r.strip() | |
540 try: | |
541 if repo[ha] not in ctxs: | |
542 raise util.Abort(_('may not use changesets other than the ones listed')) | |
543 except error.RepoError: | |
544 raise util.Abort(_('unknown changeset %s listed') % ha) | |
545 if action not in actiontable: | |
546 raise util.Abort(_('unknown action "%s"') % action) | |
547 parsed.append([action, ha]) | |
548 return parsed | |
549 | |
550 | |
551 cmdtable = { | |
552 "histedit": | |
553 (histedit, | |
554 [('', 'commands', '', _('Read history edits from the specified file.')), | |
555 ('c', 'continue', False, _('continue an edit already in progress')), | |
556 ('k', 'keep', False, _("don't strip old nodes after edit is complete")), | |
557 ('', 'abort', False, _('abort an edit in progress')), | |
558 ('o', 'outgoing', False, _('changesets not found in destination')), | |
559 ('f', 'force', False, _('force outgoing even for unrelated repositories')), | |
560 ('r', 'rev', [], _('first revision to be edited')), | |
561 ], | |
562 __doc__, | |
563 ), | |
564 } |