1# rewriteutil.py - utility functions for rewriting changesets
2#
3# Copyright 2017 Octobus <contact@octobus.net>
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
8from __future__ import absolute_import
9
10import re
11
12from .i18n import _
13from .node import (
14    hex,
15    nullrev,
16)
17
18from . import (
19    error,
20    node,
21    obsolete,
22    obsutil,
23    revset,
24    scmutil,
25    util,
26)
27
28
29NODE_RE = re.compile(br'\b[0-9a-f]{6,64}\b')
30
31
32def _formatrevs(repo, revs, maxrevs=4):
33    """returns a string summarizing revisions in a decent size
34
35    If there are few enough revisions, we list them all. Otherwise we display a
36    summary of the form:
37
38        1ea73414a91b and 5 others
39    """
40    tonode = repo.changelog.node
41    numrevs = len(revs)
42    if numrevs < maxrevs:
43        shorts = [node.short(tonode(r)) for r in revs]
44        summary = b', '.join(shorts)
45    else:
46        first = revs.first()
47        summary = _(b'%s and %d others')
48        summary %= (node.short(tonode(first)), numrevs - 1)
49    return summary
50
51
52def precheck(repo, revs, action=b'rewrite'):
53    """check if revs can be rewritten
54    action is used to control the error message.
55
56    Make sure this function is called after taking the lock.
57    """
58    if nullrev in revs:
59        msg = _(b"cannot %s the null revision") % action
60        hint = _(b"no changeset checked out")
61        raise error.InputError(msg, hint=hint)
62
63    if any(util.safehasattr(r, 'rev') for r in revs):
64        repo.ui.develwarn(b"rewriteutil.precheck called with ctx not revs")
65        revs = (r.rev() for r in revs)
66
67    if len(repo[None].parents()) > 1:
68        raise error.StateError(
69            _(b"cannot %s changesets while merging") % action
70        )
71
72    publicrevs = repo.revs(b'%ld and public()', revs)
73    if publicrevs:
74        summary = _formatrevs(repo, publicrevs)
75        msg = _(b"cannot %s public changesets: %s") % (action, summary)
76        hint = _(b"see 'hg help phases' for details")
77        raise error.InputError(msg, hint=hint)
78
79    newunstable = disallowednewunstable(repo, revs)
80    if newunstable:
81        hint = _(b"see 'hg help evolution.instability'")
82        raise error.InputError(
83            _(b"cannot %s changeset, as that will orphan %d descendants")
84            % (action, len(newunstable)),
85            hint=hint,
86        )
87
88    if not obsolete.isenabled(repo, obsolete.allowdivergenceopt):
89        new_divergence = _find_new_divergence(repo, revs)
90        if new_divergence:
91            local_ctx, other_ctx, base_ctx = new_divergence
92            msg = _(
93                b'cannot %s %s, as that creates content-divergence with %s'
94            ) % (
95                action,
96                local_ctx,
97                other_ctx,
98            )
99            if local_ctx.rev() != base_ctx.rev():
100                msg += _(b', from %s') % base_ctx
101            if repo.ui.verbose:
102                if local_ctx.rev() != base_ctx.rev():
103                    msg += _(
104                        b'\n    changeset %s is a successor of ' b'changeset %s'
105                    ) % (local_ctx, base_ctx)
106                msg += _(
107                    b'\n    changeset %s already has a successor in '
108                    b'changeset %s\n'
109                    b'    rewriting changeset %s would create '
110                    b'"content-divergence"\n'
111                    b'    set experimental.evolution.allowdivergence=True to '
112                    b'skip this check'
113                ) % (base_ctx, other_ctx, local_ctx)
114                raise error.InputError(
115                    msg,
116                    hint=_(
117                        b"see 'hg help evolution.instability' for details on content-divergence"
118                    ),
119                )
120            else:
121                raise error.InputError(
122                    msg,
123                    hint=_(
124                        b"add --verbose for details or see "
125                        b"'hg help evolution.instability'"
126                    ),
127                )
128
129
130def disallowednewunstable(repo, revs):
131    """Checks whether editing the revs will create new unstable changesets and
132    are we allowed to create them.
133
134    To allow new unstable changesets, set the config:
135        `experimental.evolution.allowunstable=True`
136    """
137    allowunstable = obsolete.isenabled(repo, obsolete.allowunstableopt)
138    if allowunstable:
139        return revset.baseset()
140    return repo.revs(b"(%ld::) - %ld", revs, revs)
141
142
143def _find_new_divergence(repo, revs):
144    obsrevs = repo.revs(b'%ld and obsolete()', revs)
145    for r in obsrevs:
146        div = find_new_divergence_from(repo, repo[r])
147        if div:
148            return (repo[r], repo[div[0]], repo.unfiltered()[div[1]])
149    return None
150
151
152def find_new_divergence_from(repo, ctx):
153    """return divergent revision if rewriting an obsolete cset (ctx) will
154    create divergence
155
156    Returns (<other node>, <common ancestor node>) or None
157    """
158    if not ctx.obsolete():
159        return None
160    # We need to check two cases that can cause divergence:
161    # case 1: the rev being rewritten has a non-obsolete successor (easily
162    #     detected by successorssets)
163    sset = obsutil.successorssets(repo, ctx.node())
164    if sset:
165        return (sset[0][0], ctx.node())
166    else:
167        # case 2: one of the precursors of the rev being revived has a
168        #     non-obsolete successor (we need divergentsets for this)
169        divsets = obsutil.divergentsets(repo, ctx)
170        if divsets:
171            nsuccset = divsets[0][b'divergentnodes']
172            prec = divsets[0][b'commonpredecessor']
173            return (nsuccset[0], prec)
174        return None
175
176
177def skip_empty_successor(ui, command):
178    empty_successor = ui.config(b'rewrite', b'empty-successor')
179    if empty_successor == b'skip':
180        return True
181    elif empty_successor == b'keep':
182        return False
183    else:
184        raise error.ConfigError(
185            _(
186                b"%s doesn't know how to handle config "
187                b"rewrite.empty-successor=%s (only 'skip' and 'keep' are "
188                b"supported)"
189            )
190            % (command, empty_successor)
191        )
192
193
194def update_hash_refs(repo, commitmsg, pending=None):
195    """Replace all obsolete commit hashes in the message with the current hash.
196
197    If the obsolete commit was split or is divergent, the hash is not replaced
198    as there's no way to know which successor to choose.
199
200    For commands that update a series of commits in the current transaction, the
201    new obsolete markers can be considered by setting ``pending`` to a mapping
202    of ``pending[oldnode] = [successor_node1, successor_node2,..]``.
203    """
204    if not pending:
205        pending = {}
206    cache = {}
207    hashes = re.findall(NODE_RE, commitmsg)
208    unfi = repo.unfiltered()
209    for h in hashes:
210        try:
211            fullnode = scmutil.resolvehexnodeidprefix(unfi, h)
212        except error.WdirUnsupported:
213            # Someone has an fffff... in a commit message we're
214            # rewriting. Don't try rewriting that.
215            continue
216        if fullnode is None:
217            continue
218        ctx = unfi[fullnode]
219        if not ctx.obsolete():
220            successors = pending.get(fullnode)
221            if successors is None:
222                continue
223            # obsutil.successorssets() returns a list of list of nodes
224            successors = [successors]
225        else:
226            successors = obsutil.successorssets(repo, ctx.node(), cache=cache)
227
228        # We can't make any assumptions about how to update the hash if the
229        # cset in question was split or diverged.
230        if len(successors) == 1 and len(successors[0]) == 1:
231            successor = successors[0][0]
232            if successor is not None:
233                newhash = hex(successor)
234                commitmsg = commitmsg.replace(h, newhash[: len(h)])
235            else:
236                repo.ui.note(
237                    _(
238                        b'The stale commit message reference to %s could '
239                        b'not be updated\n(The referenced commit was dropped)\n'
240                    )
241                    % h
242                )
243        else:
244            repo.ui.note(
245                _(
246                    b'The stale commit message reference to %s could '
247                    b'not be updated\n'
248                )
249                % h
250            )
251
252    return commitmsg
253