##// END OF EJS Templates
histedit: add histedit state class...
David Soria Parra -
r22976:88671172 default
parent child Browse files
Show More
@@ -1,945 +1,964 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 With this extension installed, Mercurial gains one new command: histedit. Usage
10 10 is as follows, assuming the following history::
11 11
12 12 @ 3[tip] 7c2fd3b9020c 2009-04-27 18:04 -0500 durin42
13 13 | Add delta
14 14 |
15 15 o 2 030b686bedc4 2009-04-27 18:04 -0500 durin42
16 16 | Add gamma
17 17 |
18 18 o 1 c561b4e977df 2009-04-27 18:04 -0500 durin42
19 19 | Add beta
20 20 |
21 21 o 0 d8d2fcd0e319 2009-04-27 18:04 -0500 durin42
22 22 Add alpha
23 23
24 24 If you were to run ``hg histedit c561b4e977df``, you would see the following
25 25 file open in your editor::
26 26
27 27 pick c561b4e977df Add beta
28 28 pick 030b686bedc4 Add gamma
29 29 pick 7c2fd3b9020c Add delta
30 30
31 31 # Edit history between c561b4e977df and 7c2fd3b9020c
32 32 #
33 33 # Commits are listed from least to most recent
34 34 #
35 35 # Commands:
36 36 # p, pick = use commit
37 37 # e, edit = use commit, but stop for amending
38 38 # f, fold = use commit, but combine it with the one above
39 39 # r, roll = like fold, but discard this commit's description
40 40 # d, drop = remove commit from history
41 41 # m, mess = edit message without changing commit content
42 42 #
43 43
44 44 In this file, lines beginning with ``#`` are ignored. You must specify a rule
45 45 for each revision in your history. For example, if you had meant to add gamma
46 46 before beta, and then wanted to add delta in the same revision as beta, you
47 47 would reorganize the file to look like this::
48 48
49 49 pick 030b686bedc4 Add gamma
50 50 pick c561b4e977df Add beta
51 51 fold 7c2fd3b9020c Add delta
52 52
53 53 # Edit history between c561b4e977df and 7c2fd3b9020c
54 54 #
55 55 # Commits are listed from least to most recent
56 56 #
57 57 # Commands:
58 58 # p, pick = use commit
59 59 # e, edit = use commit, but stop for amending
60 60 # f, fold = use commit, but combine it with the one above
61 61 # r, roll = like fold, but discard this commit's description
62 62 # d, drop = remove commit from history
63 63 # m, mess = edit message without changing commit content
64 64 #
65 65
66 66 At which point you close the editor and ``histedit`` starts working. When you
67 67 specify a ``fold`` operation, ``histedit`` will open an editor when it folds
68 68 those revisions together, offering you a chance to clean up the commit message::
69 69
70 70 Add beta
71 71 ***
72 72 Add delta
73 73
74 74 Edit the commit message to your liking, then close the editor. For
75 75 this example, let's assume that the commit message was changed to
76 76 ``Add beta and delta.`` After histedit has run and had a chance to
77 77 remove any old or temporary revisions it needed, the history looks
78 78 like this::
79 79
80 80 @ 2[tip] 989b4d060121 2009-04-27 18:04 -0500 durin42
81 81 | Add beta and delta.
82 82 |
83 83 o 1 081603921c3f 2009-04-27 18:04 -0500 durin42
84 84 | Add gamma
85 85 |
86 86 o 0 d8d2fcd0e319 2009-04-27 18:04 -0500 durin42
87 87 Add alpha
88 88
89 89 Note that ``histedit`` does *not* remove any revisions (even its own temporary
90 90 ones) until after it has completed all the editing operations, so it will
91 91 probably perform several strip operations when it's done. For the above example,
92 92 it had to run strip twice. Strip can be slow depending on a variety of factors,
93 93 so you might need to be a little patient. You can choose to keep the original
94 94 revisions by passing the ``--keep`` flag.
95 95
96 96 The ``edit`` operation will drop you back to a command prompt,
97 97 allowing you to edit files freely, or even use ``hg record`` to commit
98 98 some changes as a separate commit. When you're done, any remaining
99 99 uncommitted changes will be committed as well. When done, run ``hg
100 100 histedit --continue`` to finish this step. You'll be prompted for a
101 101 new commit message, but the default commit message will be the
102 102 original message for the ``edit`` ed revision.
103 103
104 104 The ``message`` operation will give you a chance to revise a commit
105 105 message without changing the contents. It's a shortcut for doing
106 106 ``edit`` immediately followed by `hg histedit --continue``.
107 107
108 108 If ``histedit`` encounters a conflict when moving a revision (while
109 109 handling ``pick`` or ``fold``), it'll stop in a similar manner to
110 110 ``edit`` with the difference that it won't prompt you for a commit
111 111 message when done. If you decide at this point that you don't like how
112 112 much work it will be to rearrange history, or that you made a mistake,
113 113 you can use ``hg histedit --abort`` to abandon the new changes you
114 114 have made and return to the state before you attempted to edit your
115 115 history.
116 116
117 117 If we clone the histedit-ed example repository above and add four more
118 118 changes, such that we have the following history::
119 119
120 120 @ 6[tip] 038383181893 2009-04-27 18:04 -0500 stefan
121 121 | Add theta
122 122 |
123 123 o 5 140988835471 2009-04-27 18:04 -0500 stefan
124 124 | Add eta
125 125 |
126 126 o 4 122930637314 2009-04-27 18:04 -0500 stefan
127 127 | Add zeta
128 128 |
129 129 o 3 836302820282 2009-04-27 18:04 -0500 stefan
130 130 | Add epsilon
131 131 |
132 132 o 2 989b4d060121 2009-04-27 18:04 -0500 durin42
133 133 | Add beta and delta.
134 134 |
135 135 o 1 081603921c3f 2009-04-27 18:04 -0500 durin42
136 136 | Add gamma
137 137 |
138 138 o 0 d8d2fcd0e319 2009-04-27 18:04 -0500 durin42
139 139 Add alpha
140 140
141 141 If you run ``hg histedit --outgoing`` on the clone then it is the same
142 142 as running ``hg histedit 836302820282``. If you need plan to push to a
143 143 repository that Mercurial does not detect to be related to the source
144 144 repo, you can add a ``--force`` option.
145 145 """
146 146
147 147 try:
148 148 import cPickle as pickle
149 149 pickle.dump # import now
150 150 except ImportError:
151 151 import pickle
152 152 import errno
153 153 import os
154 154 import sys
155 155
156 156 from mercurial import cmdutil
157 157 from mercurial import discovery
158 158 from mercurial import error
159 159 from mercurial import copies
160 160 from mercurial import context
161 161 from mercurial import hg
162 162 from mercurial import node
163 163 from mercurial import repair
164 164 from mercurial import scmutil
165 165 from mercurial import util
166 166 from mercurial import obsolete
167 167 from mercurial import merge as mergemod
168 168 from mercurial.lock import release
169 169 from mercurial.i18n import _
170 170
171 171 cmdtable = {}
172 172 command = cmdutil.command(cmdtable)
173 173
174 174 testedwith = 'internal'
175 175
176 176 # i18n: command names and abbreviations must remain untranslated
177 177 editcomment = _("""# Edit history between %s and %s
178 178 #
179 179 # Commits are listed from least to most recent
180 180 #
181 181 # Commands:
182 182 # p, pick = use commit
183 183 # e, edit = use commit, but stop for amending
184 184 # f, fold = use commit, but combine it with the one above
185 185 # r, roll = like fold, but discard this commit's description
186 186 # d, drop = remove commit from history
187 187 # m, mess = edit message without changing commit content
188 188 #
189 189 """)
190 190
191 class histeditstate(object):
192 def __init__(self, repo, parentctxnode=None, rules=None, keep=None,
193 topmost=None, replacements=None):
194 self.repo = repo
195 self.parentctxnode = parentctxnode
196 self.rules = rules
197 self.keep = keep
198 self.topmost = topmost
199 if replacements is None:
200 self.replacements = []
201 else:
202 self.replacements = replacements
203
204 def write(self):
205 fp = self.repo.vfs('histedit-state', 'w')
206 pickle.dump((self.parentctxnode, self.rules, self.keep,
207 self.topmost, self.replacements), fp)
208 fp.close()
209
191 210 def commitfuncfor(repo, src):
192 211 """Build a commit function for the replacement of <src>
193 212
194 213 This function ensure we apply the same treatment to all changesets.
195 214
196 215 - Add a 'histedit_source' entry in extra.
197 216
198 217 Note that fold have its own separated logic because its handling is a bit
199 218 different and not easily factored out of the fold method.
200 219 """
201 220 phasemin = src.phase()
202 221 def commitfunc(**kwargs):
203 222 phasebackup = repo.ui.backupconfig('phases', 'new-commit')
204 223 try:
205 224 repo.ui.setconfig('phases', 'new-commit', phasemin,
206 225 'histedit')
207 226 extra = kwargs.get('extra', {}).copy()
208 227 extra['histedit_source'] = src.hex()
209 228 kwargs['extra'] = extra
210 229 return repo.commit(**kwargs)
211 230 finally:
212 231 repo.ui.restoreconfig(phasebackup)
213 232 return commitfunc
214 233
215 234 def applychanges(ui, repo, ctx, opts):
216 235 """Merge changeset from ctx (only) in the current working directory"""
217 236 wcpar = repo.dirstate.parents()[0]
218 237 if ctx.p1().node() == wcpar:
219 238 # edition ar "in place" we do not need to make any merge,
220 239 # just applies changes on parent for edition
221 240 cmdutil.revert(ui, repo, ctx, (wcpar, node.nullid), all=True)
222 241 stats = None
223 242 else:
224 243 try:
225 244 # ui.forcemerge is an internal variable, do not document
226 245 repo.ui.setconfig('ui', 'forcemerge', opts.get('tool', ''),
227 246 'histedit')
228 247 stats = mergemod.graft(repo, ctx, ctx.p1(), ['local', 'histedit'])
229 248 finally:
230 249 repo.ui.setconfig('ui', 'forcemerge', '', 'histedit')
231 250 return stats
232 251
233 252 def collapse(repo, first, last, commitopts):
234 253 """collapse the set of revisions from first to last as new one.
235 254
236 255 Expected commit options are:
237 256 - message
238 257 - date
239 258 - username
240 259 Commit message is edited in all cases.
241 260
242 261 This function works in memory."""
243 262 ctxs = list(repo.set('%d::%d', first, last))
244 263 if not ctxs:
245 264 return None
246 265 base = first.parents()[0]
247 266
248 267 # commit a new version of the old changeset, including the update
249 268 # collect all files which might be affected
250 269 files = set()
251 270 for ctx in ctxs:
252 271 files.update(ctx.files())
253 272
254 273 # Recompute copies (avoid recording a -> b -> a)
255 274 copied = copies.pathcopies(base, last)
256 275
257 276 # prune files which were reverted by the updates
258 277 def samefile(f):
259 278 if f in last.manifest():
260 279 a = last.filectx(f)
261 280 if f in base.manifest():
262 281 b = base.filectx(f)
263 282 return (a.data() == b.data()
264 283 and a.flags() == b.flags())
265 284 else:
266 285 return False
267 286 else:
268 287 return f not in base.manifest()
269 288 files = [f for f in files if not samefile(f)]
270 289 # commit version of these files as defined by head
271 290 headmf = last.manifest()
272 291 def filectxfn(repo, ctx, path):
273 292 if path in headmf:
274 293 fctx = last[path]
275 294 flags = fctx.flags()
276 295 mctx = context.memfilectx(repo,
277 296 fctx.path(), fctx.data(),
278 297 islink='l' in flags,
279 298 isexec='x' in flags,
280 299 copied=copied.get(path))
281 300 return mctx
282 301 return None
283 302
284 303 if commitopts.get('message'):
285 304 message = commitopts['message']
286 305 else:
287 306 message = first.description()
288 307 user = commitopts.get('user')
289 308 date = commitopts.get('date')
290 309 extra = commitopts.get('extra')
291 310
292 311 parents = (first.p1().node(), first.p2().node())
293 312 editor = None
294 313 if not commitopts.get('rollup'):
295 314 editor = cmdutil.getcommiteditor(edit=True, editform='histedit.fold')
296 315 new = context.memctx(repo,
297 316 parents=parents,
298 317 text=message,
299 318 files=files,
300 319 filectxfn=filectxfn,
301 320 user=user,
302 321 date=date,
303 322 extra=extra,
304 323 editor=editor)
305 324 return repo.commitctx(new)
306 325
307 326 def pick(ui, repo, ctx, ha, opts):
308 327 oldctx = repo[ha]
309 328 if oldctx.parents()[0] == ctx:
310 329 ui.debug('node %s unchanged\n' % ha)
311 330 return oldctx, []
312 331 hg.update(repo, ctx.node())
313 332 stats = applychanges(ui, repo, oldctx, opts)
314 333 if stats and stats[3] > 0:
315 334 raise error.InterventionRequired(_('Fix up the change and run '
316 335 'hg histedit --continue'))
317 336 # drop the second merge parent
318 337 commit = commitfuncfor(repo, oldctx)
319 338 n = commit(text=oldctx.description(), user=oldctx.user(),
320 339 date=oldctx.date(), extra=oldctx.extra())
321 340 if n is None:
322 341 ui.warn(_('%s: empty changeset\n')
323 342 % node.hex(ha))
324 343 return ctx, []
325 344 new = repo[n]
326 345 return new, [(oldctx.node(), (n,))]
327 346
328 347
329 348 def edit(ui, repo, ctx, ha, opts):
330 349 oldctx = repo[ha]
331 350 hg.update(repo, ctx.node())
332 351 applychanges(ui, repo, oldctx, opts)
333 352 raise error.InterventionRequired(
334 353 _('Make changes as needed, you may commit or record as needed now.\n'
335 354 'When you are finished, run hg histedit --continue to resume.'))
336 355
337 356 def rollup(ui, repo, ctx, ha, opts):
338 357 rollupopts = opts.copy()
339 358 rollupopts['rollup'] = True
340 359 return fold(ui, repo, ctx, ha, rollupopts)
341 360
342 361 def fold(ui, repo, ctx, ha, opts):
343 362 oldctx = repo[ha]
344 363 hg.update(repo, ctx.node())
345 364 stats = applychanges(ui, repo, oldctx, opts)
346 365 if stats and stats[3] > 0:
347 366 raise error.InterventionRequired(
348 367 _('Fix up the change and run hg histedit --continue'))
349 368 n = repo.commit(text='fold-temp-revision %s' % ha, user=oldctx.user(),
350 369 date=oldctx.date(), extra=oldctx.extra())
351 370 if n is None:
352 371 ui.warn(_('%s: empty changeset')
353 372 % node.hex(ha))
354 373 return ctx, []
355 374 return finishfold(ui, repo, ctx, oldctx, n, opts, [])
356 375
357 376 def finishfold(ui, repo, ctx, oldctx, newnode, opts, internalchanges):
358 377 parent = ctx.parents()[0].node()
359 378 hg.update(repo, parent)
360 379 ### prepare new commit data
361 380 commitopts = opts.copy()
362 381 commitopts['user'] = ctx.user()
363 382 # commit message
364 383 if opts.get('rollup'):
365 384 newmessage = ctx.description()
366 385 else:
367 386 newmessage = '\n***\n'.join(
368 387 [ctx.description()] +
369 388 [repo[r].description() for r in internalchanges] +
370 389 [oldctx.description()]) + '\n'
371 390 commitopts['message'] = newmessage
372 391 # date
373 392 commitopts['date'] = max(ctx.date(), oldctx.date())
374 393 extra = ctx.extra().copy()
375 394 # histedit_source
376 395 # note: ctx is likely a temporary commit but that the best we can do here
377 396 # This is sufficient to solve issue3681 anyway
378 397 extra['histedit_source'] = '%s,%s' % (ctx.hex(), oldctx.hex())
379 398 commitopts['extra'] = extra
380 399 phasebackup = repo.ui.backupconfig('phases', 'new-commit')
381 400 try:
382 401 phasemin = max(ctx.phase(), oldctx.phase())
383 402 repo.ui.setconfig('phases', 'new-commit', phasemin, 'histedit')
384 403 n = collapse(repo, ctx, repo[newnode], commitopts)
385 404 finally:
386 405 repo.ui.restoreconfig(phasebackup)
387 406 if n is None:
388 407 return ctx, []
389 408 hg.update(repo, n)
390 409 replacements = [(oldctx.node(), (newnode,)),
391 410 (ctx.node(), (n,)),
392 411 (newnode, (n,)),
393 412 ]
394 413 for ich in internalchanges:
395 414 replacements.append((ich, (n,)))
396 415 return repo[n], replacements
397 416
398 417 def drop(ui, repo, ctx, ha, opts):
399 418 return ctx, [(repo[ha].node(), ())]
400 419
401 420
402 421 def message(ui, repo, ctx, ha, opts):
403 422 oldctx = repo[ha]
404 423 hg.update(repo, ctx.node())
405 424 stats = applychanges(ui, repo, oldctx, opts)
406 425 if stats and stats[3] > 0:
407 426 raise error.InterventionRequired(
408 427 _('Fix up the change and run hg histedit --continue'))
409 428 message = oldctx.description()
410 429 commit = commitfuncfor(repo, oldctx)
411 430 editor = cmdutil.getcommiteditor(edit=True, editform='histedit.mess')
412 431 new = commit(text=message, user=oldctx.user(), date=oldctx.date(),
413 432 extra=oldctx.extra(),
414 433 editor=editor)
415 434 newctx = repo[new]
416 435 if oldctx.node() != newctx.node():
417 436 return newctx, [(oldctx.node(), (new,))]
418 437 # We didn't make an edit, so just indicate no replaced nodes
419 438 return newctx, []
420 439
421 440 def findoutgoing(ui, repo, remote=None, force=False, opts={}):
422 441 """utility function to find the first outgoing changeset
423 442
424 443 Used by initialisation code"""
425 444 dest = ui.expandpath(remote or 'default-push', remote or 'default')
426 445 dest, revs = hg.parseurl(dest, None)[:2]
427 446 ui.status(_('comparing with %s\n') % util.hidepassword(dest))
428 447
429 448 revs, checkout = hg.addbranchrevs(repo, repo, revs, None)
430 449 other = hg.peer(repo, opts, dest)
431 450
432 451 if revs:
433 452 revs = [repo.lookup(rev) for rev in revs]
434 453
435 454 outgoing = discovery.findcommonoutgoing(repo, other, revs, force=force)
436 455 if not outgoing.missing:
437 456 raise util.Abort(_('no outgoing ancestors'))
438 457 roots = list(repo.revs("roots(%ln)", outgoing.missing))
439 458 if 1 < len(roots):
440 459 msg = _('there are ambiguous outgoing revisions')
441 460 hint = _('see "hg help histedit" for more detail')
442 461 raise util.Abort(msg, hint=hint)
443 462 return repo.lookup(roots[0])
444 463
445 464 actiontable = {'p': pick,
446 465 'pick': pick,
447 466 'e': edit,
448 467 'edit': edit,
449 468 'f': fold,
450 469 'fold': fold,
451 470 'r': rollup,
452 471 'roll': rollup,
453 472 'd': drop,
454 473 'drop': drop,
455 474 'm': message,
456 475 'mess': message,
457 476 }
458 477
459 478 @command('histedit',
460 479 [('', 'commands', '',
461 480 _('Read history edits from the specified file.')),
462 481 ('c', 'continue', False, _('continue an edit already in progress')),
463 482 ('k', 'keep', False,
464 483 _("don't strip old nodes after edit is complete")),
465 484 ('', 'abort', False, _('abort an edit in progress')),
466 485 ('o', 'outgoing', False, _('changesets not found in destination')),
467 486 ('f', 'force', False,
468 487 _('force outgoing even for unrelated repositories')),
469 488 ('r', 'rev', [], _('first revision to be edited'))],
470 489 _("ANCESTOR | --outgoing [URL]"))
471 490 def histedit(ui, repo, *freeargs, **opts):
472 491 """interactively edit changeset history
473 492
474 493 This command edits changesets between ANCESTOR and the parent of
475 494 the working directory.
476 495
477 496 With --outgoing, this edits changesets not found in the
478 497 destination repository. If URL of the destination is omitted, the
479 498 'default-push' (or 'default') path will be used.
480 499
481 500 For safety, this command is aborted, also if there are ambiguous
482 501 outgoing revisions which may confuse users: for example, there are
483 502 multiple branches containing outgoing revisions.
484 503
485 504 Use "min(outgoing() and ::.)" or similar revset specification
486 505 instead of --outgoing to specify edit target revision exactly in
487 506 such ambiguous situation. See :hg:`help revsets` for detail about
488 507 selecting revisions.
489 508
490 509 Returns 0 on success, 1 if user intervention is required (not only
491 510 for intentional "edit" command, but also for resolving unexpected
492 511 conflicts).
493 512 """
494 513 lock = wlock = None
495 514 try:
496 515 wlock = repo.wlock()
497 516 lock = repo.lock()
498 517 _histedit(ui, repo, *freeargs, **opts)
499 518 finally:
500 519 release(lock, wlock)
501 520
502 521 def _histedit(ui, repo, *freeargs, **opts):
503 522 # TODO only abort if we try and histedit mq patches, not just
504 523 # blanket if mq patches are applied somewhere
505 524 mq = getattr(repo, 'mq', None)
506 525 if mq and mq.applied:
507 526 raise util.Abort(_('source has mq patches applied'))
508 527
509 528 # basic argument incompatibility processing
510 529 outg = opts.get('outgoing')
511 530 cont = opts.get('continue')
512 531 abort = opts.get('abort')
513 532 force = opts.get('force')
514 533 rules = opts.get('commands', '')
515 534 revs = opts.get('rev', [])
516 535 goal = 'new' # This invocation goal, in new, continue, abort
517 536 if force and not outg:
518 537 raise util.Abort(_('--force only allowed with --outgoing'))
519 538 if cont:
520 539 if util.any((outg, abort, revs, freeargs, rules)):
521 540 raise util.Abort(_('no arguments allowed with --continue'))
522 541 goal = 'continue'
523 542 elif abort:
524 543 if util.any((outg, revs, freeargs, rules)):
525 544 raise util.Abort(_('no arguments allowed with --abort'))
526 545 goal = 'abort'
527 546 else:
528 547 if os.path.exists(os.path.join(repo.path, 'histedit-state')):
529 548 raise util.Abort(_('history edit already in progress, try '
530 549 '--continue or --abort'))
531 550 if outg:
532 551 if revs:
533 552 raise util.Abort(_('no revisions allowed with --outgoing'))
534 553 if len(freeargs) > 1:
535 554 raise util.Abort(
536 555 _('only one repo argument allowed with --outgoing'))
537 556 else:
538 557 revs.extend(freeargs)
539 558 if len(revs) != 1:
540 559 raise util.Abort(
541 560 _('histedit requires exactly one ancestor revision'))
542 561
543 562
544 563 if goal == 'continue':
545 564 (parentctxnode, rules, keep, topmost, replacements) = readstate(repo)
546 565 parentctx = repo[parentctxnode]
547 566 parentctx, repl = bootstrapcontinue(ui, repo, parentctx, rules, opts)
548 567 replacements.extend(repl)
549 568 elif goal == 'abort':
550 569 (parentctxnode, rules, keep, topmost, replacements) = readstate(repo)
551 570 mapping, tmpnodes, leafs, _ntm = processreplacement(repo, replacements)
552 571 ui.debug('restore wc to old parent %s\n' % node.short(topmost))
553 572 # check whether we should update away
554 573 parentnodes = [c.node() for c in repo[None].parents()]
555 574 for n in leafs | set([parentctxnode]):
556 575 if n in parentnodes:
557 576 hg.clean(repo, topmost)
558 577 break
559 578 else:
560 579 pass
561 580 cleanupnode(ui, repo, 'created', tmpnodes)
562 581 cleanupnode(ui, repo, 'temp', leafs)
563 582 os.unlink(os.path.join(repo.path, 'histedit-state'))
564 583 return
565 584 else:
566 585 cmdutil.checkunfinished(repo)
567 586 cmdutil.bailifchanged(repo)
568 587
569 588 topmost, empty = repo.dirstate.parents()
570 589 if outg:
571 590 if freeargs:
572 591 remote = freeargs[0]
573 592 else:
574 593 remote = None
575 594 root = findoutgoing(ui, repo, remote, force, opts)
576 595 else:
577 596 rr = list(repo.set('roots(%ld)', scmutil.revrange(repo, revs)))
578 597 if len(rr) != 1:
579 598 raise util.Abort(_('The specified revisions must have '
580 599 'exactly one common root'))
581 600 root = rr[0].node()
582 601
583 602 keep = opts.get('keep', False)
584 603 revs = between(repo, root, topmost, keep)
585 604 if not revs:
586 605 raise util.Abort(_('%s is not an ancestor of working directory') %
587 606 node.short(root))
588 607
589 608 ctxs = [repo[r] for r in revs]
590 609 if not rules:
591 610 rules = '\n'.join([makedesc(c) for c in ctxs])
592 611 rules += '\n\n'
593 612 rules += editcomment % (node.short(root), node.short(topmost))
594 613 rules = ui.edit(rules, ui.username())
595 614 # Save edit rules in .hg/histedit-last-edit.txt in case
596 615 # the user needs to ask for help after something
597 616 # surprising happens.
598 617 f = open(repo.join('histedit-last-edit.txt'), 'w')
599 618 f.write(rules)
600 619 f.close()
601 620 else:
602 621 if rules == '-':
603 622 f = sys.stdin
604 623 else:
605 624 f = open(rules)
606 625 rules = f.read()
607 626 f.close()
608 627 rules = [l for l in (r.strip() for r in rules.splitlines())
609 628 if l and not l.startswith('#')]
610 629 rules = verifyrules(rules, repo, ctxs)
611 630
612 631 parentctx = repo[root].parents()[0]
613 632 replacements = []
614 633
615 634
616 635 while rules:
617 636 writestate(repo, parentctx.node(), rules, keep, topmost, replacements)
618 637 action, ha = rules.pop(0)
619 638 ui.debug('histedit: processing %s %s\n' % (action, ha))
620 639 actfunc = actiontable[action]
621 640 parentctx, replacement_ = actfunc(ui, repo, parentctx, ha, opts)
622 641 replacements.extend(replacement_)
623 642
624 643 hg.update(repo, parentctx.node())
625 644
626 645 mapping, tmpnodes, created, ntm = processreplacement(repo, replacements)
627 646 if mapping:
628 647 for prec, succs in mapping.iteritems():
629 648 if not succs:
630 649 ui.debug('histedit: %s is dropped\n' % node.short(prec))
631 650 else:
632 651 ui.debug('histedit: %s is replaced by %s\n' % (
633 652 node.short(prec), node.short(succs[0])))
634 653 if len(succs) > 1:
635 654 m = 'histedit: %s'
636 655 for n in succs[1:]:
637 656 ui.debug(m % node.short(n))
638 657
639 658 if not keep:
640 659 if mapping:
641 660 movebookmarks(ui, repo, mapping, topmost, ntm)
642 661 # TODO update mq state
643 662 if obsolete.isenabled(repo, obsolete.createmarkersopt):
644 663 markers = []
645 664 # sort by revision number because it sound "right"
646 665 for prec in sorted(mapping, key=repo.changelog.rev):
647 666 succs = mapping[prec]
648 667 markers.append((repo[prec],
649 668 tuple(repo[s] for s in succs)))
650 669 if markers:
651 670 obsolete.createmarkers(repo, markers)
652 671 else:
653 672 cleanupnode(ui, repo, 'replaced', mapping)
654 673
655 674 cleanupnode(ui, repo, 'temp', tmpnodes)
656 675 os.unlink(os.path.join(repo.path, 'histedit-state'))
657 676 if os.path.exists(repo.sjoin('undo')):
658 677 os.unlink(repo.sjoin('undo'))
659 678
660 679 def gatherchildren(repo, ctx):
661 680 # is there any new commit between the expected parent and "."
662 681 #
663 682 # note: does not take non linear new change in account (but previous
664 683 # implementation didn't used them anyway (issue3655)
665 684 newchildren = [c.node() for c in repo.set('(%d::.)', ctx)]
666 685 if ctx.node() != node.nullid:
667 686 if not newchildren:
668 687 # `ctx` should match but no result. This means that
669 688 # currentnode is not a descendant from ctx.
670 689 msg = _('%s is not an ancestor of working directory')
671 690 hint = _('use "histedit --abort" to clear broken state')
672 691 raise util.Abort(msg % ctx, hint=hint)
673 692 newchildren.pop(0) # remove ctx
674 693 return newchildren
675 694
676 695 def bootstrapcontinue(ui, repo, parentctx, rules, opts):
677 696 action, currentnode = rules.pop(0)
678 697 ctx = repo[currentnode]
679 698
680 699 newchildren = gatherchildren(repo, parentctx)
681 700
682 701 # Commit dirty working directory if necessary
683 702 new = None
684 703 s = repo.status()
685 704 if s.modified or s.added or s.removed or s.deleted:
686 705 # prepare the message for the commit to comes
687 706 if action in ('f', 'fold', 'r', 'roll'):
688 707 message = 'fold-temp-revision %s' % currentnode
689 708 else:
690 709 message = ctx.description()
691 710 editopt = action in ('e', 'edit', 'm', 'mess')
692 711 canonaction = {'e': 'edit', 'm': 'mess', 'p': 'pick'}
693 712 editform = 'histedit.%s' % canonaction.get(action, action)
694 713 editor = cmdutil.getcommiteditor(edit=editopt, editform=editform)
695 714 commit = commitfuncfor(repo, ctx)
696 715 new = commit(text=message, user=ctx.user(),
697 716 date=ctx.date(), extra=ctx.extra(),
698 717 editor=editor)
699 718 if new is not None:
700 719 newchildren.append(new)
701 720
702 721 replacements = []
703 722 # track replacements
704 723 if ctx.node() not in newchildren:
705 724 # note: new children may be empty when the changeset is dropped.
706 725 # this happen e.g during conflicting pick where we revert content
707 726 # to parent.
708 727 replacements.append((ctx.node(), tuple(newchildren)))
709 728
710 729 if action in ('f', 'fold', 'r', 'roll'):
711 730 if newchildren:
712 731 # finalize fold operation if applicable
713 732 if new is None:
714 733 new = newchildren[-1]
715 734 else:
716 735 newchildren.pop() # remove new from internal changes
717 736 foldopts = opts
718 737 if action in ('r', 'roll'):
719 738 foldopts = foldopts.copy()
720 739 foldopts['rollup'] = True
721 740 parentctx, repl = finishfold(ui, repo, parentctx, ctx, new,
722 741 foldopts, newchildren)
723 742 replacements.extend(repl)
724 743 else:
725 744 # newchildren is empty if the fold did not result in any commit
726 745 # this happen when all folded change are discarded during the
727 746 # merge.
728 747 replacements.append((ctx.node(), (parentctx.node(),)))
729 748 elif newchildren:
730 749 # otherwise update "parentctx" before proceeding to further operation
731 750 parentctx = repo[newchildren[-1]]
732 751 return parentctx, replacements
733 752
734 753
735 754 def between(repo, old, new, keep):
736 755 """select and validate the set of revision to edit
737 756
738 757 When keep is false, the specified set can't have children."""
739 758 ctxs = list(repo.set('%n::%n', old, new))
740 759 if ctxs and not keep:
741 760 if (not obsolete.isenabled(repo, obsolete.allowunstableopt) and
742 761 repo.revs('(%ld::) - (%ld)', ctxs, ctxs)):
743 762 raise util.Abort(_('cannot edit history that would orphan nodes'))
744 763 if repo.revs('(%ld) and merge()', ctxs):
745 764 raise util.Abort(_('cannot edit history that contains merges'))
746 765 root = ctxs[0] # list is already sorted by repo.set
747 766 if not root.mutable():
748 767 raise util.Abort(_('cannot edit immutable changeset: %s') % root)
749 768 return [c.node() for c in ctxs]
750 769
751 770
752 771 def writestate(repo, parentnode, rules, keep, topmost, replacements):
753 772 fp = open(os.path.join(repo.path, 'histedit-state'), 'w')
754 773 pickle.dump((parentnode, rules, keep, topmost, replacements), fp)
755 774 fp.close()
756 775
757 776 def readstate(repo):
758 777 """Returns a tuple of (parentnode, rules, keep, topmost, replacements).
759 778 """
760 779 try:
761 780 fp = open(os.path.join(repo.path, 'histedit-state'))
762 781 except IOError, err:
763 782 if err.errno != errno.ENOENT:
764 783 raise
765 784 raise util.Abort(_('no histedit in progress'))
766 785 return pickle.load(fp)
767 786
768 787
769 788 def makedesc(c):
770 789 """build a initial action line for a ctx `c`
771 790
772 791 line are in the form:
773 792
774 793 pick <hash> <rev> <summary>
775 794 """
776 795 summary = ''
777 796 if c.description():
778 797 summary = c.description().splitlines()[0]
779 798 line = 'pick %s %d %s' % (c, c.rev(), summary)
780 799 # trim to 80 columns so it's not stupidly wide in my editor
781 800 return util.ellipsis(line, 80)
782 801
783 802 def verifyrules(rules, repo, ctxs):
784 803 """Verify that there exists exactly one edit rule per given changeset.
785 804
786 805 Will abort if there are to many or too few rules, a malformed rule,
787 806 or a rule on a changeset outside of the user-given range.
788 807 """
789 808 parsed = []
790 809 expected = set(str(c) for c in ctxs)
791 810 seen = set()
792 811 for r in rules:
793 812 if ' ' not in r:
794 813 raise util.Abort(_('malformed line "%s"') % r)
795 814 action, rest = r.split(' ', 1)
796 815 ha = rest.strip().split(' ', 1)[0]
797 816 try:
798 817 ha = str(repo[ha]) # ensure its a short hash
799 818 except error.RepoError:
800 819 raise util.Abort(_('unknown changeset %s listed') % ha)
801 820 if ha not in expected:
802 821 raise util.Abort(
803 822 _('may not use changesets other than the ones listed'))
804 823 if ha in seen:
805 824 raise util.Abort(_('duplicated command for changeset %s') % ha)
806 825 seen.add(ha)
807 826 if action not in actiontable:
808 827 raise util.Abort(_('unknown action "%s"') % action)
809 828 parsed.append([action, ha])
810 829 missing = sorted(expected - seen) # sort to stabilize output
811 830 if missing:
812 831 raise util.Abort(_('missing rules for changeset %s') % missing[0],
813 832 hint=_('do you want to use the drop action?'))
814 833 return parsed
815 834
816 835 def processreplacement(repo, replacements):
817 836 """process the list of replacements to return
818 837
819 838 1) the final mapping between original and created nodes
820 839 2) the list of temporary node created by histedit
821 840 3) the list of new commit created by histedit"""
822 841 allsuccs = set()
823 842 replaced = set()
824 843 fullmapping = {}
825 844 # initialise basic set
826 845 # fullmapping record all operation recorded in replacement
827 846 for rep in replacements:
828 847 allsuccs.update(rep[1])
829 848 replaced.add(rep[0])
830 849 fullmapping.setdefault(rep[0], set()).update(rep[1])
831 850 new = allsuccs - replaced
832 851 tmpnodes = allsuccs & replaced
833 852 # Reduce content fullmapping into direct relation between original nodes
834 853 # and final node created during history edition
835 854 # Dropped changeset are replaced by an empty list
836 855 toproceed = set(fullmapping)
837 856 final = {}
838 857 while toproceed:
839 858 for x in list(toproceed):
840 859 succs = fullmapping[x]
841 860 for s in list(succs):
842 861 if s in toproceed:
843 862 # non final node with unknown closure
844 863 # We can't process this now
845 864 break
846 865 elif s in final:
847 866 # non final node, replace with closure
848 867 succs.remove(s)
849 868 succs.update(final[s])
850 869 else:
851 870 final[x] = succs
852 871 toproceed.remove(x)
853 872 # remove tmpnodes from final mapping
854 873 for n in tmpnodes:
855 874 del final[n]
856 875 # we expect all changes involved in final to exist in the repo
857 876 # turn `final` into list (topologically sorted)
858 877 nm = repo.changelog.nodemap
859 878 for prec, succs in final.items():
860 879 final[prec] = sorted(succs, key=nm.get)
861 880
862 881 # computed topmost element (necessary for bookmark)
863 882 if new:
864 883 newtopmost = sorted(new, key=repo.changelog.rev)[-1]
865 884 elif not final:
866 885 # Nothing rewritten at all. we won't need `newtopmost`
867 886 # It is the same as `oldtopmost` and `processreplacement` know it
868 887 newtopmost = None
869 888 else:
870 889 # every body died. The newtopmost is the parent of the root.
871 890 newtopmost = repo[sorted(final, key=repo.changelog.rev)[0]].p1().node()
872 891
873 892 return final, tmpnodes, new, newtopmost
874 893
875 894 def movebookmarks(ui, repo, mapping, oldtopmost, newtopmost):
876 895 """Move bookmark from old to newly created node"""
877 896 if not mapping:
878 897 # if nothing got rewritten there is not purpose for this function
879 898 return
880 899 moves = []
881 900 for bk, old in sorted(repo._bookmarks.iteritems()):
882 901 if old == oldtopmost:
883 902 # special case ensure bookmark stay on tip.
884 903 #
885 904 # This is arguably a feature and we may only want that for the
886 905 # active bookmark. But the behavior is kept compatible with the old
887 906 # version for now.
888 907 moves.append((bk, newtopmost))
889 908 continue
890 909 base = old
891 910 new = mapping.get(base, None)
892 911 if new is None:
893 912 continue
894 913 while not new:
895 914 # base is killed, trying with parent
896 915 base = repo[base].p1().node()
897 916 new = mapping.get(base, (base,))
898 917 # nothing to move
899 918 moves.append((bk, new[-1]))
900 919 if moves:
901 920 marks = repo._bookmarks
902 921 for mark, new in moves:
903 922 old = marks[mark]
904 923 ui.note(_('histedit: moving bookmarks %s from %s to %s\n')
905 924 % (mark, node.short(old), node.short(new)))
906 925 marks[mark] = new
907 926 marks.write()
908 927
909 928 def cleanupnode(ui, repo, name, nodes):
910 929 """strip a group of nodes from the repository
911 930
912 931 The set of node to strip may contains unknown nodes."""
913 932 ui.debug('should strip %s nodes %s\n' %
914 933 (name, ', '.join([node.short(n) for n in nodes])))
915 934 lock = None
916 935 try:
917 936 lock = repo.lock()
918 937 # Find all node that need to be stripped
919 938 # (we hg %lr instead of %ln to silently ignore unknown item
920 939 nm = repo.changelog.nodemap
921 940 nodes = sorted(n for n in nodes if n in nm)
922 941 roots = [c.node() for c in repo.set("roots(%ln)", nodes)]
923 942 for c in roots:
924 943 # We should process node in reverse order to strip tip most first.
925 944 # but this trigger a bug in changegroup hook.
926 945 # This would reduce bundle overhead
927 946 repair.strip(ui, repo, c)
928 947 finally:
929 948 release(lock)
930 949
931 950 def summaryhook(ui, repo):
932 951 if not os.path.exists(repo.join('histedit-state')):
933 952 return
934 953 (parentctxnode, rules, keep, topmost, replacements) = readstate(repo)
935 954 if rules:
936 955 # i18n: column positioning for "hg summary"
937 956 ui.write(_('hist: %s (histedit --continue)\n') %
938 957 (ui.label(_('%d remaining'), 'histedit.remaining') %
939 958 len(rules)))
940 959
941 960 def extsetup(ui):
942 961 cmdutil.summaryhooks.add('histedit', summaryhook)
943 962 cmdutil.unfinishedstates.append(
944 963 ['histedit-state', False, True, _('histedit in progress'),
945 964 _("use 'hg histedit --continue' or 'hg histedit --abort'")])
General Comments 0
You need to be logged in to leave comments. Login now