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 }