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