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