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