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