1# Copyright 2017 Octobus <contact@octobus.net>
2#
3# This software may be used and distributed according to the terms of the
4# GNU General Public License version 2 or any later version.
5"""
6Compatibility module
7"""
8
9import contextlib
10
11from mercurial.i18n import _
12from mercurial import (
13    cmdutil,
14    context,
15    copies as copiesmod,
16    dirstate,
17    error,
18    hg,
19    logcmdutil,
20    merge as mergemod,
21    node,
22    obsolete,
23    pycompat,
24    registrar,
25    scmutil,
26    util,
27)
28
29# hg <= 5.2 (c21aca51b392)
30try:
31    from mercurial import pathutil
32    dirs = pathutil.dirs
33except (AttributeError, ImportError):
34    dirs = util.dirs  # pytype: disable=module-attr
35
36# hg <= 5.4 (b7808443ed6a)
37try:
38    from mercurial import mergestate as mergestatemod
39    mergestate = mergestatemod.mergestate
40except (AttributeError, ImportError):
41    mergestate = mergemod.mergestate  # pytype: disable=module-attr
42
43from . import (
44    exthelper,
45)
46
47eh = exthelper.exthelper()
48
49# Evolution renaming compat
50
51TROUBLES = {
52    r'ORPHAN': b'orphan',
53    r'CONTENTDIVERGENT': b'content-divergent',
54    r'PHASEDIVERGENT': b'phase-divergent',
55}
56
57# XXX: Better detection of property cache
58if r'predecessors' not in dir(obsolete.obsstore):
59    @property
60    def predecessors(self):
61        return self.precursors
62
63    obsolete.obsstore.predecessors = predecessors
64
65def memfilectx(repo, ctx, fctx, flags, copied, path):
66    # XXX Would it be better at the module level?
67    varnames = context.memfilectx.__init__.__code__.co_varnames  # pytype: disable=attribute-error
68
69    if r"copysource" in varnames:
70        mctx = context.memfilectx(repo, ctx, fctx.path(), fctx.data(),
71                                  islink=b'l' in flags,
72                                  isexec=b'x' in flags,
73                                  copysource=copied.get(path))
74    # hg <= 4.9 (550a172a603b)
75    elif varnames[2] == r"changectx":
76        mctx = context.memfilectx(repo, ctx, fctx.path(), fctx.data(),
77                                  islink=b'l' in flags,
78                                  isexec=b'x' in flags,
79                                  copied=copied.get(path))  # pytype: disable=wrong-keyword-args
80    return mctx
81
82hg48 = util.safehasattr(copiesmod, 'stringutil')
83# code imported from Mercurial core at ae17555ef93f + patch
84def fixedcopytracing(repo, c1, c2, base):
85    """A complete copy-patse of copies._fullcopytrace with a one line fix to
86    handle when the base is not parent of both c1 and c2. This should be
87    converted in a compat function once https://phab.mercurial-scm.org/D3896
88    gets in and once we drop support for 4.9, this should be removed."""
89
90    from mercurial import pathutil
91    copies = copiesmod
92
93    # In certain scenarios (e.g. graft, update or rebase), base can be
94    # overridden We still need to know a real common ancestor in this case We
95    # can't just compute _c1.ancestor(_c2) and compare it to ca, because there
96    # can be multiple common ancestors, e.g. in case of bidmerge.  Because our
97    # caller may not know if the revision passed in lieu of the CA is a genuine
98    # common ancestor or not without explicitly checking it, it's better to
99    # determine that here.
100    #
101    # base.isancestorof(wc) is False, work around that
102    _c1 = c1.p1() if c1.rev() is None else c1
103    _c2 = c2.p1() if c2.rev() is None else c2
104    # an endpoint is "dirty" if it isn't a descendant of the merge base
105    # if we have a dirty endpoint, we need to trigger graft logic, and also
106    # keep track of which endpoint is dirty
107    dirtyc1 = not base.isancestorof(_c1)
108    dirtyc2 = not base.isancestorof(_c2)
109    graft = dirtyc1 or dirtyc2
110    tca = base
111    if graft:
112        tca = _c1.ancestor(_c2)
113
114    # hg <= 4.9 (dc50121126ae)
115    try:
116        limit = copies._findlimit(repo, c1, c2)  # pytype: disable=module-attr
117    except (AttributeError, TypeError):
118        limit = copies._findlimit(repo, c1.rev(), c2.rev())  # pytype: disable=module-attr
119    if limit is None:
120        # no common ancestor, no copies
121        return {}, {}, {}, {}, {}
122    repo.ui.debug(b"  searching for copies back to rev %d\n" % limit)
123
124    m1 = c1.manifest()
125    m2 = c2.manifest()
126    mb = base.manifest()
127
128    # gather data from _checkcopies:
129    # - diverge = record all diverges in this dict
130    # - copy = record all non-divergent copies in this dict
131    # - fullcopy = record all copies in this dict
132    # - incomplete = record non-divergent partial copies here
133    # - incompletediverge = record divergent partial copies here
134    diverge = {} # divergence data is shared
135    incompletediverge = {}
136    data1 = {b'copy': {},
137             b'fullcopy': {},
138             b'incomplete': {},
139             b'diverge': diverge,
140             b'incompletediverge': incompletediverge,
141             }
142    data2 = {b'copy': {},
143             b'fullcopy': {},
144             b'incomplete': {},
145             b'diverge': diverge,
146             b'incompletediverge': incompletediverge,
147             }
148
149    # find interesting file sets from manifests
150    if hg48:
151        addedinm1 = m1.filesnotin(mb, repo.narrowmatch())
152        addedinm2 = m2.filesnotin(mb, repo.narrowmatch())
153    else:
154        addedinm1 = m1.filesnotin(mb)
155        addedinm2 = m2.filesnotin(mb)
156    bothnew = sorted(addedinm1 & addedinm2)
157    if tca == base:
158        # unmatched file from base
159        u1r, u2r = copies._computenonoverlap(repo, c1, c2, addedinm1, addedinm2)  # pytype: disable=module-attr
160        u1u, u2u = u1r, u2r
161    else:
162        # unmatched file from base (DAG rotation in the graft case)
163        u1r, u2r = copies._computenonoverlap(repo, c1, c2, addedinm1, addedinm2,  # pytype: disable=module-attr
164                                             baselabel=b'base')
165        # unmatched file from topological common ancestors (no DAG rotation)
166        # need to recompute this for directory move handling when grafting
167        mta = tca.manifest()
168        if hg48:
169            m1f = m1.filesnotin(mta, repo.narrowmatch())
170            m2f = m2.filesnotin(mta, repo.narrowmatch())
171            baselabel = b'topological common ancestor'
172            u1u, u2u = copies._computenonoverlap(repo, c1, c2, m1f, m2f,  # pytype: disable=module-attr
173                                                 baselabel=baselabel)
174        else:
175            u1u, u2u = copies._computenonoverlap(repo, c1, c2, m1.filesnotin(mta),  # pytype: disable=module-attr
176                                                 m2.filesnotin(mta),
177                                                 baselabel=b'topological common ancestor')
178
179    for f in u1u:
180        copies._checkcopies(c1, c2, f, base, tca, dirtyc1, limit, data1)  # pytype: disable=module-attr
181
182    for f in u2u:
183        copies._checkcopies(c2, c1, f, base, tca, dirtyc2, limit, data2)  # pytype: disable=module-attr
184
185    copy = dict(data1[b'copy'])
186    copy.update(data2[b'copy'])
187    fullcopy = dict(data1[b'fullcopy'])
188    fullcopy.update(data2[b'fullcopy'])
189
190    if dirtyc1:
191        copies._combinecopies(data2[b'incomplete'], data1[b'incomplete'], copy, diverge,  # pytype: disable=module-attr
192                              incompletediverge)
193    else:
194        copies._combinecopies(data1[b'incomplete'], data2[b'incomplete'], copy, diverge,  # pytype: disable=module-attr
195                              incompletediverge)
196
197    renamedelete = {}
198    renamedeleteset = set()
199    divergeset = set()
200    for of, fl in list(diverge.items()):
201        if len(fl) == 1 or of in c1 or of in c2:
202            del diverge[of] # not actually divergent, or not a rename
203            if of not in c1 and of not in c2:
204                # renamed on one side, deleted on the other side, but filter
205                # out files that have been renamed and then deleted
206                renamedelete[of] = [f for f in fl if f in c1 or f in c2]
207                renamedeleteset.update(fl) # reverse map for below
208        else:
209            divergeset.update(fl) # reverse map for below
210
211    if bothnew:
212        repo.ui.debug(b"  unmatched files new in both:\n   %s\n"
213                      % b"\n   ".join(bothnew))
214    bothdiverge = {}
215    bothincompletediverge = {}
216    remainder = {}
217    both1 = {b'copy': {},
218             b'fullcopy': {},
219             b'incomplete': {},
220             b'diverge': bothdiverge,
221             b'incompletediverge': bothincompletediverge
222             }
223    both2 = {b'copy': {},
224             b'fullcopy': {},
225             b'incomplete': {},
226             b'diverge': bothdiverge,
227             b'incompletediverge': bothincompletediverge
228             }
229    for f in bothnew:
230        copies._checkcopies(c1, c2, f, base, tca, dirtyc1, limit, both1)  # pytype: disable=module-attr
231        copies._checkcopies(c2, c1, f, base, tca, dirtyc2, limit, both2)  # pytype: disable=module-attr
232
233    if dirtyc1 and dirtyc2:
234        pass
235    elif dirtyc1:
236        # incomplete copies may only be found on the "dirty" side for bothnew
237        assert not both2[b'incomplete']
238        remainder = copies._combinecopies({}, both1[b'incomplete'], copy, bothdiverge,  # pytype: disable=module-attr
239                                          bothincompletediverge)
240    elif dirtyc2:
241        assert not both1[b'incomplete']
242        remainder = copies._combinecopies({}, both2[b'incomplete'], copy, bothdiverge,  # pytype: disable=module-attr
243                                          bothincompletediverge)
244    else:
245        # incomplete copies and divergences can't happen outside grafts
246        assert not both1[b'incomplete']
247        assert not both2[b'incomplete']
248        assert not bothincompletediverge
249    for f in remainder:
250        assert f not in bothdiverge
251        ic = remainder[f]
252        if ic[0] in (m1 if dirtyc1 else m2):
253            # backed-out rename on one side, but watch out for deleted files
254            bothdiverge[f] = ic
255    for of, fl in bothdiverge.items():
256        if len(fl) == 2 and fl[0] == fl[1]:
257            copy[fl[0]] = of # not actually divergent, just matching renames
258
259    if fullcopy and repo.ui.debugflag:
260        repo.ui.debug(b"  all copies found (* = to merge, ! = divergent, "
261                      b"% = renamed and deleted):\n")
262        for f in sorted(fullcopy):
263            note = b""
264            if f in copy:
265                note += b"*"
266            if f in divergeset:
267                note += b"!"
268            if f in renamedeleteset:
269                note += b"%"
270            repo.ui.debug(b"   src: '%s' -> dst: '%s' %s\n" % (fullcopy[f], f,
271                                                               note))
272    del divergeset
273
274    if not fullcopy:
275        return copy, {}, diverge, renamedelete, {}
276
277    repo.ui.debug(b"  checking for directory renames\n")
278
279    # generate a directory move map
280    d1, d2 = c1.dirs(), c2.dirs()
281    # Hack for adding '', which is not otherwise added, to d1 and d2
282    d1.addpath(b'/')
283    d2.addpath(b'/')
284    invalid = set()
285    dirmove = {}
286
287    # examine each file copy for a potential directory move, which is
288    # when all the files in a directory are moved to a new directory
289    for dst, src in fullcopy.items():
290        dsrc, ddst = pathutil.dirname(src), pathutil.dirname(dst)
291        if dsrc in invalid:
292            # already seen to be uninteresting
293            continue
294        elif dsrc in d1 and ddst in d1:
295            # directory wasn't entirely moved locally
296            invalid.add(dsrc + b"/")
297        elif dsrc in d2 and ddst in d2:
298            # directory wasn't entirely moved remotely
299            invalid.add(dsrc + b"/")
300        elif dsrc + b"/" in dirmove and dirmove[dsrc + b"/"] != ddst + b"/":
301            # files from the same directory moved to two different places
302            invalid.add(dsrc + b"/")
303        else:
304            # looks good so far
305            dirmove[dsrc + b"/"] = ddst + b"/"
306
307    for i in invalid:
308        if i in dirmove:
309            del dirmove[i]
310    del d1, d2, invalid
311
312    if not dirmove:
313        return copy, {}, diverge, renamedelete, {}
314
315    for d in dirmove:
316        repo.ui.debug(b"   discovered dir src: '%s' -> dst: '%s'\n" %
317                      (d, dirmove[d]))
318
319    movewithdir = {}
320    # check unaccounted nonoverlapping files against directory moves
321    for f in u1r + u2r:
322        if f not in fullcopy:
323            for d in dirmove:
324                if f.startswith(d):
325                    # new file added in a directory that was moved, move it
326                    df = dirmove[d] + f[len(d):]
327                    if df not in copy:
328                        movewithdir[f] = df
329                        repo.ui.debug((b"   pending file src: '%s' -> "
330                                       b"dst: '%s'\n") % (f, df))
331                    break
332
333    return copy, movewithdir, diverge, renamedelete, dirmove
334
335# hg <= 4.9 (7694b685bb10)
336fixupstreamed = util.safehasattr(scmutil, 'movedirstate')
337if not fixupstreamed:
338    copiesmod._fullcopytracing = fixedcopytracing
339
340# help category compatibility
341# hg <= 4.7 (c303d65d2e34)
342def helpcategorykwargs(categoryname):
343    """Backwards-compatible specification of the helpategory argument."""
344    category = getattr(registrar.command, categoryname, None)
345    if not category:
346        return {}
347    return {'helpcategory': category}
348
349# nodemap.get and index.[has_node|rev|get_rev]
350# hg <= 5.2 (02802fa87b74)
351def getgetrev(cl):
352    """Returns index.get_rev or nodemap.get (for pre-5.3 Mercurial)."""
353    if util.safehasattr(cl.index, 'get_rev'):
354        return cl.index.get_rev
355    return cl.nodemap.get
356
357@contextlib.contextmanager
358def parentchange(repo):
359    try:
360        yield
361    finally:
362        # hg <= 5.2 (85c4cd73996b)
363        if util.safehasattr(repo, '_quick_access_changeid_invalidate'):
364            repo._quick_access_changeid_invalidate()
365
366if util.safehasattr(mergemod, '_update'):
367    def _update(*args, **kwargs):
368        return mergemod._update(*args, **kwargs)
369else:
370    # hg <= 5.5 (2c86b9587740)
371    def _update(*args, **kwargs):
372        return mergemod.update(*args, **kwargs)
373
374if (util.safehasattr(mergemod, '_update')
375    and util.safehasattr(mergemod, 'update')):
376
377    def update(ctx):
378        mergemod.update(ctx)
379
380    def clean_update(ctx):
381        mergemod.clean_update(ctx)
382else:
383    # hg <= 5.5 (c1b603cdc95a)
384    def update(ctx):
385        hg.updaterepo(ctx.repo(), ctx.node(), overwrite=False)
386
387    def clean_update(ctx):
388        hg.updaterepo(ctx.repo(), ctx.node(), overwrite=True)
389
390def cleanupnodes(repo, replacements, operation, moves=None, metadata=None):
391    # Use this condition as a proxy since the commit we care about
392    # (b99903534e06) didn't change any signatures.
393    if util.safehasattr(scmutil, 'nullrev'):
394        fixedreplacements = replacements
395    else:
396        # hg <= 4.7 (b99903534e06)
397        fixedreplacements = {}
398        for oldnodes, newnodes in replacements.items():
399            for oldnode in oldnodes:
400                fixedreplacements[oldnode] = newnodes
401
402    scmutil.cleanupnodes(repo, replacements=fixedreplacements, operation=operation,
403                         moves=moves, metadata=metadata)
404
405if util.safehasattr(cmdutil, 'format_changeset_summary'):
406    def format_changeset_summary_fn(ui, repo, command, default_spec):
407        def show(ctx):
408            text = cmdutil.format_changeset_summary(ui, ctx, command=command,
409                                                    default_spec=default_spec)
410            ui.write(b'%s\n' % text)
411        return show
412else:
413    # hg <= 5.6 (96fcc37a9c80)
414    def format_changeset_summary_fn(ui, repo, command, default_spec):
415        return logcmdutil.changesetdisplayer(ui, repo,
416                                             {b'template': default_spec}).show
417
418if util.safehasattr(cmdutil, 'check_at_most_one_arg'):
419    def check_at_most_one_arg(opts, *args):
420        return cmdutil.check_at_most_one_arg(opts, *args)
421else:
422    # hg <= 5.2 (d587937600be)
423    def check_at_most_one_arg(opts, *args):
424        def to_display(name):
425            return pycompat.sysbytes(name).replace(b'_', b'-')
426
427        if util.safehasattr(error, 'InputError'):
428            err = error.InputError
429        else:
430            # hg <= 5.6 (8d72e29ad1e0)
431            err = error.Abort
432        previous = None
433        for x in args:
434            if opts.get(x):
435                if previous:
436                    raise err(_(b'cannot specify both --%s and --%s')
437                              % (to_display(previous), to_display(x)))
438                previous = x
439        return previous
440
441if util.safehasattr(cmdutil, 'check_incompatible_arguments'):
442    code = cmdutil.check_incompatible_arguments.__code__
443    if r'others' in code.co_varnames[:code.co_argcount]:
444        def check_incompatible_arguments(opts, first, others):
445            return cmdutil.check_incompatible_arguments(opts, first, others)
446    else:
447        # hg <= 5.3 (d4c1501225c4)
448        def check_incompatible_arguments(opts, first, others):
449            return cmdutil.check_incompatible_arguments(opts, first, *others)
450else:
451    # hg <= 5.2 (023ad45e2fd2)
452    def check_incompatible_arguments(opts, first, others):
453        for other in others:
454            check_at_most_one_arg(opts, first, other)
455
456# allowdivergenceopt is a much newer addition to obsolete.py
457# hg <= 5.8 (ba6881c6a178)
458allowdivergenceopt = b'allowdivergence'
459def isenabled(repo, option):
460    if option == allowdivergenceopt:
461        if obsolete._getoptionvalue(repo, obsolete.createmarkersopt):
462            return obsolete._getoptionvalue(repo, allowdivergenceopt)
463        else:
464            # note that we're not raising error.Abort when divergence is
465            # allowed, but creating markers is not, even on older hg versions
466            return False
467    else:
468        return obsolete.isenabled(repo, option)
469
470if util.safehasattr(dirstate.dirstate, 'set_clean'):
471    movedirstate = scmutil.movedirstate
472else:  # hg <= 5.8 (8a50fb0784a9)
473    # TODO: call core's version once we've dropped support for hg <= 4.9
474    def movedirstate(repo, newctx, match=None):
475        """Move the dirstate to newctx and adjust it as necessary.
476
477        A matcher can be provided as an optimization. It is probably a bug to pass
478        a matcher that doesn't match all the differences between the parent of the
479        working copy and newctx.
480        """
481        oldctx = repo[b'.']
482        ds = repo.dirstate
483        dscopies = dict(ds.copies())
484        ds.setparents(newctx.node(), node.nullid)
485        s = newctx.status(oldctx, match=match)
486        for f in s.modified:
487            if ds[f] == b'r':
488                # modified + removed -> removed
489                continue
490            ds.normallookup(f)
491
492        for f in s.added:
493            if ds[f] == b'r':
494                # added + removed -> unknown
495                ds.drop(f)
496            elif ds[f] != b'a':
497                ds.add(f)
498
499        for f in s.removed:
500            if ds[f] == b'a':
501                # removed + added -> normal
502                ds.normallookup(f)
503            elif ds[f] != b'r':
504                ds.remove(f)
505
506        # Merge old parent and old working dir copies
507        oldcopies = copiesmod.pathcopies(newctx, oldctx, match)
508        oldcopies.update(dscopies)
509        newcopies = {
510            dst: oldcopies.get(src, src)
511            for dst, src in oldcopies.items()
512        }
513        # Adjust the dirstate copies
514        for dst, src in newcopies.items():
515            if src not in newctx or dst in newctx or ds[dst] != b'a':
516                src = None
517            ds.copy(src, dst)
518
519# hg <= 4.9 (e1ceefab9bca)
520code = context.overlayworkingctx._markdirty.__code__
521if 'copied' not in code.co_varnames[:code.co_argcount]:
522    def fixedmarkcopied(self, path, origin):
523        self._markdirty(path, exists=True, date=self.filedate(path),
524                        flags=self.flags(path), copied=origin)
525
526    context.overlayworkingctx.markcopied = fixedmarkcopied
527
528# what we're actually targeting here is e079e001d536
529# hg <= 5.0 (dc3fdd1b5af4)
530try:
531    from mercurial import state as statemod
532    markdirtyfixed = util.safehasattr(statemod, '_statecheck')
533except (AttributeError, ImportError):
534    markdirtyfixed = False
535if not markdirtyfixed:
536    def fixedmarkdirty(
537        self,
538        path,
539        exists,
540        data=None,
541        date=None,
542        flags='',
543        copied=None,
544    ):
545        # data not provided, let's see if we already have some; if not, let's
546        # grab it from our underlying context, so that we always have data if
547        # the file is marked as existing.
548        if exists and data is None:
549            oldentry = self._cache.get(path) or {}
550            data = oldentry.get('data')
551            if data is None:
552                data = self._wrappedctx[path].data()
553
554        self._cache[path] = {
555            'exists': exists,
556            'data': data,
557            'date': date,
558            'flags': flags,
559            'copied': copied,
560        }
561
562    context.overlayworkingctx._markdirty = fixedmarkdirty
563
564if util.safehasattr(dirstate.dirstate, 'get_entry'):
565    def dirchanges(dirstate):
566        return [
567            f for f in dirstate if not dirstate.get_entry(f).maybe_clean
568        ]
569else:
570    # hg <= 5.9 (dcd97b082b3b)
571    def dirchanges(dirstate):
572        return [f for f in dirstate if dirstate[f] != b'n']
573