1# histedit.py - interactive history editing for mercurial 2# 3# Copyright 2009 Augie Fackler <raf@durin42.com> 4# 5# This software may be used and distributed according to the terms of the 6# GNU General Public License version 2 or any later version. 7"""interactive history editing 8 9With this extension installed, Mercurial gains one new command: histedit. Usage 10is as follows, assuming the following history:: 11 12 @ 3[tip] 7c2fd3b9020c 2009-04-27 18:04 -0500 durin42 13 | Add delta 14 | 15 o 2 030b686bedc4 2009-04-27 18:04 -0500 durin42 16 | Add gamma 17 | 18 o 1 c561b4e977df 2009-04-27 18:04 -0500 durin42 19 | Add beta 20 | 21 o 0 d8d2fcd0e319 2009-04-27 18:04 -0500 durin42 22 Add alpha 23 24If you were to run ``hg histedit c561b4e977df``, you would see the following 25file open in your editor:: 26 27 pick c561b4e977df Add beta 28 pick 030b686bedc4 Add gamma 29 pick 7c2fd3b9020c Add delta 30 31 # Edit history between c561b4e977df and 7c2fd3b9020c 32 # 33 # Commits are listed from least to most recent 34 # 35 # Commands: 36 # p, pick = use commit 37 # e, edit = use commit, but allow edits before making new commit 38 # f, fold = use commit, but combine it with the one above 39 # r, roll = like fold, but discard this commit's description and date 40 # d, drop = remove commit from history 41 # m, mess = edit commit message without changing commit content 42 # b, base = checkout changeset and apply further changesets from there 43 # 44 45In this file, lines beginning with ``#`` are ignored. You must specify a rule 46for each revision in your history. For example, if you had meant to add gamma 47before beta, and then wanted to add delta in the same revision as beta, you 48would reorganize the file to look like this:: 49 50 pick 030b686bedc4 Add gamma 51 pick c561b4e977df Add beta 52 fold 7c2fd3b9020c Add delta 53 54 # Edit history between c561b4e977df and 7c2fd3b9020c 55 # 56 # Commits are listed from least to most recent 57 # 58 # Commands: 59 # p, pick = use commit 60 # e, edit = use commit, but allow edits before making new commit 61 # f, fold = use commit, but combine it with the one above 62 # r, roll = like fold, but discard this commit's description and date 63 # d, drop = remove commit from history 64 # m, mess = edit commit message without changing commit content 65 # b, base = checkout changeset and apply further changesets from there 66 # 67 68At which point you close the editor and ``histedit`` starts working. When you 69specify a ``fold`` operation, ``histedit`` will open an editor when it folds 70those revisions together, offering you a chance to clean up the commit message:: 71 72 Add beta 73 *** 74 Add delta 75 76Edit the commit message to your liking, then close the editor. The date used 77for the commit will be the later of the two commits' dates. For this example, 78let's assume that the commit message was changed to ``Add beta and delta.`` 79After histedit has run and had a chance to remove any old or temporary 80revisions it needed, the history looks like this:: 81 82 @ 2[tip] 989b4d060121 2009-04-27 18:04 -0500 durin42 83 | Add beta and delta. 84 | 85 o 1 081603921c3f 2009-04-27 18:04 -0500 durin42 86 | Add gamma 87 | 88 o 0 d8d2fcd0e319 2009-04-27 18:04 -0500 durin42 89 Add alpha 90 91Note that ``histedit`` does *not* remove any revisions (even its own temporary 92ones) until after it has completed all the editing operations, so it will 93probably perform several strip operations when it's done. For the above example, 94it had to run strip twice. Strip can be slow depending on a variety of factors, 95so you might need to be a little patient. You can choose to keep the original 96revisions by passing the ``--keep`` flag. 97 98The ``edit`` operation will drop you back to a command prompt, 99allowing you to edit files freely, or even use ``hg record`` to commit 100some changes as a separate commit. When you're done, any remaining 101uncommitted changes will be committed as well. When done, run ``hg 102histedit --continue`` to finish this step. If there are uncommitted 103changes, you'll be prompted for a new commit message, but the default 104commit message will be the original message for the ``edit`` ed 105revision, and the date of the original commit will be preserved. 106 107The ``message`` operation will give you a chance to revise a commit 108message without changing the contents. It's a shortcut for doing 109``edit`` immediately followed by `hg histedit --continue``. 110 111If ``histedit`` encounters a conflict when moving a revision (while 112handling ``pick`` or ``fold``), it'll stop in a similar manner to 113``edit`` with the difference that it won't prompt you for a commit 114message when done. If you decide at this point that you don't like how 115much work it will be to rearrange history, or that you made a mistake, 116you can use ``hg histedit --abort`` to abandon the new changes you 117have made and return to the state before you attempted to edit your 118history. 119 120If we clone the histedit-ed example repository above and add four more 121changes, such that we have the following history:: 122 123 @ 6[tip] 038383181893 2009-04-27 18:04 -0500 stefan 124 | Add theta 125 | 126 o 5 140988835471 2009-04-27 18:04 -0500 stefan 127 | Add eta 128 | 129 o 4 122930637314 2009-04-27 18:04 -0500 stefan 130 | Add zeta 131 | 132 o 3 836302820282 2009-04-27 18:04 -0500 stefan 133 | Add epsilon 134 | 135 o 2 989b4d060121 2009-04-27 18:04 -0500 durin42 136 | Add beta and delta. 137 | 138 o 1 081603921c3f 2009-04-27 18:04 -0500 durin42 139 | Add gamma 140 | 141 o 0 d8d2fcd0e319 2009-04-27 18:04 -0500 durin42 142 Add alpha 143 144If you run ``hg histedit --outgoing`` on the clone then it is the same 145as running ``hg histedit 836302820282``. If you need plan to push to a 146repository that Mercurial does not detect to be related to the source 147repo, you can add a ``--force`` option. 148 149Config 150------ 151 152Histedit rule lines are truncated to 80 characters by default. You 153can customize this behavior by setting a different length in your 154configuration file:: 155 156 [histedit] 157 linelen = 120 # truncate rule lines at 120 characters 158 159The summary of a change can be customized as well:: 160 161 [histedit] 162 summary-template = '{rev} {bookmarks} {desc|firstline}' 163 164The customized summary should be kept short enough that rule lines 165will fit in the configured line length. See above if that requires 166customization. 167 168``hg histedit`` attempts to automatically choose an appropriate base 169revision to use. To change which base revision is used, define a 170revset in your configuration file:: 171 172 [histedit] 173 defaultrev = only(.) & draft() 174 175By default each edited revision needs to be present in histedit commands. 176To remove revision you need to use ``drop`` operation. You can configure 177the drop to be implicit for missing commits by adding:: 178 179 [histedit] 180 dropmissing = True 181 182By default, histedit will close the transaction after each action. For 183performance purposes, you can configure histedit to use a single transaction 184across the entire histedit. WARNING: This setting introduces a significant risk 185of losing the work you've done in a histedit if the histedit aborts 186unexpectedly:: 187 188 [histedit] 189 singletransaction = True 190 191""" 192 193from __future__ import absolute_import 194 195# chistedit dependencies that are not available everywhere 196try: 197 import fcntl 198 import termios 199except ImportError: 200 fcntl = None 201 termios = None 202 203import functools 204import os 205import struct 206 207from mercurial.i18n import _ 208from mercurial.pycompat import ( 209 getattr, 210 open, 211) 212from mercurial.node import ( 213 bin, 214 hex, 215 short, 216) 217from mercurial import ( 218 bundle2, 219 cmdutil, 220 context, 221 copies, 222 destutil, 223 discovery, 224 encoding, 225 error, 226 exchange, 227 extensions, 228 hg, 229 logcmdutil, 230 merge as mergemod, 231 mergestate as mergestatemod, 232 mergeutil, 233 obsolete, 234 pycompat, 235 registrar, 236 repair, 237 rewriteutil, 238 scmutil, 239 state as statemod, 240 util, 241) 242from mercurial.utils import ( 243 dateutil, 244 stringutil, 245 urlutil, 246) 247 248pickle = util.pickle 249cmdtable = {} 250command = registrar.command(cmdtable) 251 252configtable = {} 253configitem = registrar.configitem(configtable) 254configitem( 255 b'experimental', 256 b'histedit.autoverb', 257 default=False, 258) 259configitem( 260 b'histedit', 261 b'defaultrev', 262 default=None, 263) 264configitem( 265 b'histedit', 266 b'dropmissing', 267 default=False, 268) 269configitem( 270 b'histedit', 271 b'linelen', 272 default=80, 273) 274configitem( 275 b'histedit', 276 b'singletransaction', 277 default=False, 278) 279configitem( 280 b'ui', 281 b'interface.histedit', 282 default=None, 283) 284configitem(b'histedit', b'summary-template', default=b'{rev} {desc|firstline}') 285# TODO: Teach the text-based histedit interface to respect this config option 286# before we make it non-experimental. 287configitem( 288 b'histedit', b'later-commits-first', default=False, experimental=True 289) 290 291# Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for 292# extensions which SHIP WITH MERCURIAL. Non-mainline extensions should 293# be specifying the version(s) of Mercurial they are tested with, or 294# leave the attribute unspecified. 295testedwith = b'ships-with-hg-core' 296 297actiontable = {} 298primaryactions = set() 299secondaryactions = set() 300tertiaryactions = set() 301internalactions = set() 302 303 304def geteditcomment(ui, first, last): 305 """construct the editor comment 306 The comment includes:: 307 - an intro 308 - sorted primary commands 309 - sorted short commands 310 - sorted long commands 311 - additional hints 312 313 Commands are only included once. 314 """ 315 intro = _( 316 b"""Edit history between %s and %s 317 318Commits are listed from least to most recent 319 320You can reorder changesets by reordering the lines 321 322Commands: 323""" 324 ) 325 actions = [] 326 327 def addverb(v): 328 a = actiontable[v] 329 lines = a.message.split(b"\n") 330 if len(a.verbs): 331 v = b', '.join(sorted(a.verbs, key=lambda v: len(v))) 332 actions.append(b" %s = %s" % (v, lines[0])) 333 actions.extend([b' %s'] * (len(lines) - 1)) 334 335 for v in ( 336 sorted(primaryactions) 337 + sorted(secondaryactions) 338 + sorted(tertiaryactions) 339 ): 340 addverb(v) 341 actions.append(b'') 342 343 hints = [] 344 if ui.configbool(b'histedit', b'dropmissing'): 345 hints.append( 346 b"Deleting a changeset from the list " 347 b"will DISCARD it from the edited history!" 348 ) 349 350 lines = (intro % (first, last)).split(b'\n') + actions + hints 351 352 return b''.join([b'# %s\n' % l if l else b'#\n' for l in lines]) 353 354 355class histeditstate(object): 356 def __init__(self, repo): 357 self.repo = repo 358 self.actions = None 359 self.keep = None 360 self.topmost = None 361 self.parentctxnode = None 362 self.lock = None 363 self.wlock = None 364 self.backupfile = None 365 self.stateobj = statemod.cmdstate(repo, b'histedit-state') 366 self.replacements = [] 367 368 def read(self): 369 """Load histedit state from disk and set fields appropriately.""" 370 if not self.stateobj.exists(): 371 cmdutil.wrongtooltocontinue(self.repo, _(b'histedit')) 372 373 data = self._read() 374 375 self.parentctxnode = data[b'parentctxnode'] 376 actions = parserules(data[b'rules'], self) 377 self.actions = actions 378 self.keep = data[b'keep'] 379 self.topmost = data[b'topmost'] 380 self.replacements = data[b'replacements'] 381 self.backupfile = data[b'backupfile'] 382 383 def _read(self): 384 fp = self.repo.vfs.read(b'histedit-state') 385 if fp.startswith(b'v1\n'): 386 data = self._load() 387 parentctxnode, rules, keep, topmost, replacements, backupfile = data 388 else: 389 data = pickle.loads(fp) 390 parentctxnode, rules, keep, topmost, replacements = data 391 backupfile = None 392 rules = b"\n".join([b"%s %s" % (verb, rest) for [verb, rest] in rules]) 393 394 return { 395 b'parentctxnode': parentctxnode, 396 b"rules": rules, 397 b"keep": keep, 398 b"topmost": topmost, 399 b"replacements": replacements, 400 b"backupfile": backupfile, 401 } 402 403 def write(self, tr=None): 404 if tr: 405 tr.addfilegenerator( 406 b'histedit-state', 407 (b'histedit-state',), 408 self._write, 409 location=b'plain', 410 ) 411 else: 412 with self.repo.vfs(b"histedit-state", b"w") as f: 413 self._write(f) 414 415 def _write(self, fp): 416 fp.write(b'v1\n') 417 fp.write(b'%s\n' % hex(self.parentctxnode)) 418 fp.write(b'%s\n' % hex(self.topmost)) 419 fp.write(b'%s\n' % (b'True' if self.keep else b'False')) 420 fp.write(b'%d\n' % len(self.actions)) 421 for action in self.actions: 422 fp.write(b'%s\n' % action.tostate()) 423 fp.write(b'%d\n' % len(self.replacements)) 424 for replacement in self.replacements: 425 fp.write( 426 b'%s%s\n' 427 % ( 428 hex(replacement[0]), 429 b''.join(hex(r) for r in replacement[1]), 430 ) 431 ) 432 backupfile = self.backupfile 433 if not backupfile: 434 backupfile = b'' 435 fp.write(b'%s\n' % backupfile) 436 437 def _load(self): 438 fp = self.repo.vfs(b'histedit-state', b'r') 439 lines = [l[:-1] for l in fp.readlines()] 440 441 index = 0 442 lines[index] # version number 443 index += 1 444 445 parentctxnode = bin(lines[index]) 446 index += 1 447 448 topmost = bin(lines[index]) 449 index += 1 450 451 keep = lines[index] == b'True' 452 index += 1 453 454 # Rules 455 rules = [] 456 rulelen = int(lines[index]) 457 index += 1 458 for i in pycompat.xrange(rulelen): 459 ruleaction = lines[index] 460 index += 1 461 rule = lines[index] 462 index += 1 463 rules.append((ruleaction, rule)) 464 465 # Replacements 466 replacements = [] 467 replacementlen = int(lines[index]) 468 index += 1 469 for i in pycompat.xrange(replacementlen): 470 replacement = lines[index] 471 original = bin(replacement[:40]) 472 succ = [ 473 bin(replacement[i : i + 40]) 474 for i in range(40, len(replacement), 40) 475 ] 476 replacements.append((original, succ)) 477 index += 1 478 479 backupfile = lines[index] 480 index += 1 481 482 fp.close() 483 484 return parentctxnode, rules, keep, topmost, replacements, backupfile 485 486 def clear(self): 487 if self.inprogress(): 488 self.repo.vfs.unlink(b'histedit-state') 489 490 def inprogress(self): 491 return self.repo.vfs.exists(b'histedit-state') 492 493 494class histeditaction(object): 495 def __init__(self, state, node): 496 self.state = state 497 self.repo = state.repo 498 self.node = node 499 500 @classmethod 501 def fromrule(cls, state, rule): 502 """Parses the given rule, returning an instance of the histeditaction.""" 503 ruleid = rule.strip().split(b' ', 1)[0] 504 # ruleid can be anything from rev numbers, hashes, "bookmarks" etc 505 # Check for validation of rule ids and get the rulehash 506 try: 507 rev = bin(ruleid) 508 except TypeError: 509 try: 510 _ctx = scmutil.revsingle(state.repo, ruleid) 511 rulehash = _ctx.hex() 512 rev = bin(rulehash) 513 except error.RepoLookupError: 514 raise error.ParseError(_(b"invalid changeset %s") % ruleid) 515 return cls(state, rev) 516 517 def verify(self, prev, expected, seen): 518 """Verifies semantic correctness of the rule""" 519 repo = self.repo 520 ha = hex(self.node) 521 self.node = scmutil.resolvehexnodeidprefix(repo, ha) 522 if self.node is None: 523 raise error.ParseError(_(b'unknown changeset %s listed') % ha[:12]) 524 self._verifynodeconstraints(prev, expected, seen) 525 526 def _verifynodeconstraints(self, prev, expected, seen): 527 # by default command need a node in the edited list 528 if self.node not in expected: 529 raise error.ParseError( 530 _(b'%s "%s" changeset was not a candidate') 531 % (self.verb, short(self.node)), 532 hint=_(b'only use listed changesets'), 533 ) 534 # and only one command per node 535 if self.node in seen: 536 raise error.ParseError( 537 _(b'duplicated command for changeset %s') % short(self.node) 538 ) 539 540 def torule(self): 541 """build a histedit rule line for an action 542 543 by default lines are in the form: 544 <hash> <rev> <summary> 545 """ 546 ctx = self.repo[self.node] 547 ui = self.repo.ui 548 # We don't want color codes in the commit message template, so 549 # disable the label() template function while we render it. 550 with ui.configoverride( 551 {(b'templatealias', b'label(l,x)'): b"x"}, b'histedit' 552 ): 553 summary = cmdutil.rendertemplate( 554 ctx, ui.config(b'histedit', b'summary-template') 555 ) 556 # Handle the fact that `''.splitlines() => []` 557 summary = summary.splitlines()[0] if summary else b'' 558 line = b'%s %s %s' % (self.verb, ctx, summary) 559 # trim to 75 columns by default so it's not stupidly wide in my editor 560 # (the 5 more are left for verb) 561 maxlen = self.repo.ui.configint(b'histedit', b'linelen') 562 maxlen = max(maxlen, 22) # avoid truncating hash 563 return stringutil.ellipsis(line, maxlen) 564 565 def tostate(self): 566 """Print an action in format used by histedit state files 567 (the first line is a verb, the remainder is the second) 568 """ 569 return b"%s\n%s" % (self.verb, hex(self.node)) 570 571 def run(self): 572 """Runs the action. The default behavior is simply apply the action's 573 rulectx onto the current parentctx.""" 574 self.applychange() 575 self.continuedirty() 576 return self.continueclean() 577 578 def applychange(self): 579 """Applies the changes from this action's rulectx onto the current 580 parentctx, but does not commit them.""" 581 repo = self.repo 582 rulectx = repo[self.node] 583 with repo.ui.silent(): 584 hg.update(repo, self.state.parentctxnode, quietempty=True) 585 stats = applychanges(repo.ui, repo, rulectx, {}) 586 repo.dirstate.setbranch(rulectx.branch()) 587 if stats.unresolvedcount: 588 raise error.InterventionRequired( 589 _(b'Fix up the change (%s %s)') % (self.verb, short(self.node)), 590 hint=_(b'hg histedit --continue to resume'), 591 ) 592 593 def continuedirty(self): 594 """Continues the action when changes have been applied to the working 595 copy. The default behavior is to commit the dirty changes.""" 596 repo = self.repo 597 rulectx = repo[self.node] 598 599 editor = self.commiteditor() 600 commit = commitfuncfor(repo, rulectx) 601 if repo.ui.configbool(b'rewrite', b'update-timestamp'): 602 date = dateutil.makedate() 603 else: 604 date = rulectx.date() 605 commit( 606 text=rulectx.description(), 607 user=rulectx.user(), 608 date=date, 609 extra=rulectx.extra(), 610 editor=editor, 611 ) 612 613 def commiteditor(self): 614 """The editor to be used to edit the commit message.""" 615 return False 616 617 def continueclean(self): 618 """Continues the action when the working copy is clean. The default 619 behavior is to accept the current commit as the new version of the 620 rulectx.""" 621 ctx = self.repo[b'.'] 622 if ctx.node() == self.state.parentctxnode: 623 self.repo.ui.warn( 624 _(b'%s: skipping changeset (no changes)\n') % short(self.node) 625 ) 626 return ctx, [(self.node, tuple())] 627 if ctx.node() == self.node: 628 # Nothing changed 629 return ctx, [] 630 return ctx, [(self.node, (ctx.node(),))] 631 632 633def commitfuncfor(repo, src): 634 """Build a commit function for the replacement of <src> 635 636 This function ensure we apply the same treatment to all changesets. 637 638 - Add a 'histedit_source' entry in extra. 639 640 Note that fold has its own separated logic because its handling is a bit 641 different and not easily factored out of the fold method. 642 """ 643 phasemin = src.phase() 644 645 def commitfunc(**kwargs): 646 overrides = {(b'phases', b'new-commit'): phasemin} 647 with repo.ui.configoverride(overrides, b'histedit'): 648 extra = kwargs.get('extra', {}).copy() 649 extra[b'histedit_source'] = src.hex() 650 kwargs['extra'] = extra 651 return repo.commit(**kwargs) 652 653 return commitfunc 654 655 656def applychanges(ui, repo, ctx, opts): 657 """Merge changeset from ctx (only) in the current working directory""" 658 if ctx.p1().node() == repo.dirstate.p1(): 659 # edits are "in place" we do not need to make any merge, 660 # just applies changes on parent for editing 661 with ui.silent(): 662 cmdutil.revert(ui, repo, ctx, all=True) 663 stats = mergemod.updateresult(0, 0, 0, 0) 664 else: 665 try: 666 # ui.forcemerge is an internal variable, do not document 667 repo.ui.setconfig( 668 b'ui', b'forcemerge', opts.get(b'tool', b''), b'histedit' 669 ) 670 stats = mergemod.graft(repo, ctx, labels=[b'local', b'histedit']) 671 finally: 672 repo.ui.setconfig(b'ui', b'forcemerge', b'', b'histedit') 673 return stats 674 675 676def collapse(repo, firstctx, lastctx, commitopts, skipprompt=False): 677 """collapse the set of revisions from first to last as new one. 678 679 Expected commit options are: 680 - message 681 - date 682 - username 683 Commit message is edited in all cases. 684 685 This function works in memory.""" 686 ctxs = list(repo.set(b'%d::%d', firstctx.rev(), lastctx.rev())) 687 if not ctxs: 688 return None 689 for c in ctxs: 690 if not c.mutable(): 691 raise error.ParseError( 692 _(b"cannot fold into public change %s") % short(c.node()) 693 ) 694 base = firstctx.p1() 695 696 # commit a new version of the old changeset, including the update 697 # collect all files which might be affected 698 files = set() 699 for ctx in ctxs: 700 files.update(ctx.files()) 701 702 # Recompute copies (avoid recording a -> b -> a) 703 copied = copies.pathcopies(base, lastctx) 704 705 # prune files which were reverted by the updates 706 files = [f for f in files if not cmdutil.samefile(f, lastctx, base)] 707 # commit version of these files as defined by head 708 headmf = lastctx.manifest() 709 710 def filectxfn(repo, ctx, path): 711 if path in headmf: 712 fctx = lastctx[path] 713 flags = fctx.flags() 714 mctx = context.memfilectx( 715 repo, 716 ctx, 717 fctx.path(), 718 fctx.data(), 719 islink=b'l' in flags, 720 isexec=b'x' in flags, 721 copysource=copied.get(path), 722 ) 723 return mctx 724 return None 725 726 if commitopts.get(b'message'): 727 message = commitopts[b'message'] 728 else: 729 message = firstctx.description() 730 user = commitopts.get(b'user') 731 date = commitopts.get(b'date') 732 extra = commitopts.get(b'extra') 733 734 parents = (firstctx.p1().node(), firstctx.p2().node()) 735 editor = None 736 if not skipprompt: 737 editor = cmdutil.getcommiteditor(edit=True, editform=b'histedit.fold') 738 new = context.memctx( 739 repo, 740 parents=parents, 741 text=message, 742 files=files, 743 filectxfn=filectxfn, 744 user=user, 745 date=date, 746 extra=extra, 747 editor=editor, 748 ) 749 return repo.commitctx(new) 750 751 752def _isdirtywc(repo): 753 return repo[None].dirty(missing=True) 754 755 756def abortdirty(): 757 raise error.StateError( 758 _(b'working copy has pending changes'), 759 hint=_( 760 b'amend, commit, or revert them and run histedit ' 761 b'--continue, or abort with histedit --abort' 762 ), 763 ) 764 765 766def action(verbs, message, priority=False, internal=False): 767 def wrap(cls): 768 assert not priority or not internal 769 verb = verbs[0] 770 if priority: 771 primaryactions.add(verb) 772 elif internal: 773 internalactions.add(verb) 774 elif len(verbs) > 1: 775 secondaryactions.add(verb) 776 else: 777 tertiaryactions.add(verb) 778 779 cls.verb = verb 780 cls.verbs = verbs 781 cls.message = message 782 for verb in verbs: 783 actiontable[verb] = cls 784 return cls 785 786 return wrap 787 788 789@action([b'pick', b'p'], _(b'use commit'), priority=True) 790class pick(histeditaction): 791 def run(self): 792 rulectx = self.repo[self.node] 793 if rulectx.p1().node() == self.state.parentctxnode: 794 self.repo.ui.debug(b'node %s unchanged\n' % short(self.node)) 795 return rulectx, [] 796 797 return super(pick, self).run() 798 799 800@action( 801 [b'edit', b'e'], 802 _(b'use commit, but allow edits before making new commit'), 803 priority=True, 804) 805class edit(histeditaction): 806 def run(self): 807 repo = self.repo 808 rulectx = repo[self.node] 809 hg.update(repo, self.state.parentctxnode, quietempty=True) 810 applychanges(repo.ui, repo, rulectx, {}) 811 hint = _(b'to edit %s, `hg histedit --continue` after making changes') 812 raise error.InterventionRequired( 813 _(b'Editing (%s), commit as needed now to split the change') 814 % short(self.node), 815 hint=hint % short(self.node), 816 ) 817 818 def commiteditor(self): 819 return cmdutil.getcommiteditor(edit=True, editform=b'histedit.edit') 820 821 822@action([b'fold', b'f'], _(b'use commit, but combine it with the one above')) 823class fold(histeditaction): 824 def verify(self, prev, expected, seen): 825 """Verifies semantic correctness of the fold rule""" 826 super(fold, self).verify(prev, expected, seen) 827 repo = self.repo 828 if not prev: 829 c = repo[self.node].p1() 830 elif not prev.verb in (b'pick', b'base'): 831 return 832 else: 833 c = repo[prev.node] 834 if not c.mutable(): 835 raise error.ParseError( 836 _(b"cannot fold into public change %s") % short(c.node()) 837 ) 838 839 def continuedirty(self): 840 repo = self.repo 841 rulectx = repo[self.node] 842 843 commit = commitfuncfor(repo, rulectx) 844 commit( 845 text=b'fold-temp-revision %s' % short(self.node), 846 user=rulectx.user(), 847 date=rulectx.date(), 848 extra=rulectx.extra(), 849 ) 850 851 def continueclean(self): 852 repo = self.repo 853 ctx = repo[b'.'] 854 rulectx = repo[self.node] 855 parentctxnode = self.state.parentctxnode 856 if ctx.node() == parentctxnode: 857 repo.ui.warn(_(b'%s: empty changeset\n') % short(self.node)) 858 return ctx, [(self.node, (parentctxnode,))] 859 860 parentctx = repo[parentctxnode] 861 newcommits = { 862 c.node() 863 for c in repo.set(b'(%d::. - %d)', parentctx.rev(), parentctx.rev()) 864 } 865 if not newcommits: 866 repo.ui.warn( 867 _( 868 b'%s: cannot fold - working copy is not a ' 869 b'descendant of previous commit %s\n' 870 ) 871 % (short(self.node), short(parentctxnode)) 872 ) 873 return ctx, [(self.node, (ctx.node(),))] 874 875 middlecommits = newcommits.copy() 876 middlecommits.discard(ctx.node()) 877 878 return self.finishfold( 879 repo.ui, repo, parentctx, rulectx, ctx.node(), middlecommits 880 ) 881 882 def skipprompt(self): 883 """Returns true if the rule should skip the message editor. 884 885 For example, 'fold' wants to show an editor, but 'rollup' 886 doesn't want to. 887 """ 888 return False 889 890 def mergedescs(self): 891 """Returns true if the rule should merge messages of multiple changes. 892 893 This exists mainly so that 'rollup' rules can be a subclass of 894 'fold'. 895 """ 896 return True 897 898 def firstdate(self): 899 """Returns true if the rule should preserve the date of the first 900 change. 901 902 This exists mainly so that 'rollup' rules can be a subclass of 903 'fold'. 904 """ 905 return False 906 907 def finishfold(self, ui, repo, ctx, oldctx, newnode, internalchanges): 908 mergemod.update(ctx.p1()) 909 ### prepare new commit data 910 commitopts = {} 911 commitopts[b'user'] = ctx.user() 912 # commit message 913 if not self.mergedescs(): 914 newmessage = ctx.description() 915 else: 916 newmessage = ( 917 b'\n***\n'.join( 918 [ctx.description()] 919 + [repo[r].description() for r in internalchanges] 920 + [oldctx.description()] 921 ) 922 + b'\n' 923 ) 924 commitopts[b'message'] = newmessage 925 # date 926 if self.firstdate(): 927 commitopts[b'date'] = ctx.date() 928 else: 929 commitopts[b'date'] = max(ctx.date(), oldctx.date()) 930 # if date is to be updated to current 931 if ui.configbool(b'rewrite', b'update-timestamp'): 932 commitopts[b'date'] = dateutil.makedate() 933 934 extra = ctx.extra().copy() 935 # histedit_source 936 # note: ctx is likely a temporary commit but that the best we can do 937 # here. This is sufficient to solve issue3681 anyway. 938 extra[b'histedit_source'] = b'%s,%s' % (ctx.hex(), oldctx.hex()) 939 commitopts[b'extra'] = extra 940 phasemin = max(ctx.phase(), oldctx.phase()) 941 overrides = {(b'phases', b'new-commit'): phasemin} 942 with repo.ui.configoverride(overrides, b'histedit'): 943 n = collapse( 944 repo, 945 ctx, 946 repo[newnode], 947 commitopts, 948 skipprompt=self.skipprompt(), 949 ) 950 if n is None: 951 return ctx, [] 952 mergemod.update(repo[n]) 953 replacements = [ 954 (oldctx.node(), (newnode,)), 955 (ctx.node(), (n,)), 956 (newnode, (n,)), 957 ] 958 for ich in internalchanges: 959 replacements.append((ich, (n,))) 960 return repo[n], replacements 961 962 963@action( 964 [b'base', b'b'], 965 _(b'checkout changeset and apply further changesets from there'), 966) 967class base(histeditaction): 968 def run(self): 969 if self.repo[b'.'].node() != self.node: 970 mergemod.clean_update(self.repo[self.node]) 971 return self.continueclean() 972 973 def continuedirty(self): 974 abortdirty() 975 976 def continueclean(self): 977 basectx = self.repo[b'.'] 978 return basectx, [] 979 980 def _verifynodeconstraints(self, prev, expected, seen): 981 # base can only be use with a node not in the edited set 982 if self.node in expected: 983 msg = _(b'%s "%s" changeset was an edited list candidate') 984 raise error.ParseError( 985 msg % (self.verb, short(self.node)), 986 hint=_(b'base must only use unlisted changesets'), 987 ) 988 989 990@action( 991 [b'_multifold'], 992 _( 993 """fold subclass used for when multiple folds happen in a row 994 995 We only want to fire the editor for the folded message once when 996 (say) four changes are folded down into a single change. This is 997 similar to rollup, but we should preserve both messages so that 998 when the last fold operation runs we can show the user all the 999 commit messages in their editor. 1000 """ 1001 ), 1002 internal=True, 1003) 1004class _multifold(fold): 1005 def skipprompt(self): 1006 return True 1007 1008 1009@action( 1010 [b"roll", b"r"], 1011 _(b"like fold, but discard this commit's description and date"), 1012) 1013class rollup(fold): 1014 def mergedescs(self): 1015 return False 1016 1017 def skipprompt(self): 1018 return True 1019 1020 def firstdate(self): 1021 return True 1022 1023 1024@action([b"drop", b"d"], _(b'remove commit from history')) 1025class drop(histeditaction): 1026 def run(self): 1027 parentctx = self.repo[self.state.parentctxnode] 1028 return parentctx, [(self.node, tuple())] 1029 1030 1031@action( 1032 [b"mess", b"m"], 1033 _(b'edit commit message without changing commit content'), 1034 priority=True, 1035) 1036class message(histeditaction): 1037 def commiteditor(self): 1038 return cmdutil.getcommiteditor(edit=True, editform=b'histedit.mess') 1039 1040 1041def findoutgoing(ui, repo, remote=None, force=False, opts=None): 1042 """utility function to find the first outgoing changeset 1043 1044 Used by initialization code""" 1045 if opts is None: 1046 opts = {} 1047 path = urlutil.get_unique_push_path(b'histedit', repo, ui, remote) 1048 dest = path.pushloc or path.loc 1049 1050 ui.status(_(b'comparing with %s\n') % urlutil.hidepassword(dest)) 1051 1052 revs, checkout = hg.addbranchrevs(repo, repo, (path.branch, []), None) 1053 other = hg.peer(repo, opts, dest) 1054 1055 if revs: 1056 revs = [repo.lookup(rev) for rev in revs] 1057 1058 outgoing = discovery.findcommonoutgoing(repo, other, revs, force=force) 1059 if not outgoing.missing: 1060 raise error.StateError(_(b'no outgoing ancestors')) 1061 roots = list(repo.revs(b"roots(%ln)", outgoing.missing)) 1062 if len(roots) > 1: 1063 msg = _(b'there are ambiguous outgoing revisions') 1064 hint = _(b"see 'hg help histedit' for more detail") 1065 raise error.StateError(msg, hint=hint) 1066 return repo[roots[0]].node() 1067 1068 1069# Curses Support 1070try: 1071 import curses 1072except ImportError: 1073 curses = None 1074 1075KEY_LIST = [b'pick', b'edit', b'fold', b'drop', b'mess', b'roll'] 1076ACTION_LABELS = { 1077 b'fold': b'^fold', 1078 b'roll': b'^roll', 1079} 1080 1081COLOR_HELP, COLOR_SELECTED, COLOR_OK, COLOR_WARN, COLOR_CURRENT = 1, 2, 3, 4, 5 1082COLOR_DIFF_ADD_LINE, COLOR_DIFF_DEL_LINE, COLOR_DIFF_OFFSET = 6, 7, 8 1083COLOR_ROLL, COLOR_ROLL_CURRENT, COLOR_ROLL_SELECTED = 9, 10, 11 1084 1085E_QUIT, E_HISTEDIT = 1, 2 1086E_PAGEDOWN, E_PAGEUP, E_LINEUP, E_LINEDOWN, E_RESIZE = 3, 4, 5, 6, 7 1087MODE_INIT, MODE_PATCH, MODE_RULES, MODE_HELP = 0, 1, 2, 3 1088 1089KEYTABLE = { 1090 b'global': { 1091 b'h': b'next-action', 1092 b'KEY_RIGHT': b'next-action', 1093 b'l': b'prev-action', 1094 b'KEY_LEFT': b'prev-action', 1095 b'q': b'quit', 1096 b'c': b'histedit', 1097 b'C': b'histedit', 1098 b'v': b'showpatch', 1099 b'?': b'help', 1100 }, 1101 MODE_RULES: { 1102 b'd': b'action-drop', 1103 b'e': b'action-edit', 1104 b'f': b'action-fold', 1105 b'm': b'action-mess', 1106 b'p': b'action-pick', 1107 b'r': b'action-roll', 1108 b' ': b'select', 1109 b'j': b'down', 1110 b'k': b'up', 1111 b'KEY_DOWN': b'down', 1112 b'KEY_UP': b'up', 1113 b'J': b'move-down', 1114 b'K': b'move-up', 1115 b'KEY_NPAGE': b'move-down', 1116 b'KEY_PPAGE': b'move-up', 1117 b'0': b'goto', # Used for 0..9 1118 }, 1119 MODE_PATCH: { 1120 b' ': b'page-down', 1121 b'KEY_NPAGE': b'page-down', 1122 b'KEY_PPAGE': b'page-up', 1123 b'j': b'line-down', 1124 b'k': b'line-up', 1125 b'KEY_DOWN': b'line-down', 1126 b'KEY_UP': b'line-up', 1127 b'J': b'down', 1128 b'K': b'up', 1129 }, 1130 MODE_HELP: {}, 1131} 1132 1133 1134def screen_size(): 1135 return struct.unpack(b'hh', fcntl.ioctl(1, termios.TIOCGWINSZ, b' ')) 1136 1137 1138class histeditrule(object): 1139 def __init__(self, ui, ctx, pos, action=b'pick'): 1140 self.ui = ui 1141 self.ctx = ctx 1142 self.action = action 1143 self.origpos = pos 1144 self.pos = pos 1145 self.conflicts = [] 1146 1147 def __bytes__(self): 1148 # Example display of several histeditrules: 1149 # 1150 # #10 pick 316392:06a16c25c053 add option to skip tests 1151 # #11 ^roll 316393:71313c964cc5 <RED>oops a fixup commit</RED> 1152 # #12 pick 316394:ab31f3973b0d include mfbt for mozilla-config.h 1153 # #13 ^fold 316395:14ce5803f4c3 fix warnings 1154 # 1155 # The carets point to the changeset being folded into ("roll this 1156 # changeset into the changeset above"). 1157 return b'%s%s' % (self.prefix, self.desc) 1158 1159 __str__ = encoding.strmethod(__bytes__) 1160 1161 @property 1162 def prefix(self): 1163 # Some actions ('fold' and 'roll') combine a patch with a 1164 # previous one. Add a marker showing which patch they apply 1165 # to. 1166 action = ACTION_LABELS.get(self.action, self.action) 1167 1168 h = self.ctx.hex()[0:12] 1169 r = self.ctx.rev() 1170 1171 return b"#%s %s %d:%s " % ( 1172 (b'%d' % self.origpos).ljust(2), 1173 action.ljust(6), 1174 r, 1175 h, 1176 ) 1177 1178 @util.propertycache 1179 def desc(self): 1180 summary = cmdutil.rendertemplate( 1181 self.ctx, self.ui.config(b'histedit', b'summary-template') 1182 ) 1183 if summary: 1184 return summary 1185 # This is split off from the prefix property so that we can 1186 # separately make the description for 'roll' red (since it 1187 # will get discarded). 1188 return self.ctx.description().splitlines()[0].strip() 1189 1190 def checkconflicts(self, other): 1191 if other.pos > self.pos and other.origpos <= self.origpos: 1192 if set(other.ctx.files()) & set(self.ctx.files()) != set(): 1193 self.conflicts.append(other) 1194 return self.conflicts 1195 1196 if other in self.conflicts: 1197 self.conflicts.remove(other) 1198 return self.conflicts 1199 1200 1201def makecommands(rules): 1202 """Returns a list of commands consumable by histedit --commands based on 1203 our list of rules""" 1204 commands = [] 1205 for rules in rules: 1206 commands.append(b'%s %s\n' % (rules.action, rules.ctx)) 1207 return commands 1208 1209 1210def addln(win, y, x, line, color=None): 1211 """Add a line to the given window left padding but 100% filled with 1212 whitespace characters, so that the color appears on the whole line""" 1213 maxy, maxx = win.getmaxyx() 1214 length = maxx - 1 - x 1215 line = bytes(line).ljust(length)[:length] 1216 if y < 0: 1217 y = maxy + y 1218 if x < 0: 1219 x = maxx + x 1220 if color: 1221 win.addstr(y, x, line, color) 1222 else: 1223 win.addstr(y, x, line) 1224 1225 1226def _trunc_head(line, n): 1227 if len(line) <= n: 1228 return line 1229 return b'> ' + line[-(n - 2) :] 1230 1231 1232def _trunc_tail(line, n): 1233 if len(line) <= n: 1234 return line 1235 return line[: n - 2] + b' >' 1236 1237 1238class _chistedit_state(object): 1239 def __init__( 1240 self, 1241 repo, 1242 rules, 1243 stdscr, 1244 ): 1245 self.repo = repo 1246 self.rules = rules 1247 self.stdscr = stdscr 1248 self.later_on_top = repo.ui.configbool( 1249 b'histedit', b'later-commits-first' 1250 ) 1251 # The current item in display order, initialized to point to the top 1252 # of the screen. 1253 self.pos = 0 1254 self.selected = None 1255 self.mode = (MODE_INIT, MODE_INIT) 1256 self.page_height = None 1257 self.modes = { 1258 MODE_RULES: { 1259 b'line_offset': 0, 1260 }, 1261 MODE_PATCH: { 1262 b'line_offset': 0, 1263 }, 1264 } 1265 1266 def render_commit(self, win): 1267 """Renders the commit window that shows the log of the current selected 1268 commit""" 1269 rule = self.rules[self.display_pos_to_rule_pos(self.pos)] 1270 1271 ctx = rule.ctx 1272 win.box() 1273 1274 maxy, maxx = win.getmaxyx() 1275 length = maxx - 3 1276 1277 line = b"changeset: %d:%s" % (ctx.rev(), ctx.hex()[:12]) 1278 win.addstr(1, 1, line[:length]) 1279 1280 line = b"user: %s" % ctx.user() 1281 win.addstr(2, 1, line[:length]) 1282 1283 bms = self.repo.nodebookmarks(ctx.node()) 1284 line = b"bookmark: %s" % b' '.join(bms) 1285 win.addstr(3, 1, line[:length]) 1286 1287 line = b"summary: %s" % (ctx.description().splitlines()[0]) 1288 win.addstr(4, 1, line[:length]) 1289 1290 line = b"files: " 1291 win.addstr(5, 1, line) 1292 fnx = 1 + len(line) 1293 fnmaxx = length - fnx + 1 1294 y = 5 1295 fnmaxn = maxy - (1 + y) - 1 1296 files = ctx.files() 1297 for i, line1 in enumerate(files): 1298 if len(files) > fnmaxn and i == fnmaxn - 1: 1299 win.addstr(y, fnx, _trunc_tail(b','.join(files[i:]), fnmaxx)) 1300 y = y + 1 1301 break 1302 win.addstr(y, fnx, _trunc_head(line1, fnmaxx)) 1303 y = y + 1 1304 1305 conflicts = rule.conflicts 1306 if len(conflicts) > 0: 1307 conflictstr = b','.join(map(lambda r: r.ctx.hex()[:12], conflicts)) 1308 conflictstr = b"changed files overlap with %s" % conflictstr 1309 else: 1310 conflictstr = b'no overlap' 1311 1312 win.addstr(y, 1, conflictstr[:length]) 1313 win.noutrefresh() 1314 1315 def helplines(self): 1316 if self.mode[0] == MODE_PATCH: 1317 help = b"""\ 1318?: help, k/up: line up, j/down: line down, v: stop viewing patch 1319pgup: prev page, space/pgdn: next page, c: commit, q: abort 1320""" 1321 else: 1322 help = b"""\ 1323?: help, k/up: move up, j/down: move down, space: select, v: view patch 1324d: drop, e: edit, f: fold, m: mess, p: pick, r: roll 1325pgup/K: move patch up, pgdn/J: move patch down, c: commit, q: abort 1326""" 1327 return help.splitlines() 1328 1329 def render_help(self, win): 1330 maxy, maxx = win.getmaxyx() 1331 for y, line in enumerate(self.helplines()): 1332 if y >= maxy: 1333 break 1334 addln(win, y, 0, line, curses.color_pair(COLOR_HELP)) 1335 win.noutrefresh() 1336 1337 def layout(self): 1338 maxy, maxx = self.stdscr.getmaxyx() 1339 helplen = len(self.helplines()) 1340 mainlen = maxy - helplen - 12 1341 if mainlen < 1: 1342 raise error.Abort( 1343 _(b"terminal dimensions %d by %d too small for curses histedit") 1344 % (maxy, maxx), 1345 hint=_( 1346 b"enlarge your terminal or use --config ui.interface=text" 1347 ), 1348 ) 1349 return { 1350 b'commit': (12, maxx), 1351 b'help': (helplen, maxx), 1352 b'main': (mainlen, maxx), 1353 } 1354 1355 def display_pos_to_rule_pos(self, display_pos): 1356 """Converts a position in display order to rule order. 1357 1358 The `display_pos` is the order from the top in display order, not 1359 considering which items are currently visible on the screen. Thus, 1360 `display_pos=0` is the item at the top (possibly after scrolling to 1361 the top) 1362 """ 1363 if self.later_on_top: 1364 return len(self.rules) - 1 - display_pos 1365 else: 1366 return display_pos 1367 1368 def render_rules(self, rulesscr): 1369 start = self.modes[MODE_RULES][b'line_offset'] 1370 1371 conflicts = [r.ctx for r in self.rules if r.conflicts] 1372 if len(conflicts) > 0: 1373 line = b"potential conflict in %s" % b','.join( 1374 map(pycompat.bytestr, conflicts) 1375 ) 1376 addln(rulesscr, -1, 0, line, curses.color_pair(COLOR_WARN)) 1377 1378 for display_pos in range(start, len(self.rules)): 1379 y = display_pos - start 1380 if y < 0 or y >= self.page_height: 1381 continue 1382 rule_pos = self.display_pos_to_rule_pos(display_pos) 1383 rule = self.rules[rule_pos] 1384 if len(rule.conflicts) > 0: 1385 rulesscr.addstr(y, 0, b" ", curses.color_pair(COLOR_WARN)) 1386 else: 1387 rulesscr.addstr(y, 0, b" ", curses.COLOR_BLACK) 1388 1389 if display_pos == self.selected: 1390 rollcolor = COLOR_ROLL_SELECTED 1391 addln(rulesscr, y, 2, rule, curses.color_pair(COLOR_SELECTED)) 1392 elif display_pos == self.pos: 1393 rollcolor = COLOR_ROLL_CURRENT 1394 addln( 1395 rulesscr, 1396 y, 1397 2, 1398 rule, 1399 curses.color_pair(COLOR_CURRENT) | curses.A_BOLD, 1400 ) 1401 else: 1402 rollcolor = COLOR_ROLL 1403 addln(rulesscr, y, 2, rule) 1404 1405 if rule.action == b'roll': 1406 rulesscr.addstr( 1407 y, 1408 2 + len(rule.prefix), 1409 rule.desc, 1410 curses.color_pair(rollcolor), 1411 ) 1412 1413 rulesscr.noutrefresh() 1414 1415 def render_string(self, win, output, diffcolors=False): 1416 maxy, maxx = win.getmaxyx() 1417 length = min(maxy - 1, len(output)) 1418 for y in range(0, length): 1419 line = output[y] 1420 if diffcolors: 1421 if line and line[0] == b'+': 1422 win.addstr( 1423 y, 0, line, curses.color_pair(COLOR_DIFF_ADD_LINE) 1424 ) 1425 elif line and line[0] == b'-': 1426 win.addstr( 1427 y, 0, line, curses.color_pair(COLOR_DIFF_DEL_LINE) 1428 ) 1429 elif line.startswith(b'@@ '): 1430 win.addstr(y, 0, line, curses.color_pair(COLOR_DIFF_OFFSET)) 1431 else: 1432 win.addstr(y, 0, line) 1433 else: 1434 win.addstr(y, 0, line) 1435 win.noutrefresh() 1436 1437 def render_patch(self, win): 1438 start = self.modes[MODE_PATCH][b'line_offset'] 1439 content = self.modes[MODE_PATCH][b'patchcontents'] 1440 self.render_string(win, content[start:], diffcolors=True) 1441 1442 def event(self, ch): 1443 """Change state based on the current character input 1444 1445 This takes the current state and based on the current character input from 1446 the user we change the state. 1447 """ 1448 oldpos = self.pos 1449 1450 if ch in (curses.KEY_RESIZE, b"KEY_RESIZE"): 1451 return E_RESIZE 1452 1453 lookup_ch = ch 1454 if ch is not None and b'0' <= ch <= b'9': 1455 lookup_ch = b'0' 1456 1457 curmode, prevmode = self.mode 1458 action = KEYTABLE[curmode].get( 1459 lookup_ch, KEYTABLE[b'global'].get(lookup_ch) 1460 ) 1461 if action is None: 1462 return 1463 if action in (b'down', b'move-down'): 1464 newpos = min(oldpos + 1, len(self.rules) - 1) 1465 self.move_cursor(oldpos, newpos) 1466 if self.selected is not None or action == b'move-down': 1467 self.swap(oldpos, newpos) 1468 elif action in (b'up', b'move-up'): 1469 newpos = max(0, oldpos - 1) 1470 self.move_cursor(oldpos, newpos) 1471 if self.selected is not None or action == b'move-up': 1472 self.swap(oldpos, newpos) 1473 elif action == b'next-action': 1474 self.cycle_action(oldpos, next=True) 1475 elif action == b'prev-action': 1476 self.cycle_action(oldpos, next=False) 1477 elif action == b'select': 1478 self.selected = oldpos if self.selected is None else None 1479 self.make_selection(self.selected) 1480 elif action == b'goto' and int(ch) < len(self.rules) <= 10: 1481 newrule = next((r for r in self.rules if r.origpos == int(ch))) 1482 self.move_cursor(oldpos, newrule.pos) 1483 if self.selected is not None: 1484 self.swap(oldpos, newrule.pos) 1485 elif action.startswith(b'action-'): 1486 self.change_action(oldpos, action[7:]) 1487 elif action == b'showpatch': 1488 self.change_mode(MODE_PATCH if curmode != MODE_PATCH else prevmode) 1489 elif action == b'help': 1490 self.change_mode(MODE_HELP if curmode != MODE_HELP else prevmode) 1491 elif action == b'quit': 1492 return E_QUIT 1493 elif action == b'histedit': 1494 return E_HISTEDIT 1495 elif action == b'page-down': 1496 return E_PAGEDOWN 1497 elif action == b'page-up': 1498 return E_PAGEUP 1499 elif action == b'line-down': 1500 return E_LINEDOWN 1501 elif action == b'line-up': 1502 return E_LINEUP 1503 1504 def patch_contents(self): 1505 repo = self.repo 1506 rule = self.rules[self.display_pos_to_rule_pos(self.pos)] 1507 displayer = logcmdutil.changesetdisplayer( 1508 repo.ui, 1509 repo, 1510 {b"patch": True, b"template": b"status"}, 1511 buffered=True, 1512 ) 1513 overrides = {(b'ui', b'verbose'): True} 1514 with repo.ui.configoverride(overrides, source=b'histedit'): 1515 displayer.show(rule.ctx) 1516 displayer.close() 1517 return displayer.hunk[rule.ctx.rev()].splitlines() 1518 1519 def move_cursor(self, oldpos, newpos): 1520 """Change the rule/changeset that the cursor is pointing to, regardless of 1521 current mode (you can switch between patches from the view patch window).""" 1522 self.pos = newpos 1523 1524 mode, _ = self.mode 1525 if mode == MODE_RULES: 1526 # Scroll through the list by updating the view for MODE_RULES, so that 1527 # even if we are not currently viewing the rules, switching back will 1528 # result in the cursor's rule being visible. 1529 modestate = self.modes[MODE_RULES] 1530 if newpos < modestate[b'line_offset']: 1531 modestate[b'line_offset'] = newpos 1532 elif newpos > modestate[b'line_offset'] + self.page_height - 1: 1533 modestate[b'line_offset'] = newpos - self.page_height + 1 1534 1535 # Reset the patch view region to the top of the new patch. 1536 self.modes[MODE_PATCH][b'line_offset'] = 0 1537 1538 def change_mode(self, mode): 1539 curmode, _ = self.mode 1540 self.mode = (mode, curmode) 1541 if mode == MODE_PATCH: 1542 self.modes[MODE_PATCH][b'patchcontents'] = self.patch_contents() 1543 1544 def make_selection(self, pos): 1545 self.selected = pos 1546 1547 def swap(self, oldpos, newpos): 1548 """Swap two positions and calculate necessary conflicts in 1549 O(|newpos-oldpos|) time""" 1550 old_rule_pos = self.display_pos_to_rule_pos(oldpos) 1551 new_rule_pos = self.display_pos_to_rule_pos(newpos) 1552 1553 rules = self.rules 1554 assert 0 <= old_rule_pos < len(rules) and 0 <= new_rule_pos < len(rules) 1555 1556 rules[old_rule_pos], rules[new_rule_pos] = ( 1557 rules[new_rule_pos], 1558 rules[old_rule_pos], 1559 ) 1560 1561 # TODO: swap should not know about histeditrule's internals 1562 rules[new_rule_pos].pos = new_rule_pos 1563 rules[old_rule_pos].pos = old_rule_pos 1564 1565 start = min(old_rule_pos, new_rule_pos) 1566 end = max(old_rule_pos, new_rule_pos) 1567 for r in pycompat.xrange(start, end + 1): 1568 rules[new_rule_pos].checkconflicts(rules[r]) 1569 rules[old_rule_pos].checkconflicts(rules[r]) 1570 1571 if self.selected: 1572 self.make_selection(newpos) 1573 1574 def change_action(self, pos, action): 1575 """Change the action state on the given position to the new action""" 1576 assert 0 <= pos < len(self.rules) 1577 self.rules[pos].action = action 1578 1579 def cycle_action(self, pos, next=False): 1580 """Changes the action state the next or the previous action from 1581 the action list""" 1582 assert 0 <= pos < len(self.rules) 1583 current = self.rules[pos].action 1584 1585 assert current in KEY_LIST 1586 1587 index = KEY_LIST.index(current) 1588 if next: 1589 index += 1 1590 else: 1591 index -= 1 1592 self.change_action(pos, KEY_LIST[index % len(KEY_LIST)]) 1593 1594 def change_view(self, delta, unit): 1595 """Change the region of whatever is being viewed (a patch or the list of 1596 changesets). 'delta' is an amount (+/- 1) and 'unit' is 'page' or 'line'.""" 1597 mode, _ = self.mode 1598 if mode != MODE_PATCH: 1599 return 1600 mode_state = self.modes[mode] 1601 num_lines = len(mode_state[b'patchcontents']) 1602 page_height = self.page_height 1603 unit = page_height if unit == b'page' else 1 1604 num_pages = 1 + (num_lines - 1) // page_height 1605 max_offset = (num_pages - 1) * page_height 1606 newline = mode_state[b'line_offset'] + delta * unit 1607 mode_state[b'line_offset'] = max(0, min(max_offset, newline)) 1608 1609 1610def _chisteditmain(repo, rules, stdscr): 1611 try: 1612 curses.use_default_colors() 1613 except curses.error: 1614 pass 1615 1616 # initialize color pattern 1617 curses.init_pair(COLOR_HELP, curses.COLOR_WHITE, curses.COLOR_BLUE) 1618 curses.init_pair(COLOR_SELECTED, curses.COLOR_BLACK, curses.COLOR_WHITE) 1619 curses.init_pair(COLOR_WARN, curses.COLOR_BLACK, curses.COLOR_YELLOW) 1620 curses.init_pair(COLOR_OK, curses.COLOR_BLACK, curses.COLOR_GREEN) 1621 curses.init_pair(COLOR_CURRENT, curses.COLOR_WHITE, curses.COLOR_MAGENTA) 1622 curses.init_pair(COLOR_DIFF_ADD_LINE, curses.COLOR_GREEN, -1) 1623 curses.init_pair(COLOR_DIFF_DEL_LINE, curses.COLOR_RED, -1) 1624 curses.init_pair(COLOR_DIFF_OFFSET, curses.COLOR_MAGENTA, -1) 1625 curses.init_pair(COLOR_ROLL, curses.COLOR_RED, -1) 1626 curses.init_pair( 1627 COLOR_ROLL_CURRENT, curses.COLOR_BLACK, curses.COLOR_MAGENTA 1628 ) 1629 curses.init_pair(COLOR_ROLL_SELECTED, curses.COLOR_RED, curses.COLOR_WHITE) 1630 1631 # don't display the cursor 1632 try: 1633 curses.curs_set(0) 1634 except curses.error: 1635 pass 1636 1637 def drawvertwin(size, y, x): 1638 win = curses.newwin(size[0], size[1], y, x) 1639 y += size[0] 1640 return win, y, x 1641 1642 state = _chistedit_state(repo, rules, stdscr) 1643 1644 # eventloop 1645 ch = None 1646 stdscr.clear() 1647 stdscr.refresh() 1648 while True: 1649 oldmode, unused = state.mode 1650 if oldmode == MODE_INIT: 1651 state.change_mode(MODE_RULES) 1652 e = state.event(ch) 1653 1654 if e == E_QUIT: 1655 return False 1656 if e == E_HISTEDIT: 1657 return state.rules 1658 else: 1659 if e == E_RESIZE: 1660 size = screen_size() 1661 if size != stdscr.getmaxyx(): 1662 curses.resizeterm(*size) 1663 1664 sizes = state.layout() 1665 curmode, unused = state.mode 1666 if curmode != oldmode: 1667 state.page_height = sizes[b'main'][0] 1668 # Adjust the view to fit the current screen size. 1669 state.move_cursor(state.pos, state.pos) 1670 1671 # Pack the windows against the top, each pane spread across the 1672 # full width of the screen. 1673 y, x = (0, 0) 1674 helpwin, y, x = drawvertwin(sizes[b'help'], y, x) 1675 mainwin, y, x = drawvertwin(sizes[b'main'], y, x) 1676 commitwin, y, x = drawvertwin(sizes[b'commit'], y, x) 1677 1678 if e in (E_PAGEDOWN, E_PAGEUP, E_LINEDOWN, E_LINEUP): 1679 if e == E_PAGEDOWN: 1680 state.change_view(+1, b'page') 1681 elif e == E_PAGEUP: 1682 state.change_view(-1, b'page') 1683 elif e == E_LINEDOWN: 1684 state.change_view(+1, b'line') 1685 elif e == E_LINEUP: 1686 state.change_view(-1, b'line') 1687 1688 # start rendering 1689 commitwin.erase() 1690 helpwin.erase() 1691 mainwin.erase() 1692 if curmode == MODE_PATCH: 1693 state.render_patch(mainwin) 1694 elif curmode == MODE_HELP: 1695 state.render_string(mainwin, __doc__.strip().splitlines()) 1696 else: 1697 state.render_rules(mainwin) 1698 state.render_commit(commitwin) 1699 state.render_help(helpwin) 1700 curses.doupdate() 1701 # done rendering 1702 ch = encoding.strtolocal(stdscr.getkey()) 1703 1704 1705def _chistedit(ui, repo, freeargs, opts): 1706 """interactively edit changeset history via a curses interface 1707 1708 Provides a ncurses interface to histedit. Press ? in chistedit mode 1709 to see an extensive help. Requires python-curses to be installed.""" 1710 1711 if curses is None: 1712 raise error.Abort(_(b"Python curses library required")) 1713 1714 # disable color 1715 ui._colormode = None 1716 1717 try: 1718 keep = opts.get(b'keep') 1719 revs = opts.get(b'rev', [])[:] 1720 cmdutil.checkunfinished(repo) 1721 cmdutil.bailifchanged(repo) 1722 1723 revs.extend(freeargs) 1724 if not revs: 1725 defaultrev = destutil.desthistedit(ui, repo) 1726 if defaultrev is not None: 1727 revs.append(defaultrev) 1728 if len(revs) != 1: 1729 raise error.InputError( 1730 _(b'histedit requires exactly one ancestor revision') 1731 ) 1732 1733 rr = list(repo.set(b'roots(%ld)', logcmdutil.revrange(repo, revs))) 1734 if len(rr) != 1: 1735 raise error.InputError( 1736 _( 1737 b'The specified revisions must have ' 1738 b'exactly one common root' 1739 ) 1740 ) 1741 root = rr[0].node() 1742 1743 topmost = repo.dirstate.p1() 1744 revs = between(repo, root, topmost, keep) 1745 if not revs: 1746 raise error.InputError( 1747 _(b'%s is not an ancestor of working directory') % short(root) 1748 ) 1749 1750 rules = [] 1751 for i, r in enumerate(revs): 1752 rules.append(histeditrule(ui, repo[r], i)) 1753 with util.with_lc_ctype(): 1754 rc = curses.wrapper(functools.partial(_chisteditmain, repo, rules)) 1755 curses.echo() 1756 curses.endwin() 1757 if rc is False: 1758 ui.write(_(b"histedit aborted\n")) 1759 return 0 1760 if type(rc) is list: 1761 ui.status(_(b"performing changes\n")) 1762 rules = makecommands(rc) 1763 with repo.vfs(b'chistedit', b'w+') as fp: 1764 for r in rules: 1765 fp.write(r) 1766 opts[b'commands'] = fp.name 1767 return _texthistedit(ui, repo, freeargs, opts) 1768 except KeyboardInterrupt: 1769 pass 1770 return -1 1771 1772 1773@command( 1774 b'histedit', 1775 [ 1776 ( 1777 b'', 1778 b'commands', 1779 b'', 1780 _(b'read history edits from the specified file'), 1781 _(b'FILE'), 1782 ), 1783 (b'c', b'continue', False, _(b'continue an edit already in progress')), 1784 (b'', b'edit-plan', False, _(b'edit remaining actions list')), 1785 ( 1786 b'k', 1787 b'keep', 1788 False, 1789 _(b"don't strip old nodes after edit is complete"), 1790 ), 1791 (b'', b'abort', False, _(b'abort an edit in progress')), 1792 (b'o', b'outgoing', False, _(b'changesets not found in destination')), 1793 ( 1794 b'f', 1795 b'force', 1796 False, 1797 _(b'force outgoing even for unrelated repositories'), 1798 ), 1799 (b'r', b'rev', [], _(b'first revision to be edited'), _(b'REV')), 1800 ] 1801 + cmdutil.formatteropts, 1802 _(b"[OPTIONS] ([ANCESTOR] | --outgoing [URL])"), 1803 helpcategory=command.CATEGORY_CHANGE_MANAGEMENT, 1804) 1805def histedit(ui, repo, *freeargs, **opts): 1806 """interactively edit changeset history 1807 1808 This command lets you edit a linear series of changesets (up to 1809 and including the working directory, which should be clean). 1810 You can: 1811 1812 - `pick` to [re]order a changeset 1813 1814 - `drop` to omit changeset 1815 1816 - `mess` to reword the changeset commit message 1817 1818 - `fold` to combine it with the preceding changeset (using the later date) 1819 1820 - `roll` like fold, but discarding this commit's description and date 1821 1822 - `edit` to edit this changeset (preserving date) 1823 1824 - `base` to checkout changeset and apply further changesets from there 1825 1826 There are a number of ways to select the root changeset: 1827 1828 - Specify ANCESTOR directly 1829 1830 - Use --outgoing -- it will be the first linear changeset not 1831 included in destination. (See :hg:`help config.paths.default-push`) 1832 1833 - Otherwise, the value from the "histedit.defaultrev" config option 1834 is used as a revset to select the base revision when ANCESTOR is not 1835 specified. The first revision returned by the revset is used. By 1836 default, this selects the editable history that is unique to the 1837 ancestry of the working directory. 1838 1839 .. container:: verbose 1840 1841 If you use --outgoing, this command will abort if there are ambiguous 1842 outgoing revisions. For example, if there are multiple branches 1843 containing outgoing revisions. 1844 1845 Use "min(outgoing() and ::.)" or similar revset specification 1846 instead of --outgoing to specify edit target revision exactly in 1847 such ambiguous situation. See :hg:`help revsets` for detail about 1848 selecting revisions. 1849 1850 .. container:: verbose 1851 1852 Examples: 1853 1854 - A number of changes have been made. 1855 Revision 3 is no longer needed. 1856 1857 Start history editing from revision 3:: 1858 1859 hg histedit -r 3 1860 1861 An editor opens, containing the list of revisions, 1862 with specific actions specified:: 1863 1864 pick 5339bf82f0ca 3 Zworgle the foobar 1865 pick 8ef592ce7cc4 4 Bedazzle the zerlog 1866 pick 0a9639fcda9d 5 Morgify the cromulancy 1867 1868 Additional information about the possible actions 1869 to take appears below the list of revisions. 1870 1871 To remove revision 3 from the history, 1872 its action (at the beginning of the relevant line) 1873 is changed to 'drop':: 1874 1875 drop 5339bf82f0ca 3 Zworgle the foobar 1876 pick 8ef592ce7cc4 4 Bedazzle the zerlog 1877 pick 0a9639fcda9d 5 Morgify the cromulancy 1878 1879 - A number of changes have been made. 1880 Revision 2 and 4 need to be swapped. 1881 1882 Start history editing from revision 2:: 1883 1884 hg histedit -r 2 1885 1886 An editor opens, containing the list of revisions, 1887 with specific actions specified:: 1888 1889 pick 252a1af424ad 2 Blorb a morgwazzle 1890 pick 5339bf82f0ca 3 Zworgle the foobar 1891 pick 8ef592ce7cc4 4 Bedazzle the zerlog 1892 1893 To swap revision 2 and 4, its lines are swapped 1894 in the editor:: 1895 1896 pick 8ef592ce7cc4 4 Bedazzle the zerlog 1897 pick 5339bf82f0ca 3 Zworgle the foobar 1898 pick 252a1af424ad 2 Blorb a morgwazzle 1899 1900 Returns 0 on success, 1 if user intervention is required (not only 1901 for intentional "edit" command, but also for resolving unexpected 1902 conflicts). 1903 """ 1904 opts = pycompat.byteskwargs(opts) 1905 1906 # kludge: _chistedit only works for starting an edit, not aborting 1907 # or continuing, so fall back to regular _texthistedit for those 1908 # operations. 1909 if ui.interface(b'histedit') == b'curses' and _getgoal(opts) == goalnew: 1910 return _chistedit(ui, repo, freeargs, opts) 1911 return _texthistedit(ui, repo, freeargs, opts) 1912 1913 1914def _texthistedit(ui, repo, freeargs, opts): 1915 state = histeditstate(repo) 1916 with repo.wlock() as wlock, repo.lock() as lock: 1917 state.wlock = wlock 1918 state.lock = lock 1919 _histedit(ui, repo, state, freeargs, opts) 1920 1921 1922goalcontinue = b'continue' 1923goalabort = b'abort' 1924goaleditplan = b'edit-plan' 1925goalnew = b'new' 1926 1927 1928def _getgoal(opts): 1929 if opts.get(b'continue'): 1930 return goalcontinue 1931 if opts.get(b'abort'): 1932 return goalabort 1933 if opts.get(b'edit_plan'): 1934 return goaleditplan 1935 return goalnew 1936 1937 1938def _readfile(ui, path): 1939 if path == b'-': 1940 with ui.timeblockedsection(b'histedit'): 1941 return ui.fin.read() 1942 else: 1943 with open(path, b'rb') as f: 1944 return f.read() 1945 1946 1947def _validateargs(ui, repo, freeargs, opts, goal, rules, revs): 1948 # TODO only abort if we try to histedit mq patches, not just 1949 # blanket if mq patches are applied somewhere 1950 mq = getattr(repo, 'mq', None) 1951 if mq and mq.applied: 1952 raise error.StateError(_(b'source has mq patches applied')) 1953 1954 # basic argument incompatibility processing 1955 outg = opts.get(b'outgoing') 1956 editplan = opts.get(b'edit_plan') 1957 abort = opts.get(b'abort') 1958 force = opts.get(b'force') 1959 if force and not outg: 1960 raise error.InputError(_(b'--force only allowed with --outgoing')) 1961 if goal == b'continue': 1962 if any((outg, abort, revs, freeargs, rules, editplan)): 1963 raise error.InputError(_(b'no arguments allowed with --continue')) 1964 elif goal == b'abort': 1965 if any((outg, revs, freeargs, rules, editplan)): 1966 raise error.InputError(_(b'no arguments allowed with --abort')) 1967 elif goal == b'edit-plan': 1968 if any((outg, revs, freeargs)): 1969 raise error.InputError( 1970 _(b'only --commands argument allowed with --edit-plan') 1971 ) 1972 else: 1973 if outg: 1974 if revs: 1975 raise error.InputError( 1976 _(b'no revisions allowed with --outgoing') 1977 ) 1978 if len(freeargs) > 1: 1979 raise error.InputError( 1980 _(b'only one repo argument allowed with --outgoing') 1981 ) 1982 else: 1983 revs.extend(freeargs) 1984 if len(revs) == 0: 1985 defaultrev = destutil.desthistedit(ui, repo) 1986 if defaultrev is not None: 1987 revs.append(defaultrev) 1988 1989 if len(revs) != 1: 1990 raise error.InputError( 1991 _(b'histedit requires exactly one ancestor revision') 1992 ) 1993 1994 1995def _histedit(ui, repo, state, freeargs, opts): 1996 fm = ui.formatter(b'histedit', opts) 1997 fm.startitem() 1998 goal = _getgoal(opts) 1999 revs = opts.get(b'rev', []) 2000 nobackup = not ui.configbool(b'rewrite', b'backup-bundle') 2001 rules = opts.get(b'commands', b'') 2002 state.keep = opts.get(b'keep', False) 2003 2004 _validateargs(ui, repo, freeargs, opts, goal, rules, revs) 2005 2006 hastags = False 2007 if revs: 2008 revs = logcmdutil.revrange(repo, revs) 2009 ctxs = [repo[rev] for rev in revs] 2010 for ctx in ctxs: 2011 tags = [tag for tag in ctx.tags() if tag != b'tip'] 2012 if not hastags: 2013 hastags = len(tags) 2014 if hastags: 2015 if ui.promptchoice( 2016 _( 2017 b'warning: tags associated with the given' 2018 b' changeset will be lost after histedit.\n' 2019 b'do you want to continue (yN)? $$ &Yes $$ &No' 2020 ), 2021 default=1, 2022 ): 2023 raise error.CanceledError(_(b'histedit cancelled\n')) 2024 # rebuild state 2025 if goal == goalcontinue: 2026 state.read() 2027 state = bootstrapcontinue(ui, state, opts) 2028 elif goal == goaleditplan: 2029 _edithisteditplan(ui, repo, state, rules) 2030 return 2031 elif goal == goalabort: 2032 _aborthistedit(ui, repo, state, nobackup=nobackup) 2033 return 2034 else: 2035 # goal == goalnew 2036 _newhistedit(ui, repo, state, revs, freeargs, opts) 2037 2038 _continuehistedit(ui, repo, state) 2039 _finishhistedit(ui, repo, state, fm) 2040 fm.end() 2041 2042 2043def _continuehistedit(ui, repo, state): 2044 """This function runs after either: 2045 - bootstrapcontinue (if the goal is 'continue') 2046 - _newhistedit (if the goal is 'new') 2047 """ 2048 # preprocess rules so that we can hide inner folds from the user 2049 # and only show one editor 2050 actions = state.actions[:] 2051 for idx, (action, nextact) in enumerate(zip(actions, actions[1:] + [None])): 2052 if action.verb == b'fold' and nextact and nextact.verb == b'fold': 2053 state.actions[idx].__class__ = _multifold 2054 2055 # Force an initial state file write, so the user can run --abort/continue 2056 # even if there's an exception before the first transaction serialize. 2057 state.write() 2058 2059 tr = None 2060 # Don't use singletransaction by default since it rolls the entire 2061 # transaction back if an unexpected exception happens (like a 2062 # pretxncommit hook throws, or the user aborts the commit msg editor). 2063 if ui.configbool(b"histedit", b"singletransaction"): 2064 # Don't use a 'with' for the transaction, since actions may close 2065 # and reopen a transaction. For example, if the action executes an 2066 # external process it may choose to commit the transaction first. 2067 tr = repo.transaction(b'histedit') 2068 progress = ui.makeprogress( 2069 _(b"editing"), unit=_(b'changes'), total=len(state.actions) 2070 ) 2071 with progress, util.acceptintervention(tr): 2072 while state.actions: 2073 state.write(tr=tr) 2074 actobj = state.actions[0] 2075 progress.increment(item=actobj.torule()) 2076 ui.debug( 2077 b'histedit: processing %s %s\n' % (actobj.verb, actobj.torule()) 2078 ) 2079 parentctx, replacement_ = actobj.run() 2080 state.parentctxnode = parentctx.node() 2081 state.replacements.extend(replacement_) 2082 state.actions.pop(0) 2083 2084 state.write() 2085 2086 2087def _finishhistedit(ui, repo, state, fm): 2088 """This action runs when histedit is finishing its session""" 2089 mergemod.update(repo[state.parentctxnode]) 2090 2091 mapping, tmpnodes, created, ntm = processreplacement(state) 2092 if mapping: 2093 for prec, succs in pycompat.iteritems(mapping): 2094 if not succs: 2095 ui.debug(b'histedit: %s is dropped\n' % short(prec)) 2096 else: 2097 ui.debug( 2098 b'histedit: %s is replaced by %s\n' 2099 % (short(prec), short(succs[0])) 2100 ) 2101 if len(succs) > 1: 2102 m = b'histedit: %s' 2103 for n in succs[1:]: 2104 ui.debug(m % short(n)) 2105 2106 if not state.keep: 2107 if mapping: 2108 movetopmostbookmarks(repo, state.topmost, ntm) 2109 # TODO update mq state 2110 else: 2111 mapping = {} 2112 2113 for n in tmpnodes: 2114 if n in repo: 2115 mapping[n] = () 2116 2117 # remove entries about unknown nodes 2118 has_node = repo.unfiltered().changelog.index.has_node 2119 mapping = { 2120 k: v 2121 for k, v in mapping.items() 2122 if has_node(k) and all(has_node(n) for n in v) 2123 } 2124 scmutil.cleanupnodes(repo, mapping, b'histedit') 2125 hf = fm.hexfunc 2126 fl = fm.formatlist 2127 fd = fm.formatdict 2128 nodechanges = fd( 2129 { 2130 hf(oldn): fl([hf(n) for n in newn], name=b'node') 2131 for oldn, newn in pycompat.iteritems(mapping) 2132 }, 2133 key=b"oldnode", 2134 value=b"newnodes", 2135 ) 2136 fm.data(nodechanges=nodechanges) 2137 2138 state.clear() 2139 if os.path.exists(repo.sjoin(b'undo')): 2140 os.unlink(repo.sjoin(b'undo')) 2141 if repo.vfs.exists(b'histedit-last-edit.txt'): 2142 repo.vfs.unlink(b'histedit-last-edit.txt') 2143 2144 2145def _aborthistedit(ui, repo, state, nobackup=False): 2146 try: 2147 state.read() 2148 __, leafs, tmpnodes, __ = processreplacement(state) 2149 ui.debug(b'restore wc to old parent %s\n' % short(state.topmost)) 2150 2151 # Recover our old commits if necessary 2152 if not state.topmost in repo and state.backupfile: 2153 backupfile = repo.vfs.join(state.backupfile) 2154 f = hg.openpath(ui, backupfile) 2155 gen = exchange.readbundle(ui, f, backupfile) 2156 with repo.transaction(b'histedit.abort') as tr: 2157 bundle2.applybundle( 2158 repo, 2159 gen, 2160 tr, 2161 source=b'histedit', 2162 url=b'bundle:' + backupfile, 2163 ) 2164 2165 os.remove(backupfile) 2166 2167 # check whether we should update away 2168 if repo.unfiltered().revs( 2169 b'parents() and (%n or %ln::)', 2170 state.parentctxnode, 2171 leafs | tmpnodes, 2172 ): 2173 hg.clean(repo, state.topmost, show_stats=True, quietempty=True) 2174 cleanupnode(ui, repo, tmpnodes, nobackup=nobackup) 2175 cleanupnode(ui, repo, leafs, nobackup=nobackup) 2176 except Exception: 2177 if state.inprogress(): 2178 ui.warn( 2179 _( 2180 b'warning: encountered an exception during histedit ' 2181 b'--abort; the repository may not have been completely ' 2182 b'cleaned up\n' 2183 ) 2184 ) 2185 raise 2186 finally: 2187 state.clear() 2188 2189 2190def hgaborthistedit(ui, repo): 2191 state = histeditstate(repo) 2192 nobackup = not ui.configbool(b'rewrite', b'backup-bundle') 2193 with repo.wlock() as wlock, repo.lock() as lock: 2194 state.wlock = wlock 2195 state.lock = lock 2196 _aborthistedit(ui, repo, state, nobackup=nobackup) 2197 2198 2199def _edithisteditplan(ui, repo, state, rules): 2200 state.read() 2201 if not rules: 2202 comment = geteditcomment( 2203 ui, short(state.parentctxnode), short(state.topmost) 2204 ) 2205 rules = ruleeditor(repo, ui, state.actions, comment) 2206 else: 2207 rules = _readfile(ui, rules) 2208 actions = parserules(rules, state) 2209 ctxs = [repo[act.node] for act in state.actions if act.node] 2210 warnverifyactions(ui, repo, actions, state, ctxs) 2211 state.actions = actions 2212 state.write() 2213 2214 2215def _newhistedit(ui, repo, state, revs, freeargs, opts): 2216 outg = opts.get(b'outgoing') 2217 rules = opts.get(b'commands', b'') 2218 force = opts.get(b'force') 2219 2220 cmdutil.checkunfinished(repo) 2221 cmdutil.bailifchanged(repo) 2222 2223 topmost = repo.dirstate.p1() 2224 if outg: 2225 if freeargs: 2226 remote = freeargs[0] 2227 else: 2228 remote = None 2229 root = findoutgoing(ui, repo, remote, force, opts) 2230 else: 2231 rr = list(repo.set(b'roots(%ld)', logcmdutil.revrange(repo, revs))) 2232 if len(rr) != 1: 2233 raise error.InputError( 2234 _( 2235 b'The specified revisions must have ' 2236 b'exactly one common root' 2237 ) 2238 ) 2239 root = rr[0].node() 2240 2241 revs = between(repo, root, topmost, state.keep) 2242 if not revs: 2243 raise error.InputError( 2244 _(b'%s is not an ancestor of working directory') % short(root) 2245 ) 2246 2247 ctxs = [repo[r] for r in revs] 2248 2249 wctx = repo[None] 2250 # Please don't ask me why `ancestors` is this value. I figured it 2251 # out with print-debugging, not by actually understanding what the 2252 # merge code is doing. :( 2253 ancs = [repo[b'.']] 2254 # Sniff-test to make sure we won't collide with untracked files in 2255 # the working directory. If we don't do this, we can get a 2256 # collision after we've started histedit and backing out gets ugly 2257 # for everyone, especially the user. 2258 for c in [ctxs[0].p1()] + ctxs: 2259 try: 2260 mergemod.calculateupdates( 2261 repo, 2262 wctx, 2263 c, 2264 ancs, 2265 # These parameters were determined by print-debugging 2266 # what happens later on inside histedit. 2267 branchmerge=False, 2268 force=False, 2269 acceptremote=False, 2270 followcopies=False, 2271 ) 2272 except error.Abort: 2273 raise error.StateError( 2274 _( 2275 b"untracked files in working directory conflict with files in %s" 2276 ) 2277 % c 2278 ) 2279 2280 if not rules: 2281 comment = geteditcomment(ui, short(root), short(topmost)) 2282 actions = [pick(state, r) for r in revs] 2283 rules = ruleeditor(repo, ui, actions, comment) 2284 else: 2285 rules = _readfile(ui, rules) 2286 actions = parserules(rules, state) 2287 warnverifyactions(ui, repo, actions, state, ctxs) 2288 2289 parentctxnode = repo[root].p1().node() 2290 2291 state.parentctxnode = parentctxnode 2292 state.actions = actions 2293 state.topmost = topmost 2294 state.replacements = [] 2295 2296 ui.log( 2297 b"histedit", 2298 b"%d actions to histedit\n", 2299 len(actions), 2300 histedit_num_actions=len(actions), 2301 ) 2302 2303 # Create a backup so we can always abort completely. 2304 backupfile = None 2305 if not obsolete.isenabled(repo, obsolete.createmarkersopt): 2306 backupfile = repair.backupbundle( 2307 repo, [parentctxnode], [topmost], root, b'histedit' 2308 ) 2309 state.backupfile = backupfile 2310 2311 2312def _getsummary(ctx): 2313 # a common pattern is to extract the summary but default to the empty 2314 # string 2315 summary = ctx.description() or b'' 2316 if summary: 2317 summary = summary.splitlines()[0] 2318 return summary 2319 2320 2321def bootstrapcontinue(ui, state, opts): 2322 repo = state.repo 2323 2324 ms = mergestatemod.mergestate.read(repo) 2325 mergeutil.checkunresolved(ms) 2326 2327 if state.actions: 2328 actobj = state.actions.pop(0) 2329 2330 if _isdirtywc(repo): 2331 actobj.continuedirty() 2332 if _isdirtywc(repo): 2333 abortdirty() 2334 2335 parentctx, replacements = actobj.continueclean() 2336 2337 state.parentctxnode = parentctx.node() 2338 state.replacements.extend(replacements) 2339 2340 return state 2341 2342 2343def between(repo, old, new, keep): 2344 """select and validate the set of revision to edit 2345 2346 When keep is false, the specified set can't have children.""" 2347 revs = repo.revs(b'%n::%n', old, new) 2348 if revs and not keep: 2349 rewriteutil.precheck(repo, revs, b'edit') 2350 if repo.revs(b'(%ld) and merge()', revs): 2351 raise error.StateError( 2352 _(b'cannot edit history that contains merges') 2353 ) 2354 return pycompat.maplist(repo.changelog.node, revs) 2355 2356 2357def ruleeditor(repo, ui, actions, editcomment=b""): 2358 """open an editor to edit rules 2359 2360 rules are in the format [ [act, ctx], ...] like in state.rules 2361 """ 2362 if repo.ui.configbool(b"experimental", b"histedit.autoverb"): 2363 newact = util.sortdict() 2364 for act in actions: 2365 ctx = repo[act.node] 2366 summary = _getsummary(ctx) 2367 fword = summary.split(b' ', 1)[0].lower() 2368 added = False 2369 2370 # if it doesn't end with the special character '!' just skip this 2371 if fword.endswith(b'!'): 2372 fword = fword[:-1] 2373 if fword in primaryactions | secondaryactions | tertiaryactions: 2374 act.verb = fword 2375 # get the target summary 2376 tsum = summary[len(fword) + 1 :].lstrip() 2377 # safe but slow: reverse iterate over the actions so we 2378 # don't clash on two commits having the same summary 2379 for na, l in reversed(list(pycompat.iteritems(newact))): 2380 actx = repo[na.node] 2381 asum = _getsummary(actx) 2382 if asum == tsum: 2383 added = True 2384 l.append(act) 2385 break 2386 2387 if not added: 2388 newact[act] = [] 2389 2390 # copy over and flatten the new list 2391 actions = [] 2392 for na, l in pycompat.iteritems(newact): 2393 actions.append(na) 2394 actions += l 2395 2396 rules = b'\n'.join([act.torule() for act in actions]) 2397 rules += b'\n\n' 2398 rules += editcomment 2399 rules = ui.edit( 2400 rules, 2401 ui.username(), 2402 {b'prefix': b'histedit'}, 2403 repopath=repo.path, 2404 action=b'histedit', 2405 ) 2406 2407 # Save edit rules in .hg/histedit-last-edit.txt in case 2408 # the user needs to ask for help after something 2409 # surprising happens. 2410 with repo.vfs(b'histedit-last-edit.txt', b'wb') as f: 2411 f.write(rules) 2412 2413 return rules 2414 2415 2416def parserules(rules, state): 2417 """Read the histedit rules string and return list of action objects""" 2418 rules = [ 2419 l 2420 for l in (r.strip() for r in rules.splitlines()) 2421 if l and not l.startswith(b'#') 2422 ] 2423 actions = [] 2424 for r in rules: 2425 if b' ' not in r: 2426 raise error.ParseError(_(b'malformed line "%s"') % r) 2427 verb, rest = r.split(b' ', 1) 2428 2429 if verb not in actiontable: 2430 raise error.ParseError(_(b'unknown action "%s"') % verb) 2431 2432 action = actiontable[verb].fromrule(state, rest) 2433 actions.append(action) 2434 return actions 2435 2436 2437def warnverifyactions(ui, repo, actions, state, ctxs): 2438 try: 2439 verifyactions(actions, state, ctxs) 2440 except error.ParseError: 2441 if repo.vfs.exists(b'histedit-last-edit.txt'): 2442 ui.warn( 2443 _( 2444 b'warning: histedit rules saved ' 2445 b'to: .hg/histedit-last-edit.txt\n' 2446 ) 2447 ) 2448 raise 2449 2450 2451def verifyactions(actions, state, ctxs): 2452 """Verify that there exists exactly one action per given changeset and 2453 other constraints. 2454 2455 Will abort if there are to many or too few rules, a malformed rule, 2456 or a rule on a changeset outside of the user-given range. 2457 """ 2458 expected = {c.node() for c in ctxs} 2459 seen = set() 2460 prev = None 2461 2462 if actions and actions[0].verb in [b'roll', b'fold']: 2463 raise error.ParseError( 2464 _(b'first changeset cannot use verb "%s"') % actions[0].verb 2465 ) 2466 2467 for action in actions: 2468 action.verify(prev, expected, seen) 2469 prev = action 2470 if action.node is not None: 2471 seen.add(action.node) 2472 missing = sorted(expected - seen) # sort to stabilize output 2473 2474 if state.repo.ui.configbool(b'histedit', b'dropmissing'): 2475 if len(actions) == 0: 2476 raise error.ParseError( 2477 _(b'no rules provided'), 2478 hint=_(b'use strip extension to remove commits'), 2479 ) 2480 2481 drops = [drop(state, n) for n in missing] 2482 # put the in the beginning so they execute immediately and 2483 # don't show in the edit-plan in the future 2484 actions[:0] = drops 2485 elif missing: 2486 raise error.ParseError( 2487 _(b'missing rules for changeset %s') % short(missing[0]), 2488 hint=_( 2489 b'use "drop %s" to discard, see also: ' 2490 b"'hg help -e histedit.config'" 2491 ) 2492 % short(missing[0]), 2493 ) 2494 2495 2496def adjustreplacementsfrommarkers(repo, oldreplacements): 2497 """Adjust replacements from obsolescence markers 2498 2499 Replacements structure is originally generated based on 2500 histedit's state and does not account for changes that are 2501 not recorded there. This function fixes that by adding 2502 data read from obsolescence markers""" 2503 if not obsolete.isenabled(repo, obsolete.createmarkersopt): 2504 return oldreplacements 2505 2506 unfi = repo.unfiltered() 2507 get_rev = unfi.changelog.index.get_rev 2508 obsstore = repo.obsstore 2509 newreplacements = list(oldreplacements) 2510 oldsuccs = [r[1] for r in oldreplacements] 2511 # successors that have already been added to succstocheck once 2512 seensuccs = set().union( 2513 *oldsuccs 2514 ) # create a set from an iterable of tuples 2515 succstocheck = list(seensuccs) 2516 while succstocheck: 2517 n = succstocheck.pop() 2518 missing = get_rev(n) is None 2519 markers = obsstore.successors.get(n, ()) 2520 if missing and not markers: 2521 # dead end, mark it as such 2522 newreplacements.append((n, ())) 2523 for marker in markers: 2524 nsuccs = marker[1] 2525 newreplacements.append((n, nsuccs)) 2526 for nsucc in nsuccs: 2527 if nsucc not in seensuccs: 2528 seensuccs.add(nsucc) 2529 succstocheck.append(nsucc) 2530 2531 return newreplacements 2532 2533 2534def processreplacement(state): 2535 """process the list of replacements to return 2536 2537 1) the final mapping between original and created nodes 2538 2) the list of temporary node created by histedit 2539 3) the list of new commit created by histedit""" 2540 replacements = adjustreplacementsfrommarkers(state.repo, state.replacements) 2541 allsuccs = set() 2542 replaced = set() 2543 fullmapping = {} 2544 # initialize basic set 2545 # fullmapping records all operations recorded in replacement 2546 for rep in replacements: 2547 allsuccs.update(rep[1]) 2548 replaced.add(rep[0]) 2549 fullmapping.setdefault(rep[0], set()).update(rep[1]) 2550 new = allsuccs - replaced 2551 tmpnodes = allsuccs & replaced 2552 # Reduce content fullmapping into direct relation between original nodes 2553 # and final node created during history edition 2554 # Dropped changeset are replaced by an empty list 2555 toproceed = set(fullmapping) 2556 final = {} 2557 while toproceed: 2558 for x in list(toproceed): 2559 succs = fullmapping[x] 2560 for s in list(succs): 2561 if s in toproceed: 2562 # non final node with unknown closure 2563 # We can't process this now 2564 break 2565 elif s in final: 2566 # non final node, replace with closure 2567 succs.remove(s) 2568 succs.update(final[s]) 2569 else: 2570 final[x] = succs 2571 toproceed.remove(x) 2572 # remove tmpnodes from final mapping 2573 for n in tmpnodes: 2574 del final[n] 2575 # we expect all changes involved in final to exist in the repo 2576 # turn `final` into list (topologically sorted) 2577 get_rev = state.repo.changelog.index.get_rev 2578 for prec, succs in final.items(): 2579 final[prec] = sorted(succs, key=get_rev) 2580 2581 # computed topmost element (necessary for bookmark) 2582 if new: 2583 newtopmost = sorted(new, key=state.repo.changelog.rev)[-1] 2584 elif not final: 2585 # Nothing rewritten at all. we won't need `newtopmost` 2586 # It is the same as `oldtopmost` and `processreplacement` know it 2587 newtopmost = None 2588 else: 2589 # every body died. The newtopmost is the parent of the root. 2590 r = state.repo.changelog.rev 2591 newtopmost = state.repo[sorted(final, key=r)[0]].p1().node() 2592 2593 return final, tmpnodes, new, newtopmost 2594 2595 2596def movetopmostbookmarks(repo, oldtopmost, newtopmost): 2597 """Move bookmark from oldtopmost to newly created topmost 2598 2599 This is arguably a feature and we may only want that for the active 2600 bookmark. But the behavior is kept compatible with the old version for now. 2601 """ 2602 if not oldtopmost or not newtopmost: 2603 return 2604 oldbmarks = repo.nodebookmarks(oldtopmost) 2605 if oldbmarks: 2606 with repo.lock(), repo.transaction(b'histedit') as tr: 2607 marks = repo._bookmarks 2608 changes = [] 2609 for name in oldbmarks: 2610 changes.append((name, newtopmost)) 2611 marks.applychanges(repo, tr, changes) 2612 2613 2614def cleanupnode(ui, repo, nodes, nobackup=False): 2615 """strip a group of nodes from the repository 2616 2617 The set of node to strip may contains unknown nodes.""" 2618 with repo.lock(): 2619 # do not let filtering get in the way of the cleanse 2620 # we should probably get rid of obsolescence marker created during the 2621 # histedit, but we currently do not have such information. 2622 repo = repo.unfiltered() 2623 # Find all nodes that need to be stripped 2624 # (we use %lr instead of %ln to silently ignore unknown items) 2625 has_node = repo.changelog.index.has_node 2626 nodes = sorted(n for n in nodes if has_node(n)) 2627 roots = [c.node() for c in repo.set(b"roots(%ln)", nodes)] 2628 if roots: 2629 backup = not nobackup 2630 repair.strip(ui, repo, roots, backup=backup) 2631 2632 2633def stripwrapper(orig, ui, repo, nodelist, *args, **kwargs): 2634 if isinstance(nodelist, bytes): 2635 nodelist = [nodelist] 2636 state = histeditstate(repo) 2637 if state.inprogress(): 2638 state.read() 2639 histedit_nodes = { 2640 action.node for action in state.actions if action.node 2641 } 2642 common_nodes = histedit_nodes & set(nodelist) 2643 if common_nodes: 2644 raise error.Abort( 2645 _(b"histedit in progress, can't strip %s") 2646 % b', '.join(short(x) for x in common_nodes) 2647 ) 2648 return orig(ui, repo, nodelist, *args, **kwargs) 2649 2650 2651extensions.wrapfunction(repair, b'strip', stripwrapper) 2652 2653 2654def summaryhook(ui, repo): 2655 state = histeditstate(repo) 2656 if not state.inprogress(): 2657 return 2658 state.read() 2659 if state.actions: 2660 # i18n: column positioning for "hg summary" 2661 ui.write( 2662 _(b'hist: %s (histedit --continue)\n') 2663 % ( 2664 ui.label(_(b'%d remaining'), b'histedit.remaining') 2665 % len(state.actions) 2666 ) 2667 ) 2668 2669 2670def extsetup(ui): 2671 cmdutil.summaryhooks.add(b'histedit', summaryhook) 2672 statemod.addunfinished( 2673 b'histedit', 2674 fname=b'histedit-state', 2675 allowcommit=True, 2676 continueflag=True, 2677 abortfunc=hgaborthistedit, 2678 ) 2679