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