##// END OF EJS Templates
histedit: change state format to allow non-hash lines...
Durham Goode -
r24810:f5416657 stable
parent child Browse files
Show More
@@ -1,1144 +1,1145 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 Histedit rule lines are truncated to 80 characters by default. You
147 147 can customise this behaviour by setting a different length in your
148 148 configuration file:
149 149
150 150 [histedit]
151 151 linelen = 120 # truncate rule lines at 120 characters
152 152 """
153 153
154 154 try:
155 155 import cPickle as pickle
156 156 pickle.dump # import now
157 157 except ImportError:
158 158 import pickle
159 159 import errno
160 160 import os
161 161 import sys
162 162
163 163 from mercurial import cmdutil
164 164 from mercurial import discovery
165 165 from mercurial import error
166 166 from mercurial import changegroup
167 167 from mercurial import copies
168 168 from mercurial import context
169 169 from mercurial import exchange
170 170 from mercurial import extensions
171 171 from mercurial import hg
172 172 from mercurial import node
173 173 from mercurial import repair
174 174 from mercurial import scmutil
175 175 from mercurial import util
176 176 from mercurial import obsolete
177 177 from mercurial import merge as mergemod
178 178 from mercurial.lock import release
179 179 from mercurial.i18n import _
180 180
181 181 cmdtable = {}
182 182 command = cmdutil.command(cmdtable)
183 183
184 184 testedwith = 'internal'
185 185
186 186 # i18n: command names and abbreviations must remain untranslated
187 187 editcomment = _("""# Edit history between %s and %s
188 188 #
189 189 # Commits are listed from least to most recent
190 190 #
191 191 # Commands:
192 192 # p, pick = use commit
193 193 # e, edit = use commit, but stop for amending
194 194 # f, fold = use commit, but combine it with the one above
195 195 # r, roll = like fold, but discard this commit's description
196 196 # d, drop = remove commit from history
197 197 # m, mess = edit message without changing commit content
198 198 #
199 199 """)
200 200
201 201 class histeditstate(object):
202 202 def __init__(self, repo, parentctxnode=None, rules=None, keep=None,
203 203 topmost=None, replacements=None, lock=None, wlock=None):
204 204 self.repo = repo
205 205 self.rules = rules
206 206 self.keep = keep
207 207 self.topmost = topmost
208 208 self.parentctxnode = parentctxnode
209 209 self.lock = lock
210 210 self.wlock = wlock
211 211 self.backupfile = None
212 212 if replacements is None:
213 213 self.replacements = []
214 214 else:
215 215 self.replacements = replacements
216 216
217 217 def read(self):
218 218 """Load histedit state from disk and set fields appropriately."""
219 219 try:
220 220 fp = self.repo.vfs('histedit-state', 'r')
221 221 except IOError, err:
222 222 if err.errno != errno.ENOENT:
223 223 raise
224 224 raise util.Abort(_('no histedit in progress'))
225 225
226 226 try:
227 227 data = pickle.load(fp)
228 228 parentctxnode, rules, keep, topmost, replacements = data
229 229 backupfile = None
230 230 except pickle.UnpicklingError:
231 231 data = self._load()
232 232 parentctxnode, rules, keep, topmost, replacements, backupfile = data
233 233
234 234 self.parentctxnode = parentctxnode
235 235 self.rules = rules
236 236 self.keep = keep
237 237 self.topmost = topmost
238 238 self.replacements = replacements
239 239 self.backupfile = backupfile
240 240
241 241 def write(self):
242 242 fp = self.repo.vfs('histedit-state', 'w')
243 243 fp.write('v1\n')
244 244 fp.write('%s\n' % node.hex(self.parentctxnode))
245 245 fp.write('%s\n' % node.hex(self.topmost))
246 246 fp.write('%s\n' % self.keep)
247 247 fp.write('%d\n' % len(self.rules))
248 248 for rule in self.rules:
249 fp.write('%s%s\n' % (rule[1], rule[0]))
249 fp.write('%s\n' % rule[0]) # action
250 fp.write('%s\n' % rule[1]) # remainder
250 251 fp.write('%d\n' % len(self.replacements))
251 252 for replacement in self.replacements:
252 253 fp.write('%s%s\n' % (node.hex(replacement[0]), ''.join(node.hex(r)
253 254 for r in replacement[1])))
254 255 fp.write('%s\n' % self.backupfile)
255 256 fp.close()
256 257
257 258 def _load(self):
258 259 fp = self.repo.vfs('histedit-state', 'r')
259 260 lines = [l[:-1] for l in fp.readlines()]
260 261
261 262 index = 0
262 263 lines[index] # version number
263 264 index += 1
264 265
265 266 parentctxnode = node.bin(lines[index])
266 267 index += 1
267 268
268 269 topmost = node.bin(lines[index])
269 270 index += 1
270 271
271 272 keep = lines[index] == 'True'
272 273 index += 1
273 274
274 275 # Rules
275 276 rules = []
276 277 rulelen = int(lines[index])
277 278 index += 1
278 279 for i in xrange(rulelen):
280 ruleaction = lines[index]
281 index += 1
279 282 rule = lines[index]
280 rulehash = rule[:40]
281 ruleaction = rule[40:]
282 rules.append((ruleaction, rulehash))
283 283 index += 1
284 rules.append((ruleaction, rule))
284 285
285 286 # Replacements
286 287 replacements = []
287 288 replacementlen = int(lines[index])
288 289 index += 1
289 290 for i in xrange(replacementlen):
290 291 replacement = lines[index]
291 292 original = node.bin(replacement[:40])
292 293 succ = [node.bin(replacement[i:i + 40]) for i in
293 294 range(40, len(replacement), 40)]
294 295 replacements.append((original, succ))
295 296 index += 1
296 297
297 298 backupfile = lines[index]
298 299 index += 1
299 300
300 301 fp.close()
301 302
302 303 return parentctxnode, rules, keep, topmost, replacements, backupfile
303 304
304 305 def clear(self):
305 306 self.repo.vfs.unlink('histedit-state')
306 307
307 308 class histeditaction(object):
308 309 def __init__(self, state, node):
309 310 self.state = state
310 311 self.repo = state.repo
311 312 self.node = node
312 313
313 314 @classmethod
314 315 def fromrule(cls, state, rule):
315 316 """Parses the given rule, returning an instance of the histeditaction.
316 317 """
317 318 repo = state.repo
318 319 rulehash = rule.strip().split(' ', 1)[0]
319 320 try:
320 321 node = repo[rulehash].node()
321 322 except error.RepoError:
322 323 raise util.Abort(_('unknown changeset %s listed') % rulehash[:12])
323 324 return cls(state, node)
324 325
325 326 def run(self):
326 327 """Runs the action. The default behavior is simply apply the action's
327 328 rulectx onto the current parentctx."""
328 329 self.applychange()
329 330 self.continuedirty()
330 331 return self.continueclean()
331 332
332 333 def applychange(self):
333 334 """Applies the changes from this action's rulectx onto the current
334 335 parentctx, but does not commit them."""
335 336 repo = self.repo
336 337 rulectx = repo[self.node]
337 338 hg.update(repo, self.state.parentctxnode)
338 339 stats = applychanges(repo.ui, repo, rulectx, {})
339 340 if stats and stats[3] > 0:
340 341 raise error.InterventionRequired(_('Fix up the change and run '
341 342 'hg histedit --continue'))
342 343
343 344 def continuedirty(self):
344 345 """Continues the action when changes have been applied to the working
345 346 copy. The default behavior is to commit the dirty changes."""
346 347 repo = self.repo
347 348 rulectx = repo[self.node]
348 349
349 350 editor = self.commiteditor()
350 351 commit = commitfuncfor(repo, rulectx)
351 352
352 353 commit(text=rulectx.description(), user=rulectx.user(),
353 354 date=rulectx.date(), extra=rulectx.extra(), editor=editor)
354 355
355 356 def commiteditor(self):
356 357 """The editor to be used to edit the commit message."""
357 358 return False
358 359
359 360 def continueclean(self):
360 361 """Continues the action when the working copy is clean. The default
361 362 behavior is to accept the current commit as the new version of the
362 363 rulectx."""
363 364 ctx = self.repo['.']
364 365 if ctx.node() == self.state.parentctxnode:
365 366 self.repo.ui.warn(_('%s: empty changeset\n') %
366 367 node.short(self.node))
367 368 return ctx, [(self.node, tuple())]
368 369 if ctx.node() == self.node:
369 370 # Nothing changed
370 371 return ctx, []
371 372 return ctx, [(self.node, (ctx.node(),))]
372 373
373 374 def commitfuncfor(repo, src):
374 375 """Build a commit function for the replacement of <src>
375 376
376 377 This function ensure we apply the same treatment to all changesets.
377 378
378 379 - Add a 'histedit_source' entry in extra.
379 380
380 381 Note that fold have its own separated logic because its handling is a bit
381 382 different and not easily factored out of the fold method.
382 383 """
383 384 phasemin = src.phase()
384 385 def commitfunc(**kwargs):
385 386 phasebackup = repo.ui.backupconfig('phases', 'new-commit')
386 387 try:
387 388 repo.ui.setconfig('phases', 'new-commit', phasemin,
388 389 'histedit')
389 390 extra = kwargs.get('extra', {}).copy()
390 391 extra['histedit_source'] = src.hex()
391 392 kwargs['extra'] = extra
392 393 return repo.commit(**kwargs)
393 394 finally:
394 395 repo.ui.restoreconfig(phasebackup)
395 396 return commitfunc
396 397
397 398 def applychanges(ui, repo, ctx, opts):
398 399 """Merge changeset from ctx (only) in the current working directory"""
399 400 wcpar = repo.dirstate.parents()[0]
400 401 if ctx.p1().node() == wcpar:
401 402 # edition ar "in place" we do not need to make any merge,
402 403 # just applies changes on parent for edition
403 404 cmdutil.revert(ui, repo, ctx, (wcpar, node.nullid), all=True)
404 405 stats = None
405 406 else:
406 407 try:
407 408 # ui.forcemerge is an internal variable, do not document
408 409 repo.ui.setconfig('ui', 'forcemerge', opts.get('tool', ''),
409 410 'histedit')
410 411 stats = mergemod.graft(repo, ctx, ctx.p1(), ['local', 'histedit'])
411 412 finally:
412 413 repo.ui.setconfig('ui', 'forcemerge', '', 'histedit')
413 414 return stats
414 415
415 416 def collapse(repo, first, last, commitopts):
416 417 """collapse the set of revisions from first to last as new one.
417 418
418 419 Expected commit options are:
419 420 - message
420 421 - date
421 422 - username
422 423 Commit message is edited in all cases.
423 424
424 425 This function works in memory."""
425 426 ctxs = list(repo.set('%d::%d', first, last))
426 427 if not ctxs:
427 428 return None
428 429 base = first.parents()[0]
429 430
430 431 # commit a new version of the old changeset, including the update
431 432 # collect all files which might be affected
432 433 files = set()
433 434 for ctx in ctxs:
434 435 files.update(ctx.files())
435 436
436 437 # Recompute copies (avoid recording a -> b -> a)
437 438 copied = copies.pathcopies(base, last)
438 439
439 440 # prune files which were reverted by the updates
440 441 def samefile(f):
441 442 if f in last.manifest():
442 443 a = last.filectx(f)
443 444 if f in base.manifest():
444 445 b = base.filectx(f)
445 446 return (a.data() == b.data()
446 447 and a.flags() == b.flags())
447 448 else:
448 449 return False
449 450 else:
450 451 return f not in base.manifest()
451 452 files = [f for f in files if not samefile(f)]
452 453 # commit version of these files as defined by head
453 454 headmf = last.manifest()
454 455 def filectxfn(repo, ctx, path):
455 456 if path in headmf:
456 457 fctx = last[path]
457 458 flags = fctx.flags()
458 459 mctx = context.memfilectx(repo,
459 460 fctx.path(), fctx.data(),
460 461 islink='l' in flags,
461 462 isexec='x' in flags,
462 463 copied=copied.get(path))
463 464 return mctx
464 465 return None
465 466
466 467 if commitopts.get('message'):
467 468 message = commitopts['message']
468 469 else:
469 470 message = first.description()
470 471 user = commitopts.get('user')
471 472 date = commitopts.get('date')
472 473 extra = commitopts.get('extra')
473 474
474 475 parents = (first.p1().node(), first.p2().node())
475 476 editor = None
476 477 if not commitopts.get('rollup'):
477 478 editor = cmdutil.getcommiteditor(edit=True, editform='histedit.fold')
478 479 new = context.memctx(repo,
479 480 parents=parents,
480 481 text=message,
481 482 files=files,
482 483 filectxfn=filectxfn,
483 484 user=user,
484 485 date=date,
485 486 extra=extra,
486 487 editor=editor)
487 488 return repo.commitctx(new)
488 489
489 490 class pick(histeditaction):
490 491 def run(self):
491 492 rulectx = self.repo[self.node]
492 493 if rulectx.parents()[0].node() == self.state.parentctxnode:
493 494 self.repo.ui.debug('node %s unchanged\n' % node.short(self.node))
494 495 return rulectx, []
495 496
496 497 return super(pick, self).run()
497 498
498 499 class edit(histeditaction):
499 500 def run(self):
500 501 repo = self.repo
501 502 rulectx = repo[self.node]
502 503 hg.update(repo, self.state.parentctxnode)
503 504 applychanges(repo.ui, repo, rulectx, {})
504 505 raise error.InterventionRequired(
505 506 _('Make changes as needed, you may commit or record as needed '
506 507 'now.\nWhen you are finished, run hg histedit --continue to '
507 508 'resume.'))
508 509
509 510 def commiteditor(self):
510 511 return cmdutil.getcommiteditor(edit=True, editform='histedit.edit')
511 512
512 513 class fold(histeditaction):
513 514 def continuedirty(self):
514 515 repo = self.repo
515 516 rulectx = repo[self.node]
516 517
517 518 commit = commitfuncfor(repo, rulectx)
518 519 commit(text='fold-temp-revision %s' % node.short(self.node),
519 520 user=rulectx.user(), date=rulectx.date(),
520 521 extra=rulectx.extra())
521 522
522 523 def continueclean(self):
523 524 repo = self.repo
524 525 ctx = repo['.']
525 526 rulectx = repo[self.node]
526 527 parentctxnode = self.state.parentctxnode
527 528 if ctx.node() == parentctxnode:
528 529 repo.ui.warn(_('%s: empty changeset\n') %
529 530 node.short(self.node))
530 531 return ctx, [(self.node, (parentctxnode,))]
531 532
532 533 parentctx = repo[parentctxnode]
533 534 newcommits = set(c.node() for c in repo.set('(%d::. - %d)', parentctx,
534 535 parentctx))
535 536 if not newcommits:
536 537 repo.ui.warn(_('%s: cannot fold - working copy is not a '
537 538 'descendant of previous commit %s\n') %
538 539 (node.short(self.node), node.short(parentctxnode)))
539 540 return ctx, [(self.node, (ctx.node(),))]
540 541
541 542 middlecommits = newcommits.copy()
542 543 middlecommits.discard(ctx.node())
543 544
544 545 return self.finishfold(repo.ui, repo, parentctx, rulectx, ctx.node(),
545 546 middlecommits)
546 547
547 548 def skipprompt(self):
548 549 return False
549 550
550 551 def finishfold(self, ui, repo, ctx, oldctx, newnode, internalchanges):
551 552 parent = ctx.parents()[0].node()
552 553 hg.update(repo, parent)
553 554 ### prepare new commit data
554 555 commitopts = {}
555 556 commitopts['user'] = ctx.user()
556 557 # commit message
557 558 if self.skipprompt():
558 559 newmessage = ctx.description()
559 560 else:
560 561 newmessage = '\n***\n'.join(
561 562 [ctx.description()] +
562 563 [repo[r].description() for r in internalchanges] +
563 564 [oldctx.description()]) + '\n'
564 565 commitopts['message'] = newmessage
565 566 # date
566 567 commitopts['date'] = max(ctx.date(), oldctx.date())
567 568 extra = ctx.extra().copy()
568 569 # histedit_source
569 570 # note: ctx is likely a temporary commit but that the best we can do
570 571 # here. This is sufficient to solve issue3681 anyway.
571 572 extra['histedit_source'] = '%s,%s' % (ctx.hex(), oldctx.hex())
572 573 commitopts['extra'] = extra
573 574 phasebackup = repo.ui.backupconfig('phases', 'new-commit')
574 575 try:
575 576 phasemin = max(ctx.phase(), oldctx.phase())
576 577 repo.ui.setconfig('phases', 'new-commit', phasemin, 'histedit')
577 578 n = collapse(repo, ctx, repo[newnode], commitopts)
578 579 finally:
579 580 repo.ui.restoreconfig(phasebackup)
580 581 if n is None:
581 582 return ctx, []
582 583 hg.update(repo, n)
583 584 replacements = [(oldctx.node(), (newnode,)),
584 585 (ctx.node(), (n,)),
585 586 (newnode, (n,)),
586 587 ]
587 588 for ich in internalchanges:
588 589 replacements.append((ich, (n,)))
589 590 return repo[n], replacements
590 591
591 592 class rollup(fold):
592 593 def skipprompt(self):
593 594 return True
594 595
595 596 class drop(histeditaction):
596 597 def run(self):
597 598 parentctx = self.repo[self.state.parentctxnode]
598 599 return parentctx, [(self.node, tuple())]
599 600
600 601 class message(histeditaction):
601 602 def commiteditor(self):
602 603 return cmdutil.getcommiteditor(edit=True, editform='histedit.mess')
603 604
604 605 def findoutgoing(ui, repo, remote=None, force=False, opts={}):
605 606 """utility function to find the first outgoing changeset
606 607
607 608 Used by initialisation code"""
608 609 dest = ui.expandpath(remote or 'default-push', remote or 'default')
609 610 dest, revs = hg.parseurl(dest, None)[:2]
610 611 ui.status(_('comparing with %s\n') % util.hidepassword(dest))
611 612
612 613 revs, checkout = hg.addbranchrevs(repo, repo, revs, None)
613 614 other = hg.peer(repo, opts, dest)
614 615
615 616 if revs:
616 617 revs = [repo.lookup(rev) for rev in revs]
617 618
618 619 outgoing = discovery.findcommonoutgoing(repo, other, revs, force=force)
619 620 if not outgoing.missing:
620 621 raise util.Abort(_('no outgoing ancestors'))
621 622 roots = list(repo.revs("roots(%ln)", outgoing.missing))
622 623 if 1 < len(roots):
623 624 msg = _('there are ambiguous outgoing revisions')
624 625 hint = _('see "hg help histedit" for more detail')
625 626 raise util.Abort(msg, hint=hint)
626 627 return repo.lookup(roots[0])
627 628
628 629 actiontable = {'p': pick,
629 630 'pick': pick,
630 631 'e': edit,
631 632 'edit': edit,
632 633 'f': fold,
633 634 'fold': fold,
634 635 'r': rollup,
635 636 'roll': rollup,
636 637 'd': drop,
637 638 'drop': drop,
638 639 'm': message,
639 640 'mess': message,
640 641 }
641 642
642 643 @command('histedit',
643 644 [('', 'commands', '',
644 645 _('read history edits from the specified file'), _('FILE')),
645 646 ('c', 'continue', False, _('continue an edit already in progress')),
646 647 ('', 'edit-plan', False, _('edit remaining actions list')),
647 648 ('k', 'keep', False,
648 649 _("don't strip old nodes after edit is complete")),
649 650 ('', 'abort', False, _('abort an edit in progress')),
650 651 ('o', 'outgoing', False, _('changesets not found in destination')),
651 652 ('f', 'force', False,
652 653 _('force outgoing even for unrelated repositories')),
653 654 ('r', 'rev', [], _('first revision to be edited'), _('REV'))],
654 655 _("ANCESTOR | --outgoing [URL]"))
655 656 def histedit(ui, repo, *freeargs, **opts):
656 657 """interactively edit changeset history
657 658
658 659 This command edits changesets between ANCESTOR and the parent of
659 660 the working directory.
660 661
661 662 With --outgoing, this edits changesets not found in the
662 663 destination repository. If URL of the destination is omitted, the
663 664 'default-push' (or 'default') path will be used.
664 665
665 666 For safety, this command is aborted, also if there are ambiguous
666 667 outgoing revisions which may confuse users: for example, there are
667 668 multiple branches containing outgoing revisions.
668 669
669 670 Use "min(outgoing() and ::.)" or similar revset specification
670 671 instead of --outgoing to specify edit target revision exactly in
671 672 such ambiguous situation. See :hg:`help revsets` for detail about
672 673 selecting revisions.
673 674
674 675 Returns 0 on success, 1 if user intervention is required (not only
675 676 for intentional "edit" command, but also for resolving unexpected
676 677 conflicts).
677 678 """
678 679 state = histeditstate(repo)
679 680 try:
680 681 state.wlock = repo.wlock()
681 682 state.lock = repo.lock()
682 683 _histedit(ui, repo, state, *freeargs, **opts)
683 684 finally:
684 685 release(state.lock, state.wlock)
685 686
686 687 def _histedit(ui, repo, state, *freeargs, **opts):
687 688 # TODO only abort if we try and histedit mq patches, not just
688 689 # blanket if mq patches are applied somewhere
689 690 mq = getattr(repo, 'mq', None)
690 691 if mq and mq.applied:
691 692 raise util.Abort(_('source has mq patches applied'))
692 693
693 694 # basic argument incompatibility processing
694 695 outg = opts.get('outgoing')
695 696 cont = opts.get('continue')
696 697 editplan = opts.get('edit_plan')
697 698 abort = opts.get('abort')
698 699 force = opts.get('force')
699 700 rules = opts.get('commands', '')
700 701 revs = opts.get('rev', [])
701 702 goal = 'new' # This invocation goal, in new, continue, abort
702 703 if force and not outg:
703 704 raise util.Abort(_('--force only allowed with --outgoing'))
704 705 if cont:
705 706 if util.any((outg, abort, revs, freeargs, rules, editplan)):
706 707 raise util.Abort(_('no arguments allowed with --continue'))
707 708 goal = 'continue'
708 709 elif abort:
709 710 if util.any((outg, revs, freeargs, rules, editplan)):
710 711 raise util.Abort(_('no arguments allowed with --abort'))
711 712 goal = 'abort'
712 713 elif editplan:
713 714 if util.any((outg, revs, freeargs)):
714 715 raise util.Abort(_('only --commands argument allowed with'
715 716 '--edit-plan'))
716 717 goal = 'edit-plan'
717 718 else:
718 719 if os.path.exists(os.path.join(repo.path, 'histedit-state')):
719 720 raise util.Abort(_('history edit already in progress, try '
720 721 '--continue or --abort'))
721 722 if outg:
722 723 if revs:
723 724 raise util.Abort(_('no revisions allowed with --outgoing'))
724 725 if len(freeargs) > 1:
725 726 raise util.Abort(
726 727 _('only one repo argument allowed with --outgoing'))
727 728 else:
728 729 revs.extend(freeargs)
729 730 if len(revs) == 0:
730 731 histeditdefault = ui.config('histedit', 'defaultrev')
731 732 if histeditdefault:
732 733 revs.append(histeditdefault)
733 734 if len(revs) != 1:
734 735 raise util.Abort(
735 736 _('histedit requires exactly one ancestor revision'))
736 737
737 738
738 739 replacements = []
739 740 keep = opts.get('keep', False)
740 741
741 742 # rebuild state
742 743 if goal == 'continue':
743 744 state.read()
744 745 state = bootstrapcontinue(ui, state, opts)
745 746 elif goal == 'edit-plan':
746 747 state.read()
747 748 if not rules:
748 749 comment = editcomment % (state.parentctx, node.short(state.topmost))
749 750 rules = ruleeditor(repo, ui, state.rules, comment)
750 751 else:
751 752 if rules == '-':
752 753 f = sys.stdin
753 754 else:
754 755 f = open(rules)
755 756 rules = f.read()
756 757 f.close()
757 758 rules = [l for l in (r.strip() for r in rules.splitlines())
758 759 if l and not l.startswith('#')]
759 760 rules = verifyrules(rules, repo, [repo[c] for [_a, c] in state.rules])
760 761 state.rules = rules
761 762 state.write()
762 763 return
763 764 elif goal == 'abort':
764 765 state.read()
765 766 mapping, tmpnodes, leafs, _ntm = processreplacement(state)
766 767 ui.debug('restore wc to old parent %s\n' % node.short(state.topmost))
767 768
768 769 # Recover our old commits if necessary
769 770 if not state.topmost in repo and state.backupfile:
770 771 backupfile = repo.join(state.backupfile)
771 772 f = hg.openpath(ui, backupfile)
772 773 gen = exchange.readbundle(ui, f, backupfile)
773 774 changegroup.addchangegroup(repo, gen, 'histedit',
774 775 'bundle:' + backupfile)
775 776 os.remove(backupfile)
776 777
777 778 # check whether we should update away
778 779 parentnodes = [c.node() for c in repo[None].parents()]
779 780 for n in leafs | set([state.parentctxnode]):
780 781 if n in parentnodes:
781 782 hg.clean(repo, state.topmost)
782 783 break
783 784 else:
784 785 pass
785 786 cleanupnode(ui, repo, 'created', tmpnodes)
786 787 cleanupnode(ui, repo, 'temp', leafs)
787 788 state.clear()
788 789 return
789 790 else:
790 791 cmdutil.checkunfinished(repo)
791 792 cmdutil.bailifchanged(repo)
792 793
793 794 topmost, empty = repo.dirstate.parents()
794 795 if outg:
795 796 if freeargs:
796 797 remote = freeargs[0]
797 798 else:
798 799 remote = None
799 800 root = findoutgoing(ui, repo, remote, force, opts)
800 801 else:
801 802 rr = list(repo.set('roots(%ld)', scmutil.revrange(repo, revs)))
802 803 if len(rr) != 1:
803 804 raise util.Abort(_('The specified revisions must have '
804 805 'exactly one common root'))
805 806 root = rr[0].node()
806 807
807 808 revs = between(repo, root, topmost, keep)
808 809 if not revs:
809 810 raise util.Abort(_('%s is not an ancestor of working directory') %
810 811 node.short(root))
811 812
812 813 ctxs = [repo[r] for r in revs]
813 814 if not rules:
814 815 comment = editcomment % (node.short(root), node.short(topmost))
815 816 rules = ruleeditor(repo, ui, [['pick', c] for c in ctxs], comment)
816 817 else:
817 818 if rules == '-':
818 819 f = sys.stdin
819 820 else:
820 821 f = open(rules)
821 822 rules = f.read()
822 823 f.close()
823 824 rules = [l for l in (r.strip() for r in rules.splitlines())
824 825 if l and not l.startswith('#')]
825 826 rules = verifyrules(rules, repo, ctxs)
826 827
827 828 parentctxnode = repo[root].parents()[0].node()
828 829
829 830 state.parentctxnode = parentctxnode
830 831 state.rules = rules
831 832 state.keep = keep
832 833 state.topmost = topmost
833 834 state.replacements = replacements
834 835
835 836 # Create a backup so we can always abort completely.
836 837 backupfile = None
837 838 if not obsolete.isenabled(repo, obsolete.createmarkersopt):
838 839 backupfile = repair._bundle(repo, [parentctxnode], [topmost], root,
839 840 'histedit')
840 841 state.backupfile = backupfile
841 842
842 843 while state.rules:
843 844 state.write()
844 845 action, ha = state.rules.pop(0)
845 846 ui.debug('histedit: processing %s %s\n' % (action, ha[:12]))
846 847 actobj = actiontable[action].fromrule(state, ha)
847 848 parentctx, replacement_ = actobj.run()
848 849 state.parentctxnode = parentctx.node()
849 850 state.replacements.extend(replacement_)
850 851 state.write()
851 852
852 853 hg.update(repo, state.parentctxnode)
853 854
854 855 mapping, tmpnodes, created, ntm = processreplacement(state)
855 856 if mapping:
856 857 for prec, succs in mapping.iteritems():
857 858 if not succs:
858 859 ui.debug('histedit: %s is dropped\n' % node.short(prec))
859 860 else:
860 861 ui.debug('histedit: %s is replaced by %s\n' % (
861 862 node.short(prec), node.short(succs[0])))
862 863 if len(succs) > 1:
863 864 m = 'histedit: %s'
864 865 for n in succs[1:]:
865 866 ui.debug(m % node.short(n))
866 867
867 868 if not keep:
868 869 if mapping:
869 870 movebookmarks(ui, repo, mapping, state.topmost, ntm)
870 871 # TODO update mq state
871 872 if obsolete.isenabled(repo, obsolete.createmarkersopt):
872 873 markers = []
873 874 # sort by revision number because it sound "right"
874 875 for prec in sorted(mapping, key=repo.changelog.rev):
875 876 succs = mapping[prec]
876 877 markers.append((repo[prec],
877 878 tuple(repo[s] for s in succs)))
878 879 if markers:
879 880 obsolete.createmarkers(repo, markers)
880 881 else:
881 882 cleanupnode(ui, repo, 'replaced', mapping)
882 883
883 884 cleanupnode(ui, repo, 'temp', tmpnodes)
884 885 state.clear()
885 886 if os.path.exists(repo.sjoin('undo')):
886 887 os.unlink(repo.sjoin('undo'))
887 888
888 889 def bootstrapcontinue(ui, state, opts):
889 890 repo = state.repo
890 891 action, currentnode = state.rules.pop(0)
891 892
892 893 actobj = actiontable[action].fromrule(state, currentnode)
893 894
894 895 s = repo.status()
895 896 if s.modified or s.added or s.removed or s.deleted:
896 897 actobj.continuedirty()
897 898 s = repo.status()
898 899 if s.modified or s.added or s.removed or s.deleted:
899 900 raise util.Abort(_("working copy still dirty"))
900 901
901 902 parentctx, replacements = actobj.continueclean()
902 903
903 904 state.parentctxnode = parentctx.node()
904 905 state.replacements.extend(replacements)
905 906
906 907 return state
907 908
908 909 def between(repo, old, new, keep):
909 910 """select and validate the set of revision to edit
910 911
911 912 When keep is false, the specified set can't have children."""
912 913 ctxs = list(repo.set('%n::%n', old, new))
913 914 if ctxs and not keep:
914 915 if (not obsolete.isenabled(repo, obsolete.allowunstableopt) and
915 916 repo.revs('(%ld::) - (%ld)', ctxs, ctxs)):
916 917 raise util.Abort(_('cannot edit history that would orphan nodes'))
917 918 if repo.revs('(%ld) and merge()', ctxs):
918 919 raise util.Abort(_('cannot edit history that contains merges'))
919 920 root = ctxs[0] # list is already sorted by repo.set
920 921 if not root.mutable():
921 922 raise util.Abort(_('cannot edit immutable changeset: %s') % root)
922 923 return [c.node() for c in ctxs]
923 924
924 925 def makedesc(repo, action, rev):
925 926 """build a initial action line for a ctx
926 927
927 928 line are in the form:
928 929
929 930 <action> <hash> <rev> <summary>
930 931 """
931 932 ctx = repo[rev]
932 933 summary = ''
933 934 if ctx.description():
934 935 summary = ctx.description().splitlines()[0]
935 936 line = '%s %s %d %s' % (action, ctx, ctx.rev(), summary)
936 937 # trim to 80 columns so it's not stupidly wide in my editor
937 938 maxlen = repo.ui.configint('histedit', 'linelen', default=80)
938 939 maxlen = max(maxlen, 22) # avoid truncating hash
939 940 return util.ellipsis(line, maxlen)
940 941
941 942 def ruleeditor(repo, ui, rules, editcomment=""):
942 943 """open an editor to edit rules
943 944
944 945 rules are in the format [ [act, ctx], ...] like in state.rules
945 946 """
946 947 rules = '\n'.join([makedesc(repo, act, rev) for [act, rev] in rules])
947 948 rules += '\n\n'
948 949 rules += editcomment
949 950 rules = ui.edit(rules, ui.username())
950 951
951 952 # Save edit rules in .hg/histedit-last-edit.txt in case
952 953 # the user needs to ask for help after something
953 954 # surprising happens.
954 955 f = open(repo.join('histedit-last-edit.txt'), 'w')
955 956 f.write(rules)
956 957 f.close()
957 958
958 959 return rules
959 960
960 961 def verifyrules(rules, repo, ctxs):
961 962 """Verify that there exists exactly one edit rule per given changeset.
962 963
963 964 Will abort if there are to many or too few rules, a malformed rule,
964 965 or a rule on a changeset outside of the user-given range.
965 966 """
966 967 parsed = []
967 968 expected = set(c.hex() for c in ctxs)
968 969 seen = set()
969 970 for r in rules:
970 971 if ' ' not in r:
971 972 raise util.Abort(_('malformed line "%s"') % r)
972 973 action, rest = r.split(' ', 1)
973 974 ha = rest.strip().split(' ', 1)[0]
974 975 try:
975 976 ha = repo[ha].hex()
976 977 except error.RepoError:
977 978 raise util.Abort(_('unknown changeset %s listed') % ha[:12])
978 979 if ha not in expected:
979 980 raise util.Abort(
980 981 _('may not use changesets other than the ones listed'))
981 982 if ha in seen:
982 983 raise util.Abort(_('duplicated command for changeset %s') %
983 984 ha[:12])
984 985 seen.add(ha)
985 986 if action not in actiontable:
986 987 raise util.Abort(_('unknown action "%s"') % action)
987 988 parsed.append([action, ha])
988 989 missing = sorted(expected - seen) # sort to stabilize output
989 990 if missing:
990 991 raise util.Abort(_('missing rules for changeset %s') %
991 992 missing[0][:12],
992 993 hint=_('do you want to use the drop action?'))
993 994 return parsed
994 995
995 996 def processreplacement(state):
996 997 """process the list of replacements to return
997 998
998 999 1) the final mapping between original and created nodes
999 1000 2) the list of temporary node created by histedit
1000 1001 3) the list of new commit created by histedit"""
1001 1002 replacements = state.replacements
1002 1003 allsuccs = set()
1003 1004 replaced = set()
1004 1005 fullmapping = {}
1005 1006 # initialise basic set
1006 1007 # fullmapping record all operation recorded in replacement
1007 1008 for rep in replacements:
1008 1009 allsuccs.update(rep[1])
1009 1010 replaced.add(rep[0])
1010 1011 fullmapping.setdefault(rep[0], set()).update(rep[1])
1011 1012 new = allsuccs - replaced
1012 1013 tmpnodes = allsuccs & replaced
1013 1014 # Reduce content fullmapping into direct relation between original nodes
1014 1015 # and final node created during history edition
1015 1016 # Dropped changeset are replaced by an empty list
1016 1017 toproceed = set(fullmapping)
1017 1018 final = {}
1018 1019 while toproceed:
1019 1020 for x in list(toproceed):
1020 1021 succs = fullmapping[x]
1021 1022 for s in list(succs):
1022 1023 if s in toproceed:
1023 1024 # non final node with unknown closure
1024 1025 # We can't process this now
1025 1026 break
1026 1027 elif s in final:
1027 1028 # non final node, replace with closure
1028 1029 succs.remove(s)
1029 1030 succs.update(final[s])
1030 1031 else:
1031 1032 final[x] = succs
1032 1033 toproceed.remove(x)
1033 1034 # remove tmpnodes from final mapping
1034 1035 for n in tmpnodes:
1035 1036 del final[n]
1036 1037 # we expect all changes involved in final to exist in the repo
1037 1038 # turn `final` into list (topologically sorted)
1038 1039 nm = state.repo.changelog.nodemap
1039 1040 for prec, succs in final.items():
1040 1041 final[prec] = sorted(succs, key=nm.get)
1041 1042
1042 1043 # computed topmost element (necessary for bookmark)
1043 1044 if new:
1044 1045 newtopmost = sorted(new, key=state.repo.changelog.rev)[-1]
1045 1046 elif not final:
1046 1047 # Nothing rewritten at all. we won't need `newtopmost`
1047 1048 # It is the same as `oldtopmost` and `processreplacement` know it
1048 1049 newtopmost = None
1049 1050 else:
1050 1051 # every body died. The newtopmost is the parent of the root.
1051 1052 r = state.repo.changelog.rev
1052 1053 newtopmost = state.repo[sorted(final, key=r)[0]].p1().node()
1053 1054
1054 1055 return final, tmpnodes, new, newtopmost
1055 1056
1056 1057 def movebookmarks(ui, repo, mapping, oldtopmost, newtopmost):
1057 1058 """Move bookmark from old to newly created node"""
1058 1059 if not mapping:
1059 1060 # if nothing got rewritten there is not purpose for this function
1060 1061 return
1061 1062 moves = []
1062 1063 for bk, old in sorted(repo._bookmarks.iteritems()):
1063 1064 if old == oldtopmost:
1064 1065 # special case ensure bookmark stay on tip.
1065 1066 #
1066 1067 # This is arguably a feature and we may only want that for the
1067 1068 # active bookmark. But the behavior is kept compatible with the old
1068 1069 # version for now.
1069 1070 moves.append((bk, newtopmost))
1070 1071 continue
1071 1072 base = old
1072 1073 new = mapping.get(base, None)
1073 1074 if new is None:
1074 1075 continue
1075 1076 while not new:
1076 1077 # base is killed, trying with parent
1077 1078 base = repo[base].p1().node()
1078 1079 new = mapping.get(base, (base,))
1079 1080 # nothing to move
1080 1081 moves.append((bk, new[-1]))
1081 1082 if moves:
1082 1083 marks = repo._bookmarks
1083 1084 for mark, new in moves:
1084 1085 old = marks[mark]
1085 1086 ui.note(_('histedit: moving bookmarks %s from %s to %s\n')
1086 1087 % (mark, node.short(old), node.short(new)))
1087 1088 marks[mark] = new
1088 1089 marks.write()
1089 1090
1090 1091 def cleanupnode(ui, repo, name, nodes):
1091 1092 """strip a group of nodes from the repository
1092 1093
1093 1094 The set of node to strip may contains unknown nodes."""
1094 1095 ui.debug('should strip %s nodes %s\n' %
1095 1096 (name, ', '.join([node.short(n) for n in nodes])))
1096 1097 lock = None
1097 1098 try:
1098 1099 lock = repo.lock()
1099 1100 # Find all node that need to be stripped
1100 1101 # (we hg %lr instead of %ln to silently ignore unknown item
1101 1102 nm = repo.changelog.nodemap
1102 1103 nodes = sorted(n for n in nodes if n in nm)
1103 1104 roots = [c.node() for c in repo.set("roots(%ln)", nodes)]
1104 1105 for c in roots:
1105 1106 # We should process node in reverse order to strip tip most first.
1106 1107 # but this trigger a bug in changegroup hook.
1107 1108 # This would reduce bundle overhead
1108 1109 repair.strip(ui, repo, c)
1109 1110 finally:
1110 1111 release(lock)
1111 1112
1112 1113 def stripwrapper(orig, ui, repo, nodelist, *args, **kwargs):
1113 1114 if isinstance(nodelist, str):
1114 1115 nodelist = [nodelist]
1115 1116 if os.path.exists(os.path.join(repo.path, 'histedit-state')):
1116 1117 state = histeditstate(repo)
1117 1118 state.read()
1118 1119 histedit_nodes = set([repo[rulehash].node() for (action, rulehash)
1119 1120 in state.rules if rulehash in repo])
1120 1121 strip_nodes = set([repo[n].node() for n in nodelist])
1121 1122 common_nodes = histedit_nodes & strip_nodes
1122 1123 if common_nodes:
1123 1124 raise util.Abort(_("histedit in progress, can't strip %s")
1124 1125 % ', '.join(node.short(x) for x in common_nodes))
1125 1126 return orig(ui, repo, nodelist, *args, **kwargs)
1126 1127
1127 1128 extensions.wrapfunction(repair, 'strip', stripwrapper)
1128 1129
1129 1130 def summaryhook(ui, repo):
1130 1131 if not os.path.exists(repo.join('histedit-state')):
1131 1132 return
1132 1133 state = histeditstate(repo)
1133 1134 state.read()
1134 1135 if state.rules:
1135 1136 # i18n: column positioning for "hg summary"
1136 1137 ui.write(_('hist: %s (histedit --continue)\n') %
1137 1138 (ui.label(_('%d remaining'), 'histedit.remaining') %
1138 1139 len(state.rules)))
1139 1140
1140 1141 def extsetup(ui):
1141 1142 cmdutil.summaryhooks.add('histedit', summaryhook)
1142 1143 cmdutil.unfinishedstates.append(
1143 1144 ['histedit-state', False, True, _('histedit in progress'),
1144 1145 _("use 'hg histedit --continue' or 'hg histedit --abort'")])
General Comments 0
You need to be logged in to leave comments. Login now