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