##// END OF EJS Templates
histedit: extract common summary code into method...
Sean Farley -
r29467:4c4232e5 default
parent child Browse files
Show More
@@ -1,1617 +1,1625 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 commit 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 commit 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 Config
147 147 ------
148 148
149 149 Histedit rule lines are truncated to 80 characters by default. You
150 150 can customize this behavior by setting a different length in your
151 151 configuration file::
152 152
153 153 [histedit]
154 154 linelen = 120 # truncate rule lines at 120 characters
155 155
156 156 ``hg histedit`` attempts to automatically choose an appropriate base
157 157 revision to use. To change which base revision is used, define a
158 158 revset in your configuration file::
159 159
160 160 [histedit]
161 161 defaultrev = only(.) & draft()
162 162
163 163 By default each edited revision needs to be present in histedit commands.
164 164 To remove revision you need to use ``drop`` operation. You can configure
165 165 the drop to be implicit for missing commits by adding::
166 166
167 167 [histedit]
168 168 dropmissing = True
169 169
170 170 """
171 171
172 172 from __future__ import absolute_import
173 173
174 174 import errno
175 175 import os
176 176 import sys
177 177
178 178 from mercurial.i18n import _
179 179 from mercurial import (
180 180 bundle2,
181 181 cmdutil,
182 182 context,
183 183 copies,
184 184 destutil,
185 185 discovery,
186 186 error,
187 187 exchange,
188 188 extensions,
189 189 hg,
190 190 lock,
191 191 merge as mergemod,
192 192 node,
193 193 obsolete,
194 194 repair,
195 195 scmutil,
196 196 util,
197 197 )
198 198
199 199 pickle = util.pickle
200 200 release = lock.release
201 201 cmdtable = {}
202 202 command = cmdutil.command(cmdtable)
203 203
204 204 class _constraints(object):
205 205 # aborts if there are multiple rules for one node
206 206 noduplicates = 'noduplicates'
207 207 # abort if the node does belong to edited stack
208 208 forceother = 'forceother'
209 209 # abort if the node doesn't belong to edited stack
210 210 noother = 'noother'
211 211
212 212 @classmethod
213 213 def known(cls):
214 214 return set([v for k, v in cls.__dict__.items() if k[0] != '_'])
215 215
216 216 # Note for extension authors: ONLY specify testedwith = 'internal' for
217 217 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
218 218 # be specifying the version(s) of Mercurial they are tested with, or
219 219 # leave the attribute unspecified.
220 220 testedwith = 'internal'
221 221
222 222 actiontable = {}
223 223 primaryactions = set()
224 224 secondaryactions = set()
225 225 tertiaryactions = set()
226 226 internalactions = set()
227 227
228 228 def geteditcomment(ui, first, last):
229 229 """ construct the editor comment
230 230 The comment includes::
231 231 - an intro
232 232 - sorted primary commands
233 233 - sorted short commands
234 234 - sorted long commands
235 235 - additional hints
236 236
237 237 Commands are only included once.
238 238 """
239 239 intro = _("""Edit history between %s and %s
240 240
241 241 Commits are listed from least to most recent
242 242
243 243 You can reorder changesets by reordering the lines
244 244
245 245 Commands:
246 246 """)
247 247 actions = []
248 248 def addverb(v):
249 249 a = actiontable[v]
250 250 lines = a.message.split("\n")
251 251 if len(a.verbs):
252 252 v = ', '.join(sorted(a.verbs, key=lambda v: len(v)))
253 253 actions.append(" %s = %s" % (v, lines[0]))
254 254 actions.extend([' %s' for l in lines[1:]])
255 255
256 256 for v in (
257 257 sorted(primaryactions) +
258 258 sorted(secondaryactions) +
259 259 sorted(tertiaryactions)
260 260 ):
261 261 addverb(v)
262 262 actions.append('')
263 263
264 264 hints = []
265 265 if ui.configbool('histedit', 'dropmissing'):
266 266 hints.append("Deleting a changeset from the list "
267 267 "will DISCARD it from the edited history!")
268 268
269 269 lines = (intro % (first, last)).split('\n') + actions + hints
270 270
271 271 return ''.join(['# %s\n' % l if l else '#\n' for l in lines])
272 272
273 273 class histeditstate(object):
274 274 def __init__(self, repo, parentctxnode=None, actions=None, keep=None,
275 275 topmost=None, replacements=None, lock=None, wlock=None):
276 276 self.repo = repo
277 277 self.actions = actions
278 278 self.keep = keep
279 279 self.topmost = topmost
280 280 self.parentctxnode = parentctxnode
281 281 self.lock = lock
282 282 self.wlock = wlock
283 283 self.backupfile = None
284 284 if replacements is None:
285 285 self.replacements = []
286 286 else:
287 287 self.replacements = replacements
288 288
289 289 def read(self):
290 290 """Load histedit state from disk and set fields appropriately."""
291 291 try:
292 292 state = self.repo.vfs.read('histedit-state')
293 293 except IOError as err:
294 294 if err.errno != errno.ENOENT:
295 295 raise
296 296 cmdutil.wrongtooltocontinue(self.repo, _('histedit'))
297 297
298 298 if state.startswith('v1\n'):
299 299 data = self._load()
300 300 parentctxnode, rules, keep, topmost, replacements, backupfile = data
301 301 else:
302 302 data = pickle.loads(state)
303 303 parentctxnode, rules, keep, topmost, replacements = data
304 304 backupfile = None
305 305
306 306 self.parentctxnode = parentctxnode
307 307 rules = "\n".join(["%s %s" % (verb, rest) for [verb, rest] in rules])
308 308 actions = parserules(rules, self)
309 309 self.actions = actions
310 310 self.keep = keep
311 311 self.topmost = topmost
312 312 self.replacements = replacements
313 313 self.backupfile = backupfile
314 314
315 315 def write(self):
316 316 fp = self.repo.vfs('histedit-state', 'w')
317 317 fp.write('v1\n')
318 318 fp.write('%s\n' % node.hex(self.parentctxnode))
319 319 fp.write('%s\n' % node.hex(self.topmost))
320 320 fp.write('%s\n' % self.keep)
321 321 fp.write('%d\n' % len(self.actions))
322 322 for action in self.actions:
323 323 fp.write('%s\n' % action.tostate())
324 324 fp.write('%d\n' % len(self.replacements))
325 325 for replacement in self.replacements:
326 326 fp.write('%s%s\n' % (node.hex(replacement[0]), ''.join(node.hex(r)
327 327 for r in replacement[1])))
328 328 backupfile = self.backupfile
329 329 if not backupfile:
330 330 backupfile = ''
331 331 fp.write('%s\n' % backupfile)
332 332 fp.close()
333 333
334 334 def _load(self):
335 335 fp = self.repo.vfs('histedit-state', 'r')
336 336 lines = [l[:-1] for l in fp.readlines()]
337 337
338 338 index = 0
339 339 lines[index] # version number
340 340 index += 1
341 341
342 342 parentctxnode = node.bin(lines[index])
343 343 index += 1
344 344
345 345 topmost = node.bin(lines[index])
346 346 index += 1
347 347
348 348 keep = lines[index] == 'True'
349 349 index += 1
350 350
351 351 # Rules
352 352 rules = []
353 353 rulelen = int(lines[index])
354 354 index += 1
355 355 for i in xrange(rulelen):
356 356 ruleaction = lines[index]
357 357 index += 1
358 358 rule = lines[index]
359 359 index += 1
360 360 rules.append((ruleaction, rule))
361 361
362 362 # Replacements
363 363 replacements = []
364 364 replacementlen = int(lines[index])
365 365 index += 1
366 366 for i in xrange(replacementlen):
367 367 replacement = lines[index]
368 368 original = node.bin(replacement[:40])
369 369 succ = [node.bin(replacement[i:i + 40]) for i in
370 370 range(40, len(replacement), 40)]
371 371 replacements.append((original, succ))
372 372 index += 1
373 373
374 374 backupfile = lines[index]
375 375 index += 1
376 376
377 377 fp.close()
378 378
379 379 return parentctxnode, rules, keep, topmost, replacements, backupfile
380 380
381 381 def clear(self):
382 382 if self.inprogress():
383 383 self.repo.vfs.unlink('histedit-state')
384 384
385 385 def inprogress(self):
386 386 return self.repo.vfs.exists('histedit-state')
387 387
388 388
389 389 class histeditaction(object):
390 390 def __init__(self, state, node):
391 391 self.state = state
392 392 self.repo = state.repo
393 393 self.node = node
394 394
395 395 @classmethod
396 396 def fromrule(cls, state, rule):
397 397 """Parses the given rule, returning an instance of the histeditaction.
398 398 """
399 399 rulehash = rule.strip().split(' ', 1)[0]
400 400 try:
401 401 rev = node.bin(rulehash)
402 402 except TypeError:
403 403 raise error.ParseError("invalid changeset %s" % rulehash)
404 404 return cls(state, rev)
405 405
406 406 def verify(self, prev):
407 407 """ Verifies semantic correctness of the rule"""
408 408 repo = self.repo
409 409 ha = node.hex(self.node)
410 410 try:
411 411 self.node = repo[ha].node()
412 412 except error.RepoError:
413 413 raise error.ParseError(_('unknown changeset %s listed')
414 414 % ha[:12])
415 415
416 416 def torule(self):
417 417 """build a histedit rule line for an action
418 418
419 419 by default lines are in the form:
420 420 <hash> <rev> <summary>
421 421 """
422 422 ctx = self.repo[self.node]
423 423 summary = ''
424 424 if ctx.description():
425 425 summary = ctx.description().splitlines()[0]
426 426 line = '%s %s %d %s' % (self.verb, ctx, ctx.rev(), summary)
427 427 # trim to 75 columns by default so it's not stupidly wide in my editor
428 428 # (the 5 more are left for verb)
429 429 maxlen = self.repo.ui.configint('histedit', 'linelen', default=80)
430 430 maxlen = max(maxlen, 22) # avoid truncating hash
431 431 return util.ellipsis(line, maxlen)
432 432
433 433 def tostate(self):
434 434 """Print an action in format used by histedit state files
435 435 (the first line is a verb, the remainder is the second)
436 436 """
437 437 return "%s\n%s" % (self.verb, node.hex(self.node))
438 438
439 439 def constraints(self):
440 440 """Return a set of constrains that this action should be verified for
441 441 """
442 442 return set([_constraints.noduplicates, _constraints.noother])
443 443
444 444 def nodetoverify(self):
445 445 """Returns a node associated with the action that will be used for
446 446 verification purposes.
447 447
448 448 If the action doesn't correspond to node it should return None
449 449 """
450 450 return self.node
451 451
452 452 def run(self):
453 453 """Runs the action. The default behavior is simply apply the action's
454 454 rulectx onto the current parentctx."""
455 455 self.applychange()
456 456 self.continuedirty()
457 457 return self.continueclean()
458 458
459 459 def applychange(self):
460 460 """Applies the changes from this action's rulectx onto the current
461 461 parentctx, but does not commit them."""
462 462 repo = self.repo
463 463 rulectx = repo[self.node]
464 464 repo.ui.pushbuffer(error=True, labeled=True)
465 465 hg.update(repo, self.state.parentctxnode, quietempty=True)
466 466 stats = applychanges(repo.ui, repo, rulectx, {})
467 467 if stats and stats[3] > 0:
468 468 buf = repo.ui.popbuffer()
469 469 repo.ui.write(*buf)
470 470 raise error.InterventionRequired(
471 471 _('Fix up the change (%s %s)') %
472 472 (self.verb, node.short(self.node)),
473 473 hint=_('hg histedit --continue to resume'))
474 474 else:
475 475 repo.ui.popbuffer()
476 476
477 477 def continuedirty(self):
478 478 """Continues the action when changes have been applied to the working
479 479 copy. The default behavior is to commit the dirty changes."""
480 480 repo = self.repo
481 481 rulectx = repo[self.node]
482 482
483 483 editor = self.commiteditor()
484 484 commit = commitfuncfor(repo, rulectx)
485 485
486 486 commit(text=rulectx.description(), user=rulectx.user(),
487 487 date=rulectx.date(), extra=rulectx.extra(), editor=editor)
488 488
489 489 def commiteditor(self):
490 490 """The editor to be used to edit the commit message."""
491 491 return False
492 492
493 493 def continueclean(self):
494 494 """Continues the action when the working copy is clean. The default
495 495 behavior is to accept the current commit as the new version of the
496 496 rulectx."""
497 497 ctx = self.repo['.']
498 498 if ctx.node() == self.state.parentctxnode:
499 499 self.repo.ui.warn(_('%s: skipping changeset (no changes)\n') %
500 500 node.short(self.node))
501 501 return ctx, [(self.node, tuple())]
502 502 if ctx.node() == self.node:
503 503 # Nothing changed
504 504 return ctx, []
505 505 return ctx, [(self.node, (ctx.node(),))]
506 506
507 507 def commitfuncfor(repo, src):
508 508 """Build a commit function for the replacement of <src>
509 509
510 510 This function ensure we apply the same treatment to all changesets.
511 511
512 512 - Add a 'histedit_source' entry in extra.
513 513
514 514 Note that fold has its own separated logic because its handling is a bit
515 515 different and not easily factored out of the fold method.
516 516 """
517 517 phasemin = src.phase()
518 518 def commitfunc(**kwargs):
519 519 phasebackup = repo.ui.backupconfig('phases', 'new-commit')
520 520 try:
521 521 repo.ui.setconfig('phases', 'new-commit', phasemin,
522 522 'histedit')
523 523 extra = kwargs.get('extra', {}).copy()
524 524 extra['histedit_source'] = src.hex()
525 525 kwargs['extra'] = extra
526 526 return repo.commit(**kwargs)
527 527 finally:
528 528 repo.ui.restoreconfig(phasebackup)
529 529 return commitfunc
530 530
531 531 def applychanges(ui, repo, ctx, opts):
532 532 """Merge changeset from ctx (only) in the current working directory"""
533 533 wcpar = repo.dirstate.parents()[0]
534 534 if ctx.p1().node() == wcpar:
535 535 # edits are "in place" we do not need to make any merge,
536 536 # just applies changes on parent for editing
537 537 cmdutil.revert(ui, repo, ctx, (wcpar, node.nullid), all=True)
538 538 stats = None
539 539 else:
540 540 try:
541 541 # ui.forcemerge is an internal variable, do not document
542 542 repo.ui.setconfig('ui', 'forcemerge', opts.get('tool', ''),
543 543 'histedit')
544 544 stats = mergemod.graft(repo, ctx, ctx.p1(), ['local', 'histedit'])
545 545 finally:
546 546 repo.ui.setconfig('ui', 'forcemerge', '', 'histedit')
547 547 return stats
548 548
549 549 def collapse(repo, first, last, commitopts, skipprompt=False):
550 550 """collapse the set of revisions from first to last as new one.
551 551
552 552 Expected commit options are:
553 553 - message
554 554 - date
555 555 - username
556 556 Commit message is edited in all cases.
557 557
558 558 This function works in memory."""
559 559 ctxs = list(repo.set('%d::%d', first, last))
560 560 if not ctxs:
561 561 return None
562 562 for c in ctxs:
563 563 if not c.mutable():
564 564 raise error.ParseError(
565 565 _("cannot fold into public change %s") % node.short(c.node()))
566 566 base = first.parents()[0]
567 567
568 568 # commit a new version of the old changeset, including the update
569 569 # collect all files which might be affected
570 570 files = set()
571 571 for ctx in ctxs:
572 572 files.update(ctx.files())
573 573
574 574 # Recompute copies (avoid recording a -> b -> a)
575 575 copied = copies.pathcopies(base, last)
576 576
577 577 # prune files which were reverted by the updates
578 578 def samefile(f):
579 579 if f in last.manifest():
580 580 a = last.filectx(f)
581 581 if f in base.manifest():
582 582 b = base.filectx(f)
583 583 return (a.data() == b.data()
584 584 and a.flags() == b.flags())
585 585 else:
586 586 return False
587 587 else:
588 588 return f not in base.manifest()
589 589 files = [f for f in files if not samefile(f)]
590 590 # commit version of these files as defined by head
591 591 headmf = last.manifest()
592 592 def filectxfn(repo, ctx, path):
593 593 if path in headmf:
594 594 fctx = last[path]
595 595 flags = fctx.flags()
596 596 mctx = context.memfilectx(repo,
597 597 fctx.path(), fctx.data(),
598 598 islink='l' in flags,
599 599 isexec='x' in flags,
600 600 copied=copied.get(path))
601 601 return mctx
602 602 return None
603 603
604 604 if commitopts.get('message'):
605 605 message = commitopts['message']
606 606 else:
607 607 message = first.description()
608 608 user = commitopts.get('user')
609 609 date = commitopts.get('date')
610 610 extra = commitopts.get('extra')
611 611
612 612 parents = (first.p1().node(), first.p2().node())
613 613 editor = None
614 614 if not skipprompt:
615 615 editor = cmdutil.getcommiteditor(edit=True, editform='histedit.fold')
616 616 new = context.memctx(repo,
617 617 parents=parents,
618 618 text=message,
619 619 files=files,
620 620 filectxfn=filectxfn,
621 621 user=user,
622 622 date=date,
623 623 extra=extra,
624 624 editor=editor)
625 625 return repo.commitctx(new)
626 626
627 627 def _isdirtywc(repo):
628 628 return repo[None].dirty(missing=True)
629 629
630 630 def abortdirty():
631 631 raise error.Abort(_('working copy has pending changes'),
632 632 hint=_('amend, commit, or revert them and run histedit '
633 633 '--continue, or abort with histedit --abort'))
634 634
635 635 def action(verbs, message, priority=False, internal=False):
636 636 def wrap(cls):
637 637 assert not priority or not internal
638 638 verb = verbs[0]
639 639 if priority:
640 640 primaryactions.add(verb)
641 641 elif internal:
642 642 internalactions.add(verb)
643 643 elif len(verbs) > 1:
644 644 secondaryactions.add(verb)
645 645 else:
646 646 tertiaryactions.add(verb)
647 647
648 648 cls.verb = verb
649 649 cls.verbs = verbs
650 650 cls.message = message
651 651 for verb in verbs:
652 652 actiontable[verb] = cls
653 653 return cls
654 654 return wrap
655 655
656 656 @action(['pick', 'p'],
657 657 _('use commit'),
658 658 priority=True)
659 659 class pick(histeditaction):
660 660 def run(self):
661 661 rulectx = self.repo[self.node]
662 662 if rulectx.parents()[0].node() == self.state.parentctxnode:
663 663 self.repo.ui.debug('node %s unchanged\n' % node.short(self.node))
664 664 return rulectx, []
665 665
666 666 return super(pick, self).run()
667 667
668 668 @action(['edit', 'e'],
669 669 _('use commit, but stop for amending'),
670 670 priority=True)
671 671 class edit(histeditaction):
672 672 def run(self):
673 673 repo = self.repo
674 674 rulectx = repo[self.node]
675 675 hg.update(repo, self.state.parentctxnode, quietempty=True)
676 676 applychanges(repo.ui, repo, rulectx, {})
677 677 raise error.InterventionRequired(
678 678 _('Editing (%s), you may commit or record as needed now.')
679 679 % node.short(self.node),
680 680 hint=_('hg histedit --continue to resume'))
681 681
682 682 def commiteditor(self):
683 683 return cmdutil.getcommiteditor(edit=True, editform='histedit.edit')
684 684
685 685 @action(['fold', 'f'],
686 686 _('use commit, but combine it with the one above'))
687 687 class fold(histeditaction):
688 688 def verify(self, prev):
689 689 """ Verifies semantic correctness of the fold rule"""
690 690 super(fold, self).verify(prev)
691 691 repo = self.repo
692 692 if not prev:
693 693 c = repo[self.node].parents()[0]
694 694 elif not prev.verb in ('pick', 'base'):
695 695 return
696 696 else:
697 697 c = repo[prev.node]
698 698 if not c.mutable():
699 699 raise error.ParseError(
700 700 _("cannot fold into public change %s") % node.short(c.node()))
701 701
702 702
703 703 def continuedirty(self):
704 704 repo = self.repo
705 705 rulectx = repo[self.node]
706 706
707 707 commit = commitfuncfor(repo, rulectx)
708 708 commit(text='fold-temp-revision %s' % node.short(self.node),
709 709 user=rulectx.user(), date=rulectx.date(),
710 710 extra=rulectx.extra())
711 711
712 712 def continueclean(self):
713 713 repo = self.repo
714 714 ctx = repo['.']
715 715 rulectx = repo[self.node]
716 716 parentctxnode = self.state.parentctxnode
717 717 if ctx.node() == parentctxnode:
718 718 repo.ui.warn(_('%s: empty changeset\n') %
719 719 node.short(self.node))
720 720 return ctx, [(self.node, (parentctxnode,))]
721 721
722 722 parentctx = repo[parentctxnode]
723 723 newcommits = set(c.node() for c in repo.set('(%d::. - %d)', parentctx,
724 724 parentctx))
725 725 if not newcommits:
726 726 repo.ui.warn(_('%s: cannot fold - working copy is not a '
727 727 'descendant of previous commit %s\n') %
728 728 (node.short(self.node), node.short(parentctxnode)))
729 729 return ctx, [(self.node, (ctx.node(),))]
730 730
731 731 middlecommits = newcommits.copy()
732 732 middlecommits.discard(ctx.node())
733 733
734 734 return self.finishfold(repo.ui, repo, parentctx, rulectx, ctx.node(),
735 735 middlecommits)
736 736
737 737 def skipprompt(self):
738 738 """Returns true if the rule should skip the message editor.
739 739
740 740 For example, 'fold' wants to show an editor, but 'rollup'
741 741 doesn't want to.
742 742 """
743 743 return False
744 744
745 745 def mergedescs(self):
746 746 """Returns true if the rule should merge messages of multiple changes.
747 747
748 748 This exists mainly so that 'rollup' rules can be a subclass of
749 749 'fold'.
750 750 """
751 751 return True
752 752
753 753 def finishfold(self, ui, repo, ctx, oldctx, newnode, internalchanges):
754 754 parent = ctx.parents()[0].node()
755 755 repo.ui.pushbuffer()
756 756 hg.update(repo, parent)
757 757 repo.ui.popbuffer()
758 758 ### prepare new commit data
759 759 commitopts = {}
760 760 commitopts['user'] = ctx.user()
761 761 # commit message
762 762 if not self.mergedescs():
763 763 newmessage = ctx.description()
764 764 else:
765 765 newmessage = '\n***\n'.join(
766 766 [ctx.description()] +
767 767 [repo[r].description() for r in internalchanges] +
768 768 [oldctx.description()]) + '\n'
769 769 commitopts['message'] = newmessage
770 770 # date
771 771 commitopts['date'] = max(ctx.date(), oldctx.date())
772 772 extra = ctx.extra().copy()
773 773 # histedit_source
774 774 # note: ctx is likely a temporary commit but that the best we can do
775 775 # here. This is sufficient to solve issue3681 anyway.
776 776 extra['histedit_source'] = '%s,%s' % (ctx.hex(), oldctx.hex())
777 777 commitopts['extra'] = extra
778 778 phasebackup = repo.ui.backupconfig('phases', 'new-commit')
779 779 try:
780 780 phasemin = max(ctx.phase(), oldctx.phase())
781 781 repo.ui.setconfig('phases', 'new-commit', phasemin, 'histedit')
782 782 n = collapse(repo, ctx, repo[newnode], commitopts,
783 783 skipprompt=self.skipprompt())
784 784 finally:
785 785 repo.ui.restoreconfig(phasebackup)
786 786 if n is None:
787 787 return ctx, []
788 788 repo.ui.pushbuffer()
789 789 hg.update(repo, n)
790 790 repo.ui.popbuffer()
791 791 replacements = [(oldctx.node(), (newnode,)),
792 792 (ctx.node(), (n,)),
793 793 (newnode, (n,)),
794 794 ]
795 795 for ich in internalchanges:
796 796 replacements.append((ich, (n,)))
797 797 return repo[n], replacements
798 798
799 799 class base(histeditaction):
800 800 def constraints(self):
801 801 return set([_constraints.forceother])
802 802
803 803 def run(self):
804 804 if self.repo['.'].node() != self.node:
805 805 mergemod.update(self.repo, self.node, False, True)
806 806 # branchmerge, force)
807 807 return self.continueclean()
808 808
809 809 def continuedirty(self):
810 810 abortdirty()
811 811
812 812 def continueclean(self):
813 813 basectx = self.repo['.']
814 814 return basectx, []
815 815
816 816 @action(['_multifold'],
817 817 _(
818 818 """fold subclass used for when multiple folds happen in a row
819 819
820 820 We only want to fire the editor for the folded message once when
821 821 (say) four changes are folded down into a single change. This is
822 822 similar to rollup, but we should preserve both messages so that
823 823 when the last fold operation runs we can show the user all the
824 824 commit messages in their editor.
825 825 """),
826 826 internal=True)
827 827 class _multifold(fold):
828 828 def skipprompt(self):
829 829 return True
830 830
831 831 @action(["roll", "r"],
832 832 _("like fold, but discard this commit's description"))
833 833 class rollup(fold):
834 834 def mergedescs(self):
835 835 return False
836 836
837 837 def skipprompt(self):
838 838 return True
839 839
840 840 @action(["drop", "d"],
841 841 _('remove commit from history'))
842 842 class drop(histeditaction):
843 843 def run(self):
844 844 parentctx = self.repo[self.state.parentctxnode]
845 845 return parentctx, [(self.node, tuple())]
846 846
847 847 @action(["mess", "m"],
848 848 _('edit commit message without changing commit content'),
849 849 priority=True)
850 850 class message(histeditaction):
851 851 def commiteditor(self):
852 852 return cmdutil.getcommiteditor(edit=True, editform='histedit.mess')
853 853
854 854 def findoutgoing(ui, repo, remote=None, force=False, opts=None):
855 855 """utility function to find the first outgoing changeset
856 856
857 857 Used by initialization code"""
858 858 if opts is None:
859 859 opts = {}
860 860 dest = ui.expandpath(remote or 'default-push', remote or 'default')
861 861 dest, revs = hg.parseurl(dest, None)[:2]
862 862 ui.status(_('comparing with %s\n') % util.hidepassword(dest))
863 863
864 864 revs, checkout = hg.addbranchrevs(repo, repo, revs, None)
865 865 other = hg.peer(repo, opts, dest)
866 866
867 867 if revs:
868 868 revs = [repo.lookup(rev) for rev in revs]
869 869
870 870 outgoing = discovery.findcommonoutgoing(repo, other, revs, force=force)
871 871 if not outgoing.missing:
872 872 raise error.Abort(_('no outgoing ancestors'))
873 873 roots = list(repo.revs("roots(%ln)", outgoing.missing))
874 874 if 1 < len(roots):
875 875 msg = _('there are ambiguous outgoing revisions')
876 876 hint = _('see "hg help histedit" for more detail')
877 877 raise error.Abort(msg, hint=hint)
878 878 return repo.lookup(roots[0])
879 879
880 880
881 881 @command('histedit',
882 882 [('', 'commands', '',
883 883 _('read history edits from the specified file'), _('FILE')),
884 884 ('c', 'continue', False, _('continue an edit already in progress')),
885 885 ('', 'edit-plan', False, _('edit remaining actions list')),
886 886 ('k', 'keep', False,
887 887 _("don't strip old nodes after edit is complete")),
888 888 ('', 'abort', False, _('abort an edit in progress')),
889 889 ('o', 'outgoing', False, _('changesets not found in destination')),
890 890 ('f', 'force', False,
891 891 _('force outgoing even for unrelated repositories')),
892 892 ('r', 'rev', [], _('first revision to be edited'), _('REV'))],
893 893 _("[OPTIONS] ([ANCESTOR] | --outgoing [URL])"))
894 894 def histedit(ui, repo, *freeargs, **opts):
895 895 """interactively edit changeset history
896 896
897 897 This command lets you edit a linear series of changesets (up to
898 898 and including the working directory, which should be clean).
899 899 You can:
900 900
901 901 - `pick` to [re]order a changeset
902 902
903 903 - `drop` to omit changeset
904 904
905 905 - `mess` to reword the changeset commit message
906 906
907 907 - `fold` to combine it with the preceding changeset
908 908
909 909 - `roll` like fold, but discarding this commit's description
910 910
911 911 - `edit` to edit this changeset
912 912
913 913 There are a number of ways to select the root changeset:
914 914
915 915 - Specify ANCESTOR directly
916 916
917 917 - Use --outgoing -- it will be the first linear changeset not
918 918 included in destination. (See :hg:`help config.paths.default-push`)
919 919
920 920 - Otherwise, the value from the "histedit.defaultrev" config option
921 921 is used as a revset to select the base revision when ANCESTOR is not
922 922 specified. The first revision returned by the revset is used. By
923 923 default, this selects the editable history that is unique to the
924 924 ancestry of the working directory.
925 925
926 926 .. container:: verbose
927 927
928 928 If you use --outgoing, this command will abort if there are ambiguous
929 929 outgoing revisions. For example, if there are multiple branches
930 930 containing outgoing revisions.
931 931
932 932 Use "min(outgoing() and ::.)" or similar revset specification
933 933 instead of --outgoing to specify edit target revision exactly in
934 934 such ambiguous situation. See :hg:`help revsets` for detail about
935 935 selecting revisions.
936 936
937 937 .. container:: verbose
938 938
939 939 Examples:
940 940
941 941 - A number of changes have been made.
942 942 Revision 3 is no longer needed.
943 943
944 944 Start history editing from revision 3::
945 945
946 946 hg histedit -r 3
947 947
948 948 An editor opens, containing the list of revisions,
949 949 with specific actions specified::
950 950
951 951 pick 5339bf82f0ca 3 Zworgle the foobar
952 952 pick 8ef592ce7cc4 4 Bedazzle the zerlog
953 953 pick 0a9639fcda9d 5 Morgify the cromulancy
954 954
955 955 Additional information about the possible actions
956 956 to take appears below the list of revisions.
957 957
958 958 To remove revision 3 from the history,
959 959 its action (at the beginning of the relevant line)
960 960 is changed to 'drop'::
961 961
962 962 drop 5339bf82f0ca 3 Zworgle the foobar
963 963 pick 8ef592ce7cc4 4 Bedazzle the zerlog
964 964 pick 0a9639fcda9d 5 Morgify the cromulancy
965 965
966 966 - A number of changes have been made.
967 967 Revision 2 and 4 need to be swapped.
968 968
969 969 Start history editing from revision 2::
970 970
971 971 hg histedit -r 2
972 972
973 973 An editor opens, containing the list of revisions,
974 974 with specific actions specified::
975 975
976 976 pick 252a1af424ad 2 Blorb a morgwazzle
977 977 pick 5339bf82f0ca 3 Zworgle the foobar
978 978 pick 8ef592ce7cc4 4 Bedazzle the zerlog
979 979
980 980 To swap revision 2 and 4, its lines are swapped
981 981 in the editor::
982 982
983 983 pick 8ef592ce7cc4 4 Bedazzle the zerlog
984 984 pick 5339bf82f0ca 3 Zworgle the foobar
985 985 pick 252a1af424ad 2 Blorb a morgwazzle
986 986
987 987 Returns 0 on success, 1 if user intervention is required (not only
988 988 for intentional "edit" command, but also for resolving unexpected
989 989 conflicts).
990 990 """
991 991 state = histeditstate(repo)
992 992 try:
993 993 state.wlock = repo.wlock()
994 994 state.lock = repo.lock()
995 995 _histedit(ui, repo, state, *freeargs, **opts)
996 996 finally:
997 997 release(state.lock, state.wlock)
998 998
999 999 goalcontinue = 'continue'
1000 1000 goalabort = 'abort'
1001 1001 goaleditplan = 'edit-plan'
1002 1002 goalnew = 'new'
1003 1003
1004 1004 def _getgoal(opts):
1005 1005 if opts.get('continue'):
1006 1006 return goalcontinue
1007 1007 if opts.get('abort'):
1008 1008 return goalabort
1009 1009 if opts.get('edit_plan'):
1010 1010 return goaleditplan
1011 1011 return goalnew
1012 1012
1013 1013 def _readfile(path):
1014 1014 if path == '-':
1015 1015 return sys.stdin.read()
1016 1016 else:
1017 1017 with open(path, 'rb') as f:
1018 1018 return f.read()
1019 1019
1020 1020 def _validateargs(ui, repo, state, freeargs, opts, goal, rules, revs):
1021 1021 # TODO only abort if we try to histedit mq patches, not just
1022 1022 # blanket if mq patches are applied somewhere
1023 1023 mq = getattr(repo, 'mq', None)
1024 1024 if mq and mq.applied:
1025 1025 raise error.Abort(_('source has mq patches applied'))
1026 1026
1027 1027 # basic argument incompatibility processing
1028 1028 outg = opts.get('outgoing')
1029 1029 editplan = opts.get('edit_plan')
1030 1030 abort = opts.get('abort')
1031 1031 force = opts.get('force')
1032 1032 if force and not outg:
1033 1033 raise error.Abort(_('--force only allowed with --outgoing'))
1034 1034 if goal == 'continue':
1035 1035 if any((outg, abort, revs, freeargs, rules, editplan)):
1036 1036 raise error.Abort(_('no arguments allowed with --continue'))
1037 1037 elif goal == 'abort':
1038 1038 if any((outg, revs, freeargs, rules, editplan)):
1039 1039 raise error.Abort(_('no arguments allowed with --abort'))
1040 1040 elif goal == 'edit-plan':
1041 1041 if any((outg, revs, freeargs)):
1042 1042 raise error.Abort(_('only --commands argument allowed with '
1043 1043 '--edit-plan'))
1044 1044 else:
1045 1045 if os.path.exists(os.path.join(repo.path, 'histedit-state')):
1046 1046 raise error.Abort(_('history edit already in progress, try '
1047 1047 '--continue or --abort'))
1048 1048 if outg:
1049 1049 if revs:
1050 1050 raise error.Abort(_('no revisions allowed with --outgoing'))
1051 1051 if len(freeargs) > 1:
1052 1052 raise error.Abort(
1053 1053 _('only one repo argument allowed with --outgoing'))
1054 1054 else:
1055 1055 revs.extend(freeargs)
1056 1056 if len(revs) == 0:
1057 1057 defaultrev = destutil.desthistedit(ui, repo)
1058 1058 if defaultrev is not None:
1059 1059 revs.append(defaultrev)
1060 1060
1061 1061 if len(revs) != 1:
1062 1062 raise error.Abort(
1063 1063 _('histedit requires exactly one ancestor revision'))
1064 1064
1065 1065 def _histedit(ui, repo, state, *freeargs, **opts):
1066 1066 goal = _getgoal(opts)
1067 1067 revs = opts.get('rev', [])
1068 1068 rules = opts.get('commands', '')
1069 1069 state.keep = opts.get('keep', False)
1070 1070
1071 1071 _validateargs(ui, repo, state, freeargs, opts, goal, rules, revs)
1072 1072
1073 1073 # rebuild state
1074 1074 if goal == goalcontinue:
1075 1075 state.read()
1076 1076 state = bootstrapcontinue(ui, state, opts)
1077 1077 elif goal == goaleditplan:
1078 1078 _edithisteditplan(ui, repo, state, rules)
1079 1079 return
1080 1080 elif goal == goalabort:
1081 1081 _aborthistedit(ui, repo, state)
1082 1082 return
1083 1083 else:
1084 1084 # goal == goalnew
1085 1085 _newhistedit(ui, repo, state, revs, freeargs, opts)
1086 1086
1087 1087 _continuehistedit(ui, repo, state)
1088 1088 _finishhistedit(ui, repo, state)
1089 1089
1090 1090 def _continuehistedit(ui, repo, state):
1091 1091 """This function runs after either:
1092 1092 - bootstrapcontinue (if the goal is 'continue')
1093 1093 - _newhistedit (if the goal is 'new')
1094 1094 """
1095 1095 # preprocess rules so that we can hide inner folds from the user
1096 1096 # and only show one editor
1097 1097 actions = state.actions[:]
1098 1098 for idx, (action, nextact) in enumerate(
1099 1099 zip(actions, actions[1:] + [None])):
1100 1100 if action.verb == 'fold' and nextact and nextact.verb == 'fold':
1101 1101 state.actions[idx].__class__ = _multifold
1102 1102
1103 1103 total = len(state.actions)
1104 1104 pos = 0
1105 1105 while state.actions:
1106 1106 state.write()
1107 1107 actobj = state.actions.pop(0)
1108 1108 pos += 1
1109 1109 ui.progress(_("editing"), pos, actobj.torule(),
1110 1110 _('changes'), total)
1111 1111 ui.debug('histedit: processing %s %s\n' % (actobj.verb,\
1112 1112 actobj.torule()))
1113 1113 parentctx, replacement_ = actobj.run()
1114 1114 state.parentctxnode = parentctx.node()
1115 1115 state.replacements.extend(replacement_)
1116 1116 state.write()
1117 1117 ui.progress(_("editing"), None)
1118 1118
1119 1119 def _finishhistedit(ui, repo, state):
1120 1120 """This action runs when histedit is finishing its session"""
1121 1121 repo.ui.pushbuffer()
1122 1122 hg.update(repo, state.parentctxnode, quietempty=True)
1123 1123 repo.ui.popbuffer()
1124 1124
1125 1125 mapping, tmpnodes, created, ntm = processreplacement(state)
1126 1126 if mapping:
1127 1127 for prec, succs in mapping.iteritems():
1128 1128 if not succs:
1129 1129 ui.debug('histedit: %s is dropped\n' % node.short(prec))
1130 1130 else:
1131 1131 ui.debug('histedit: %s is replaced by %s\n' % (
1132 1132 node.short(prec), node.short(succs[0])))
1133 1133 if len(succs) > 1:
1134 1134 m = 'histedit: %s'
1135 1135 for n in succs[1:]:
1136 1136 ui.debug(m % node.short(n))
1137 1137
1138 1138 supportsmarkers = obsolete.isenabled(repo, obsolete.createmarkersopt)
1139 1139 if supportsmarkers:
1140 1140 # Only create markers if the temp nodes weren't already removed.
1141 1141 obsolete.createmarkers(repo, ((repo[t],()) for t in sorted(tmpnodes)
1142 1142 if t in repo))
1143 1143 else:
1144 1144 cleanupnode(ui, repo, 'temp', tmpnodes)
1145 1145
1146 1146 if not state.keep:
1147 1147 if mapping:
1148 1148 movebookmarks(ui, repo, mapping, state.topmost, ntm)
1149 1149 # TODO update mq state
1150 1150 if supportsmarkers:
1151 1151 markers = []
1152 1152 # sort by revision number because it sound "right"
1153 1153 for prec in sorted(mapping, key=repo.changelog.rev):
1154 1154 succs = mapping[prec]
1155 1155 markers.append((repo[prec],
1156 1156 tuple(repo[s] for s in succs)))
1157 1157 if markers:
1158 1158 obsolete.createmarkers(repo, markers)
1159 1159 else:
1160 1160 cleanupnode(ui, repo, 'replaced', mapping)
1161 1161
1162 1162 state.clear()
1163 1163 if os.path.exists(repo.sjoin('undo')):
1164 1164 os.unlink(repo.sjoin('undo'))
1165 1165 if repo.vfs.exists('histedit-last-edit.txt'):
1166 1166 repo.vfs.unlink('histedit-last-edit.txt')
1167 1167
1168 1168 def _aborthistedit(ui, repo, state):
1169 1169 try:
1170 1170 state.read()
1171 1171 __, leafs, tmpnodes, __ = processreplacement(state)
1172 1172 ui.debug('restore wc to old parent %s\n'
1173 1173 % node.short(state.topmost))
1174 1174
1175 1175 # Recover our old commits if necessary
1176 1176 if not state.topmost in repo and state.backupfile:
1177 1177 backupfile = repo.join(state.backupfile)
1178 1178 f = hg.openpath(ui, backupfile)
1179 1179 gen = exchange.readbundle(ui, f, backupfile)
1180 1180 with repo.transaction('histedit.abort') as tr:
1181 1181 if not isinstance(gen, bundle2.unbundle20):
1182 1182 gen.apply(repo, 'histedit', 'bundle:' + backupfile)
1183 1183 if isinstance(gen, bundle2.unbundle20):
1184 1184 bundle2.applybundle(repo, gen, tr,
1185 1185 source='histedit',
1186 1186 url='bundle:' + backupfile)
1187 1187
1188 1188 os.remove(backupfile)
1189 1189
1190 1190 # check whether we should update away
1191 1191 if repo.unfiltered().revs('parents() and (%n or %ln::)',
1192 1192 state.parentctxnode, leafs | tmpnodes):
1193 1193 hg.clean(repo, state.topmost, show_stats=True, quietempty=True)
1194 1194 cleanupnode(ui, repo, 'created', tmpnodes)
1195 1195 cleanupnode(ui, repo, 'temp', leafs)
1196 1196 except Exception:
1197 1197 if state.inprogress():
1198 1198 ui.warn(_('warning: encountered an exception during histedit '
1199 1199 '--abort; the repository may not have been completely '
1200 1200 'cleaned up\n'))
1201 1201 raise
1202 1202 finally:
1203 1203 state.clear()
1204 1204
1205 1205 def _edithisteditplan(ui, repo, state, rules):
1206 1206 state.read()
1207 1207 if not rules:
1208 1208 comment = geteditcomment(ui,
1209 1209 node.short(state.parentctxnode),
1210 1210 node.short(state.topmost))
1211 1211 rules = ruleeditor(repo, ui, state.actions, comment)
1212 1212 else:
1213 1213 rules = _readfile(rules)
1214 1214 actions = parserules(rules, state)
1215 1215 ctxs = [repo[act.nodetoverify()] \
1216 1216 for act in state.actions if act.nodetoverify()]
1217 1217 warnverifyactions(ui, repo, actions, state, ctxs)
1218 1218 state.actions = actions
1219 1219 state.write()
1220 1220
1221 1221 def _newhistedit(ui, repo, state, revs, freeargs, opts):
1222 1222 outg = opts.get('outgoing')
1223 1223 rules = opts.get('commands', '')
1224 1224 force = opts.get('force')
1225 1225
1226 1226 cmdutil.checkunfinished(repo)
1227 1227 cmdutil.bailifchanged(repo)
1228 1228
1229 1229 topmost, empty = repo.dirstate.parents()
1230 1230 if outg:
1231 1231 if freeargs:
1232 1232 remote = freeargs[0]
1233 1233 else:
1234 1234 remote = None
1235 1235 root = findoutgoing(ui, repo, remote, force, opts)
1236 1236 else:
1237 1237 rr = list(repo.set('roots(%ld)', scmutil.revrange(repo, revs)))
1238 1238 if len(rr) != 1:
1239 1239 raise error.Abort(_('The specified revisions must have '
1240 1240 'exactly one common root'))
1241 1241 root = rr[0].node()
1242 1242
1243 1243 revs = between(repo, root, topmost, state.keep)
1244 1244 if not revs:
1245 1245 raise error.Abort(_('%s is not an ancestor of working directory') %
1246 1246 node.short(root))
1247 1247
1248 1248 ctxs = [repo[r] for r in revs]
1249 1249 if not rules:
1250 1250 comment = geteditcomment(ui, node.short(root), node.short(topmost))
1251 1251 actions = [pick(state, r) for r in revs]
1252 1252 rules = ruleeditor(repo, ui, actions, comment)
1253 1253 else:
1254 1254 rules = _readfile(rules)
1255 1255 actions = parserules(rules, state)
1256 1256 warnverifyactions(ui, repo, actions, state, ctxs)
1257 1257
1258 1258 parentctxnode = repo[root].parents()[0].node()
1259 1259
1260 1260 state.parentctxnode = parentctxnode
1261 1261 state.actions = actions
1262 1262 state.topmost = topmost
1263 1263 state.replacements = []
1264 1264
1265 1265 # Create a backup so we can always abort completely.
1266 1266 backupfile = None
1267 1267 if not obsolete.isenabled(repo, obsolete.createmarkersopt):
1268 1268 backupfile = repair._bundle(repo, [parentctxnode], [topmost], root,
1269 1269 'histedit')
1270 1270 state.backupfile = backupfile
1271 1271
1272 def _getsummary(ctx):
1273 # a common pattern is to extract the summary but default to the empty
1274 # string
1275 summary = ctx.description() or ''
1276 if summary:
1277 summary = summary.splitlines()[0]
1278 return summary
1279
1272 1280 def bootstrapcontinue(ui, state, opts):
1273 1281 repo = state.repo
1274 1282 if state.actions:
1275 1283 actobj = state.actions.pop(0)
1276 1284
1277 1285 if _isdirtywc(repo):
1278 1286 actobj.continuedirty()
1279 1287 if _isdirtywc(repo):
1280 1288 abortdirty()
1281 1289
1282 1290 parentctx, replacements = actobj.continueclean()
1283 1291
1284 1292 state.parentctxnode = parentctx.node()
1285 1293 state.replacements.extend(replacements)
1286 1294
1287 1295 return state
1288 1296
1289 1297 def between(repo, old, new, keep):
1290 1298 """select and validate the set of revision to edit
1291 1299
1292 1300 When keep is false, the specified set can't have children."""
1293 1301 ctxs = list(repo.set('%n::%n', old, new))
1294 1302 if ctxs and not keep:
1295 1303 if (not obsolete.isenabled(repo, obsolete.allowunstableopt) and
1296 1304 repo.revs('(%ld::) - (%ld)', ctxs, ctxs)):
1297 1305 raise error.Abort(_('can only histedit a changeset together '
1298 1306 'with all its descendants'))
1299 1307 if repo.revs('(%ld) and merge()', ctxs):
1300 1308 raise error.Abort(_('cannot edit history that contains merges'))
1301 1309 root = ctxs[0] # list is already sorted by repo.set
1302 1310 if not root.mutable():
1303 1311 raise error.Abort(_('cannot edit public changeset: %s') % root,
1304 1312 hint=_('see "hg help phases" for details'))
1305 1313 return [c.node() for c in ctxs]
1306 1314
1307 1315 def ruleeditor(repo, ui, actions, editcomment=""):
1308 1316 """open an editor to edit rules
1309 1317
1310 1318 rules are in the format [ [act, ctx], ...] like in state.rules
1311 1319 """
1312 1320 if repo.ui.configbool("experimental", "histedit.autoverb"):
1313 1321 for act in actions:
1314 1322 ctx = repo[act.node]
1315 1323 summary = ''
1316 1324 if ctx.description():
1317 1325 summary = ctx.description().splitlines()[0]
1318 1326
1319 1327 fword = summary.split(' ', 1)[0].lower()
1320 1328 # if it doesn't end with the special character '!' just skip this
1321 1329 if fword.endswith('!'):
1322 1330 fword = fword[:-1]
1323 1331 if fword in primaryactions | secondaryactions | tertiaryactions:
1324 1332 act.verb = fword
1325 1333
1326 1334 rules = '\n'.join([act.torule() for act in actions])
1327 1335 rules += '\n\n'
1328 1336 rules += editcomment
1329 1337 rules = ui.edit(rules, ui.username(), {'prefix': 'histedit'})
1330 1338
1331 1339 # Save edit rules in .hg/histedit-last-edit.txt in case
1332 1340 # the user needs to ask for help after something
1333 1341 # surprising happens.
1334 1342 f = open(repo.join('histedit-last-edit.txt'), 'w')
1335 1343 f.write(rules)
1336 1344 f.close()
1337 1345
1338 1346 return rules
1339 1347
1340 1348 def parserules(rules, state):
1341 1349 """Read the histedit rules string and return list of action objects """
1342 1350 rules = [l for l in (r.strip() for r in rules.splitlines())
1343 1351 if l and not l.startswith('#')]
1344 1352 actions = []
1345 1353 for r in rules:
1346 1354 if ' ' not in r:
1347 1355 raise error.ParseError(_('malformed line "%s"') % r)
1348 1356 verb, rest = r.split(' ', 1)
1349 1357
1350 1358 if verb not in actiontable:
1351 1359 raise error.ParseError(_('unknown action "%s"') % verb)
1352 1360
1353 1361 action = actiontable[verb].fromrule(state, rest)
1354 1362 actions.append(action)
1355 1363 return actions
1356 1364
1357 1365 def warnverifyactions(ui, repo, actions, state, ctxs):
1358 1366 try:
1359 1367 verifyactions(actions, state, ctxs)
1360 1368 except error.ParseError:
1361 1369 if repo.vfs.exists('histedit-last-edit.txt'):
1362 1370 ui.warn(_('warning: histedit rules saved '
1363 1371 'to: .hg/histedit-last-edit.txt\n'))
1364 1372 raise
1365 1373
1366 1374 def verifyactions(actions, state, ctxs):
1367 1375 """Verify that there exists exactly one action per given changeset and
1368 1376 other constraints.
1369 1377
1370 1378 Will abort if there are to many or too few rules, a malformed rule,
1371 1379 or a rule on a changeset outside of the user-given range.
1372 1380 """
1373 1381 expected = set(c.hex() for c in ctxs)
1374 1382 seen = set()
1375 1383 prev = None
1376 1384 for action in actions:
1377 1385 action.verify(prev)
1378 1386 prev = action
1379 1387 constraints = action.constraints()
1380 1388 for constraint in constraints:
1381 1389 if constraint not in _constraints.known():
1382 1390 raise error.ParseError(_('unknown constraint "%s"') %
1383 1391 constraint)
1384 1392
1385 1393 nodetoverify = action.nodetoverify()
1386 1394 if nodetoverify is not None:
1387 1395 ha = node.hex(nodetoverify)
1388 1396 if _constraints.noother in constraints and ha not in expected:
1389 1397 raise error.ParseError(
1390 1398 _('%s "%s" changeset was not a candidate')
1391 1399 % (action.verb, ha[:12]),
1392 1400 hint=_('only use listed changesets'))
1393 1401 if _constraints.forceother in constraints and ha in expected:
1394 1402 raise error.ParseError(
1395 1403 _('%s "%s" changeset was not an edited list candidate')
1396 1404 % (action.verb, ha[:12]),
1397 1405 hint=_('only use listed changesets'))
1398 1406 if _constraints.noduplicates in constraints and ha in seen:
1399 1407 raise error.ParseError(_(
1400 1408 'duplicated command for changeset %s') %
1401 1409 ha[:12])
1402 1410 seen.add(ha)
1403 1411 missing = sorted(expected - seen) # sort to stabilize output
1404 1412
1405 1413 if state.repo.ui.configbool('histedit', 'dropmissing'):
1406 1414 if len(actions) == 0:
1407 1415 raise error.ParseError(_('no rules provided'),
1408 1416 hint=_('use strip extension to remove commits'))
1409 1417
1410 1418 drops = [drop(state, node.bin(n)) for n in missing]
1411 1419 # put the in the beginning so they execute immediately and
1412 1420 # don't show in the edit-plan in the future
1413 1421 actions[:0] = drops
1414 1422 elif missing:
1415 1423 raise error.ParseError(_('missing rules for changeset %s') %
1416 1424 missing[0][:12],
1417 1425 hint=_('use "drop %s" to discard, see also: '
1418 1426 '"hg help -e histedit.config"') % missing[0][:12])
1419 1427
1420 1428 def adjustreplacementsfrommarkers(repo, oldreplacements):
1421 1429 """Adjust replacements from obsolescense markers
1422 1430
1423 1431 Replacements structure is originally generated based on
1424 1432 histedit's state and does not account for changes that are
1425 1433 not recorded there. This function fixes that by adding
1426 1434 data read from obsolescense markers"""
1427 1435 if not obsolete.isenabled(repo, obsolete.createmarkersopt):
1428 1436 return oldreplacements
1429 1437
1430 1438 unfi = repo.unfiltered()
1431 1439 nm = unfi.changelog.nodemap
1432 1440 obsstore = repo.obsstore
1433 1441 newreplacements = list(oldreplacements)
1434 1442 oldsuccs = [r[1] for r in oldreplacements]
1435 1443 # successors that have already been added to succstocheck once
1436 1444 seensuccs = set().union(*oldsuccs) # create a set from an iterable of tuples
1437 1445 succstocheck = list(seensuccs)
1438 1446 while succstocheck:
1439 1447 n = succstocheck.pop()
1440 1448 missing = nm.get(n) is None
1441 1449 markers = obsstore.successors.get(n, ())
1442 1450 if missing and not markers:
1443 1451 # dead end, mark it as such
1444 1452 newreplacements.append((n, ()))
1445 1453 for marker in markers:
1446 1454 nsuccs = marker[1]
1447 1455 newreplacements.append((n, nsuccs))
1448 1456 for nsucc in nsuccs:
1449 1457 if nsucc not in seensuccs:
1450 1458 seensuccs.add(nsucc)
1451 1459 succstocheck.append(nsucc)
1452 1460
1453 1461 return newreplacements
1454 1462
1455 1463 def processreplacement(state):
1456 1464 """process the list of replacements to return
1457 1465
1458 1466 1) the final mapping between original and created nodes
1459 1467 2) the list of temporary node created by histedit
1460 1468 3) the list of new commit created by histedit"""
1461 1469 replacements = adjustreplacementsfrommarkers(state.repo, state.replacements)
1462 1470 allsuccs = set()
1463 1471 replaced = set()
1464 1472 fullmapping = {}
1465 1473 # initialize basic set
1466 1474 # fullmapping records all operations recorded in replacement
1467 1475 for rep in replacements:
1468 1476 allsuccs.update(rep[1])
1469 1477 replaced.add(rep[0])
1470 1478 fullmapping.setdefault(rep[0], set()).update(rep[1])
1471 1479 new = allsuccs - replaced
1472 1480 tmpnodes = allsuccs & replaced
1473 1481 # Reduce content fullmapping into direct relation between original nodes
1474 1482 # and final node created during history edition
1475 1483 # Dropped changeset are replaced by an empty list
1476 1484 toproceed = set(fullmapping)
1477 1485 final = {}
1478 1486 while toproceed:
1479 1487 for x in list(toproceed):
1480 1488 succs = fullmapping[x]
1481 1489 for s in list(succs):
1482 1490 if s in toproceed:
1483 1491 # non final node with unknown closure
1484 1492 # We can't process this now
1485 1493 break
1486 1494 elif s in final:
1487 1495 # non final node, replace with closure
1488 1496 succs.remove(s)
1489 1497 succs.update(final[s])
1490 1498 else:
1491 1499 final[x] = succs
1492 1500 toproceed.remove(x)
1493 1501 # remove tmpnodes from final mapping
1494 1502 for n in tmpnodes:
1495 1503 del final[n]
1496 1504 # we expect all changes involved in final to exist in the repo
1497 1505 # turn `final` into list (topologically sorted)
1498 1506 nm = state.repo.changelog.nodemap
1499 1507 for prec, succs in final.items():
1500 1508 final[prec] = sorted(succs, key=nm.get)
1501 1509
1502 1510 # computed topmost element (necessary for bookmark)
1503 1511 if new:
1504 1512 newtopmost = sorted(new, key=state.repo.changelog.rev)[-1]
1505 1513 elif not final:
1506 1514 # Nothing rewritten at all. we won't need `newtopmost`
1507 1515 # It is the same as `oldtopmost` and `processreplacement` know it
1508 1516 newtopmost = None
1509 1517 else:
1510 1518 # every body died. The newtopmost is the parent of the root.
1511 1519 r = state.repo.changelog.rev
1512 1520 newtopmost = state.repo[sorted(final, key=r)[0]].p1().node()
1513 1521
1514 1522 return final, tmpnodes, new, newtopmost
1515 1523
1516 1524 def movebookmarks(ui, repo, mapping, oldtopmost, newtopmost):
1517 1525 """Move bookmark from old to newly created node"""
1518 1526 if not mapping:
1519 1527 # if nothing got rewritten there is not purpose for this function
1520 1528 return
1521 1529 moves = []
1522 1530 for bk, old in sorted(repo._bookmarks.iteritems()):
1523 1531 if old == oldtopmost:
1524 1532 # special case ensure bookmark stay on tip.
1525 1533 #
1526 1534 # This is arguably a feature and we may only want that for the
1527 1535 # active bookmark. But the behavior is kept compatible with the old
1528 1536 # version for now.
1529 1537 moves.append((bk, newtopmost))
1530 1538 continue
1531 1539 base = old
1532 1540 new = mapping.get(base, None)
1533 1541 if new is None:
1534 1542 continue
1535 1543 while not new:
1536 1544 # base is killed, trying with parent
1537 1545 base = repo[base].p1().node()
1538 1546 new = mapping.get(base, (base,))
1539 1547 # nothing to move
1540 1548 moves.append((bk, new[-1]))
1541 1549 if moves:
1542 1550 lock = tr = None
1543 1551 try:
1544 1552 lock = repo.lock()
1545 1553 tr = repo.transaction('histedit')
1546 1554 marks = repo._bookmarks
1547 1555 for mark, new in moves:
1548 1556 old = marks[mark]
1549 1557 ui.note(_('histedit: moving bookmarks %s from %s to %s\n')
1550 1558 % (mark, node.short(old), node.short(new)))
1551 1559 marks[mark] = new
1552 1560 marks.recordchange(tr)
1553 1561 tr.close()
1554 1562 finally:
1555 1563 release(tr, lock)
1556 1564
1557 1565 def cleanupnode(ui, repo, name, nodes):
1558 1566 """strip a group of nodes from the repository
1559 1567
1560 1568 The set of node to strip may contains unknown nodes."""
1561 1569 ui.debug('should strip %s nodes %s\n' %
1562 1570 (name, ', '.join([node.short(n) for n in nodes])))
1563 1571 with repo.lock():
1564 1572 # do not let filtering get in the way of the cleanse
1565 1573 # we should probably get rid of obsolescence marker created during the
1566 1574 # histedit, but we currently do not have such information.
1567 1575 repo = repo.unfiltered()
1568 1576 # Find all nodes that need to be stripped
1569 1577 # (we use %lr instead of %ln to silently ignore unknown items)
1570 1578 nm = repo.changelog.nodemap
1571 1579 nodes = sorted(n for n in nodes if n in nm)
1572 1580 roots = [c.node() for c in repo.set("roots(%ln)", nodes)]
1573 1581 for c in roots:
1574 1582 # We should process node in reverse order to strip tip most first.
1575 1583 # but this trigger a bug in changegroup hook.
1576 1584 # This would reduce bundle overhead
1577 1585 repair.strip(ui, repo, c)
1578 1586
1579 1587 def stripwrapper(orig, ui, repo, nodelist, *args, **kwargs):
1580 1588 if isinstance(nodelist, str):
1581 1589 nodelist = [nodelist]
1582 1590 if os.path.exists(os.path.join(repo.path, 'histedit-state')):
1583 1591 state = histeditstate(repo)
1584 1592 state.read()
1585 1593 histedit_nodes = set([action.nodetoverify() for action
1586 1594 in state.actions if action.nodetoverify()])
1587 1595 strip_nodes = set([repo[n].node() for n in nodelist])
1588 1596 common_nodes = histedit_nodes & strip_nodes
1589 1597 if common_nodes:
1590 1598 raise error.Abort(_("histedit in progress, can't strip %s")
1591 1599 % ', '.join(node.short(x) for x in common_nodes))
1592 1600 return orig(ui, repo, nodelist, *args, **kwargs)
1593 1601
1594 1602 extensions.wrapfunction(repair, 'strip', stripwrapper)
1595 1603
1596 1604 def summaryhook(ui, repo):
1597 1605 if not os.path.exists(repo.join('histedit-state')):
1598 1606 return
1599 1607 state = histeditstate(repo)
1600 1608 state.read()
1601 1609 if state.actions:
1602 1610 # i18n: column positioning for "hg summary"
1603 1611 ui.write(_('hist: %s (histedit --continue)\n') %
1604 1612 (ui.label(_('%d remaining'), 'histedit.remaining') %
1605 1613 len(state.actions)))
1606 1614
1607 1615 def extsetup(ui):
1608 1616 cmdutil.summaryhooks.add('histedit', summaryhook)
1609 1617 cmdutil.unfinishedstates.append(
1610 1618 ['histedit-state', False, True, _('histedit in progress'),
1611 1619 _("use 'hg histedit --continue' or 'hg histedit --abort'")])
1612 1620 cmdutil.afterresolvedstates.append(
1613 1621 ['histedit-state', _('hg histedit --continue')])
1614 1622 if ui.configbool("experimental", "histeditng"):
1615 1623 globals()['base'] = action(['base', 'b'],
1616 1624 _('checkout changeset and apply further changesets from there')
1617 1625 )(base)
General Comments 0
You need to be logged in to leave comments. Login now