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