changeset 44915:b7808443ed6a

mergestate: split out merge state handling code from main merge module There's already some pretty reasonable encapsulation here, but I want to make the mergestate storage a property of the context so memctx instances can do a reasonable thing. This is the first step in a reshuffle to make that easier. Differential Revision: https://phab.mercurial-scm.org/D8550
author Augie Fackler <augie@google.com>
date Mon, 18 May 2020 14:59:59 -0400
parents 1d2d353e5c4a
children e607099d8b93
files hgext/fix.py hgext/histedit.py hgext/largefiles/overrides.py hgext/rebase.py hgext/strip.py mercurial/cmdutil.py mercurial/commands.py mercurial/debugcommands.py mercurial/fileset.py mercurial/hg.py mercurial/localrepo.py mercurial/merge.py mercurial/mergestate.py mercurial/narrowspec.py mercurial/revset.py mercurial/shelve.py mercurial/sparse.py mercurial/templatekw.py relnotes/next tests/fakemergerecord.py tests/test-dirstate.t tests/test-resolve.t
diffstat 22 files changed, 1154 insertions(+), 1035 deletions(-) [+]
line wrap: on
line diff
--- a/hgext/fix.py	Mon May 18 12:45:45 2020 -0400
+++ b/hgext/fix.py	Mon May 18 14:59:59 2020 -0400
@@ -144,6 +144,7 @@
     match as matchmod,
     mdiff,
     merge,
+    mergestate as mergestatemod,
     pycompat,
     registrar,
     rewriteutil,
@@ -426,7 +427,9 @@
     if not (len(revs) == 1 and wdirrev in revs):
         cmdutil.checkunfinished(repo)
         rewriteutil.precheck(repo, revs, b'fix')
-    if wdirrev in revs and list(merge.mergestate.read(repo).unresolved()):
+    if wdirrev in revs and list(
+        mergestatemod.mergestate.read(repo).unresolved()
+    ):
         raise error.Abort(b'unresolved conflicts', hint=b"use 'hg resolve'")
     if not revs:
         raise error.Abort(
--- a/hgext/histedit.py	Mon May 18 12:45:45 2020 -0400
+++ b/hgext/histedit.py	Mon May 18 14:59:59 2020 -0400
@@ -224,6 +224,7 @@
     hg,
     logcmdutil,
     merge as mergemod,
+    mergestate as mergestatemod,
     mergeutil,
     node,
     obsolete,
@@ -2289,7 +2290,7 @@
 def bootstrapcontinue(ui, state, opts):
     repo = state.repo
 
-    ms = mergemod.mergestate.read(repo)
+    ms = mergestatemod.mergestate.read(repo)
     mergeutil.checkunresolved(ms)
 
     if state.actions:
--- a/hgext/largefiles/overrides.py	Mon May 18 12:45:45 2020 -0400
+++ b/hgext/largefiles/overrides.py	Mon May 18 14:59:59 2020 -0400
@@ -31,6 +31,7 @@
     logcmdutil,
     match as matchmod,
     merge,
+    mergestate as mergestatemod,
     pathutil,
     pycompat,
     scmutil,
@@ -622,7 +623,7 @@
     return actions, diverge, renamedelete
 
 
-@eh.wrapfunction(merge, b'recordupdates')
+@eh.wrapfunction(mergestatemod, b'recordupdates')
 def mergerecordupdates(orig, repo, actions, branchmerge, getfiledata):
     if b'lfmr' in actions:
         lfdirstate = lfutil.openlfdirstate(repo.ui, repo)
--- a/hgext/rebase.py	Mon May 18 12:45:45 2020 -0400
+++ b/hgext/rebase.py	Mon May 18 14:59:59 2020 -0400
@@ -36,6 +36,7 @@
     extensions,
     hg,
     merge as mergemod,
+    mergestate as mergestatemod,
     mergeutil,
     node as nodemod,
     obsolete,
@@ -537,7 +538,7 @@
                     user=ctx.user(),
                     date=date,
                 )
-                mergemod.mergestate.clean(repo)
+                mergestatemod.mergestate.clean(repo)
             else:
                 newnode = commitnode(
                     repo,
@@ -1074,7 +1075,7 @@
             )
             # TODO: Make in-memory merge not use the on-disk merge state, so
             # we don't have to clean it here
-            mergemod.mergestate.clean(repo)
+            mergestatemod.mergestate.clean(repo)
             clearstatus(repo)
             clearcollapsemsg(repo)
             return _dorebase(ui, repo, action, opts, inmemory=False)
@@ -1175,7 +1176,7 @@
             if action == b'abort' and opts.get(b'tool', False):
                 ui.warn(_(b'tool option will be ignored\n'))
             if action == b'continue':
-                ms = mergemod.mergestate.read(repo)
+                ms = mergestatemod.mergestate.read(repo)
                 mergeutil.checkunresolved(ms)
 
             retcode = rbsrt._prepareabortorcontinue(
@@ -2185,7 +2186,7 @@
 def continuerebase(ui, repo):
     with repo.wlock(), repo.lock():
         rbsrt = rebaseruntime(repo, ui)
-        ms = mergemod.mergestate.read(repo)
+        ms = mergestatemod.mergestate.read(repo)
         mergeutil.checkunresolved(ms)
         retcode = rbsrt._prepareabortorcontinue(isabort=False)
         if retcode is not None:
--- a/hgext/strip.py	Mon May 18 12:45:45 2020 -0400
+++ b/hgext/strip.py	Mon May 18 14:59:59 2020 -0400
@@ -13,7 +13,7 @@
     error,
     hg,
     lock as lockmod,
-    merge,
+    mergestate as mergestatemod,
     node as nodemod,
     pycompat,
     registrar,
@@ -269,7 +269,7 @@
             repo.dirstate.write(repo.currenttransaction())
 
             # clear resolve state
-            merge.mergestate.clean(repo, repo[b'.'].node())
+            mergestatemod.mergestate.clean(repo, repo[b'.'].node())
 
             update = False
 
--- a/mercurial/cmdutil.py	Mon May 18 12:45:45 2020 -0400
+++ b/mercurial/cmdutil.py	Mon May 18 14:59:59 2020 -0400
@@ -38,6 +38,7 @@
     logcmdutil,
     match as matchmod,
     merge as mergemod,
+    mergestate as mergestatemod,
     mergeutil,
     obsolete,
     patch,
@@ -890,7 +891,7 @@
 def readmorestatus(repo):
     """Returns a morestatus object if the repo has unfinished state."""
     statetuple = statemod.getrepostate(repo)
-    mergestate = mergemod.mergestate.read(repo)
+    mergestate = mergestatemod.mergestate.read(repo)
     activemerge = mergestate.active()
     if not statetuple and not activemerge:
         return None
@@ -3127,7 +3128,7 @@
             if subs:
                 subrepoutil.writestate(repo, newsubstate)
 
-        ms = mergemod.mergestate.read(repo)
+        ms = mergestatemod.mergestate.read(repo)
         mergeutil.checkunresolved(ms)
 
         filestoamend = {f for f in wctx.files() if matcher(f)}
--- a/mercurial/commands.py	Mon May 18 12:45:45 2020 -0400
+++ b/mercurial/commands.py	Mon May 18 14:59:59 2020 -0400
@@ -46,6 +46,7 @@
     hg,
     logcmdutil,
     merge as mergemod,
+    mergestate as mergestatemod,
     narrowspec,
     obsolete,
     obsutil,
@@ -5938,7 +5939,7 @@
     if show:
         ui.pager(b'resolve')
         fm = ui.formatter(b'resolve', opts)
-        ms = mergemod.mergestate.read(repo)
+        ms = mergestatemod.mergestate.read(repo)
         wctx = repo[None]
         m = scmutil.match(wctx, pats, opts)
 
@@ -5946,14 +5947,20 @@
         # as 'P'.  Resolved path conflicts show as 'R', the same as normal
         # resolved conflicts.
         mergestateinfo = {
-            mergemod.MERGE_RECORD_UNRESOLVED: (b'resolve.unresolved', b'U'),
-            mergemod.MERGE_RECORD_RESOLVED: (b'resolve.resolved', b'R'),
-            mergemod.MERGE_RECORD_UNRESOLVED_PATH: (
+            mergestatemod.MERGE_RECORD_UNRESOLVED: (
+                b'resolve.unresolved',
+                b'U',
+            ),
+            mergestatemod.MERGE_RECORD_RESOLVED: (b'resolve.resolved', b'R'),
+            mergestatemod.MERGE_RECORD_UNRESOLVED_PATH: (
                 b'resolve.unresolved',
                 b'P',
             ),
-            mergemod.MERGE_RECORD_RESOLVED_PATH: (b'resolve.resolved', b'R'),
-            mergemod.MERGE_RECORD_DRIVER_RESOLVED: (
+            mergestatemod.MERGE_RECORD_RESOLVED_PATH: (
+                b'resolve.resolved',
+                b'R',
+            ),
+            mergestatemod.MERGE_RECORD_DRIVER_RESOLVED: (
                 b'resolve.driverresolved',
                 b'D',
             ),
@@ -5963,7 +5970,7 @@
             if not m(f):
                 continue
 
-            if ms[f] == mergemod.MERGE_RECORD_MERGED_OTHER:
+            if ms[f] == mergestatemod.MERGE_RECORD_MERGED_OTHER:
                 continue
             label, key = mergestateinfo[ms[f]]
             fm.startitem()
@@ -5975,7 +5982,7 @@
         return 0
 
     with repo.wlock():
-        ms = mergemod.mergestate.read(repo)
+        ms = mergestatemod.mergestate.read(repo)
 
         if not (ms.active() or repo.dirstate.p2() != nullid):
             raise error.Abort(
@@ -5986,7 +5993,7 @@
 
         if (
             ms.mergedriver
-            and ms.mdstate() == mergemod.MERGE_DRIVER_STATE_UNMARKED
+            and ms.mdstate() == mergestatemod.MERGE_DRIVER_STATE_UNMARKED
         ):
             proceed = mergemod.driverpreprocess(repo, ms, wctx)
             ms.commit()
@@ -6012,12 +6019,12 @@
 
             didwork = True
 
-            if ms[f] == mergemod.MERGE_RECORD_MERGED_OTHER:
+            if ms[f] == mergestatemod.MERGE_RECORD_MERGED_OTHER:
                 continue
 
             # don't let driver-resolved files be marked, and run the conclude
             # step if asked to resolve
-            if ms[f] == mergemod.MERGE_RECORD_DRIVER_RESOLVED:
+            if ms[f] == mergestatemod.MERGE_RECORD_DRIVER_RESOLVED:
                 exact = m.exact(f)
                 if mark:
                     if exact:
@@ -6037,14 +6044,14 @@
 
             # path conflicts must be resolved manually
             if ms[f] in (
-                mergemod.MERGE_RECORD_UNRESOLVED_PATH,
-                mergemod.MERGE_RECORD_RESOLVED_PATH,
+                mergestatemod.MERGE_RECORD_UNRESOLVED_PATH,
+                mergestatemod.MERGE_RECORD_RESOLVED_PATH,
             ):
                 if mark:
-                    ms.mark(f, mergemod.MERGE_RECORD_RESOLVED_PATH)
+                    ms.mark(f, mergestatemod.MERGE_RECORD_RESOLVED_PATH)
                 elif unmark:
-                    ms.mark(f, mergemod.MERGE_RECORD_UNRESOLVED_PATH)
-                elif ms[f] == mergemod.MERGE_RECORD_UNRESOLVED_PATH:
+                    ms.mark(f, mergestatemod.MERGE_RECORD_UNRESOLVED_PATH)
+                elif ms[f] == mergestatemod.MERGE_RECORD_UNRESOLVED_PATH:
                     ui.warn(
                         _(b'%s: path conflict must be resolved manually\n')
                         % uipathfn(f)
@@ -6056,12 +6063,12 @@
                     fdata = repo.wvfs.tryread(f)
                     if (
                         filemerge.hasconflictmarkers(fdata)
-                        and ms[f] != mergemod.MERGE_RECORD_RESOLVED
+                        and ms[f] != mergestatemod.MERGE_RECORD_RESOLVED
                     ):
                         hasconflictmarkers.append(f)
-                ms.mark(f, mergemod.MERGE_RECORD_RESOLVED)
+                ms.mark(f, mergestatemod.MERGE_RECORD_RESOLVED)
             elif unmark:
-                ms.mark(f, mergemod.MERGE_RECORD_UNRESOLVED)
+                ms.mark(f, mergestatemod.MERGE_RECORD_UNRESOLVED)
             else:
                 # backup pre-resolve (merge uses .orig for its own purposes)
                 a = repo.wjoin(f)
@@ -6942,7 +6949,7 @@
     marks = []
 
     try:
-        ms = mergemod.mergestate.read(repo)
+        ms = mergestatemod.mergestate.read(repo)
     except error.UnsupportedMergeRecords as e:
         s = b' '.join(e.recordtypes)
         ui.warn(
--- a/mercurial/debugcommands.py	Mon May 18 12:45:45 2020 -0400
+++ b/mercurial/debugcommands.py	Mon May 18 14:59:59 2020 -0400
@@ -58,7 +58,7 @@
     localrepo,
     lock as lockmod,
     logcmdutil,
-    merge as mergemod,
+    mergestate as mergestatemod,
     obsolete,
     obsutil,
     pathutil,
@@ -1974,7 +1974,7 @@
     was chosen."""
 
     if ui.verbose:
-        ms = mergemod.mergestate(repo)
+        ms = mergestatemod.mergestate(repo)
 
         # sort so that reasonable information is on top
         v1records = ms._readrecordsv1()
@@ -2008,7 +2008,7 @@
             b'"}'
         )
 
-    ms = mergemod.mergestate.read(repo)
+    ms = mergestatemod.mergestate.read(repo)
 
     fm = ui.formatter(b'debugmergestate', opts)
     fm.startitem()
@@ -2034,8 +2034,8 @@
             state = ms._state[f]
             fm_files.data(state=state[0])
             if state[0] in (
-                mergemod.MERGE_RECORD_UNRESOLVED,
-                mergemod.MERGE_RECORD_RESOLVED,
+                mergestatemod.MERGE_RECORD_UNRESOLVED,
+                mergestatemod.MERGE_RECORD_RESOLVED,
             ):
                 fm_files.data(local_key=state[1])
                 fm_files.data(local_path=state[2])
@@ -2045,8 +2045,8 @@
                 fm_files.data(other_node=state[6])
                 fm_files.data(local_flags=state[7])
             elif state[0] in (
-                mergemod.MERGE_RECORD_UNRESOLVED_PATH,
-                mergemod.MERGE_RECORD_RESOLVED_PATH,
+                mergestatemod.MERGE_RECORD_UNRESOLVED_PATH,
+                mergestatemod.MERGE_RECORD_RESOLVED_PATH,
             ):
                 fm_files.data(renamed_path=state[1])
                 fm_files.data(rename_side=state[2])
--- a/mercurial/fileset.py	Mon May 18 12:45:45 2020 -0400
+++ b/mercurial/fileset.py	Mon May 18 14:59:59 2020 -0400
@@ -16,7 +16,7 @@
     error,
     filesetlang,
     match as matchmod,
-    merge,
+    mergestate as mergestatemod,
     pycompat,
     registrar,
     scmutil,
@@ -245,7 +245,7 @@
     getargs(x, 0, 0, _(b"resolved takes no arguments"))
     if mctx.ctx.rev() is not None:
         return mctx.never()
-    ms = merge.mergestate.read(mctx.ctx.repo())
+    ms = mergestatemod.mergestate.read(mctx.ctx.repo())
     return mctx.predicate(
         lambda f: f in ms and ms[f] == b'r', predrepr=b'resolved'
     )
@@ -259,7 +259,7 @@
     getargs(x, 0, 0, _(b"unresolved takes no arguments"))
     if mctx.ctx.rev() is not None:
         return mctx.never()
-    ms = merge.mergestate.read(mctx.ctx.repo())
+    ms = mergestatemod.mergestate.read(mctx.ctx.repo())
     return mctx.predicate(
         lambda f: f in ms and ms[f] == b'u', predrepr=b'unresolved'
     )
--- a/mercurial/hg.py	Mon May 18 12:45:45 2020 -0400
+++ b/mercurial/hg.py	Mon May 18 14:59:59 2020 -0400
@@ -33,6 +33,7 @@
     logcmdutil,
     logexchange,
     merge as mergemod,
+    mergestate as mergestatemod,
     narrowspec,
     node,
     phases,
@@ -1164,7 +1165,7 @@
 
 
 def abortmerge(ui, repo):
-    ms = mergemod.mergestate.read(repo)
+    ms = mergestatemod.mergestate.read(repo)
     if ms.active():
         # there were conflicts
         node = ms.localctx.hex()
--- a/mercurial/localrepo.py	Mon May 18 12:45:45 2020 -0400
+++ b/mercurial/localrepo.py	Mon May 18 14:59:59 2020 -0400
@@ -44,7 +44,7 @@
     hook,
     lock as lockmod,
     match as matchmod,
-    merge as mergemod,
+    mergestate as mergestatemod,
     mergeutil,
     namespaces,
     narrowspec,
@@ -2468,7 +2468,7 @@
                 ui.status(
                     _(b'working directory now based on revision %d\n') % parents
                 )
-            mergemod.mergestate.clean(self, self[b'.'].node())
+            mergestatemod.mergestate.clean(self, self[b'.'].node())
 
         # TODO: if we know which new heads may result from this rollback, pass
         # them to destroy(), which will prevent the branchhead cache from being
@@ -2867,10 +2867,10 @@
                 fparent2 = nullid
             elif not fparentancestors:
                 # TODO: this whole if-else might be simplified much more
-                ms = mergemod.mergestate.read(self)
+                ms = mergestatemod.mergestate.read(self)
                 if (
                     fname in ms
-                    and ms[fname] == mergemod.MERGE_RECORD_MERGED_OTHER
+                    and ms[fname] == mergestatemod.MERGE_RECORD_MERGED_OTHER
                 ):
                     fparent1, fparent2 = fparent2, nullid
 
@@ -2968,7 +2968,7 @@
                 self, status, text, user, date, extra
             )
 
-            ms = mergemod.mergestate.read(self)
+            ms = mergestatemod.mergestate.read(self)
             mergeutil.checkunresolved(ms)
 
             # internal config: ui.allowemptycommit
--- a/mercurial/merge.py	Mon May 18 12:45:45 2020 -0400
+++ b/mercurial/merge.py	Mon May 18 14:59:59 2020 -0400
@@ -8,21 +8,16 @@
 from __future__ import absolute_import
 
 import errno
-import shutil
 import stat
 import struct
 
 from .i18n import _
 from .node import (
     addednodeid,
-    bin,
-    hex,
     modifiednodeid,
-    nullhex,
     nullid,
     nullrev,
 )
-from .pycompat import delattr
 from .thirdparty import attr
 from . import (
     copies,
@@ -30,6 +25,7 @@
     error,
     filemerge,
     match as matchmod,
+    mergestate as mergestatemod,
     obsutil,
     pathutil,
     pycompat,
@@ -38,741 +34,11 @@
     util,
     worker,
 )
-from .utils import hashutil
 
 _pack = struct.pack
 _unpack = struct.unpack
 
 
-def _droponode(data):
-    # used for compatibility for v1
-    bits = data.split(b'\0')
-    bits = bits[:-2] + bits[-1:]
-    return b'\0'.join(bits)
-
-
-# Merge state record types. See ``mergestate`` docs for more.
-RECORD_LOCAL = b'L'
-RECORD_OTHER = b'O'
-RECORD_MERGED = b'F'
-RECORD_CHANGEDELETE_CONFLICT = b'C'
-RECORD_MERGE_DRIVER_MERGE = b'D'
-RECORD_PATH_CONFLICT = b'P'
-RECORD_MERGE_DRIVER_STATE = b'm'
-RECORD_FILE_VALUES = b'f'
-RECORD_LABELS = b'l'
-RECORD_OVERRIDE = b't'
-RECORD_UNSUPPORTED_MANDATORY = b'X'
-RECORD_UNSUPPORTED_ADVISORY = b'x'
-RECORD_RESOLVED_OTHER = b'R'
-
-MERGE_DRIVER_STATE_UNMARKED = b'u'
-MERGE_DRIVER_STATE_MARKED = b'm'
-MERGE_DRIVER_STATE_SUCCESS = b's'
-
-MERGE_RECORD_UNRESOLVED = b'u'
-MERGE_RECORD_RESOLVED = b'r'
-MERGE_RECORD_UNRESOLVED_PATH = b'pu'
-MERGE_RECORD_RESOLVED_PATH = b'pr'
-MERGE_RECORD_DRIVER_RESOLVED = b'd'
-# represents that the file was automatically merged in favor
-# of other version. This info is used on commit.
-MERGE_RECORD_MERGED_OTHER = b'o'
-
-ACTION_FORGET = b'f'
-ACTION_REMOVE = b'r'
-ACTION_ADD = b'a'
-ACTION_GET = b'g'
-ACTION_PATH_CONFLICT = b'p'
-ACTION_PATH_CONFLICT_RESOLVE = b'pr'
-ACTION_ADD_MODIFIED = b'am'
-ACTION_CREATED = b'c'
-ACTION_DELETED_CHANGED = b'dc'
-ACTION_CHANGED_DELETED = b'cd'
-ACTION_MERGE = b'm'
-ACTION_LOCAL_DIR_RENAME_GET = b'dg'
-ACTION_DIR_RENAME_MOVE_LOCAL = b'dm'
-ACTION_KEEP = b'k'
-ACTION_EXEC = b'e'
-ACTION_CREATED_MERGE = b'cm'
-# GET the other/remote side and store this info in mergestate
-ACTION_GET_OTHER_AND_STORE = b'gs'
-
-
-class mergestate(object):
-    '''track 3-way merge state of individual files
-
-    The merge state is stored on disk when needed. Two files are used: one with
-    an old format (version 1), and one with a new format (version 2). Version 2
-    stores a superset of the data in version 1, including new kinds of records
-    in the future. For more about the new format, see the documentation for
-    `_readrecordsv2`.
-
-    Each record can contain arbitrary content, and has an associated type. This
-    `type` should be a letter. If `type` is uppercase, the record is mandatory:
-    versions of Mercurial that don't support it should abort. If `type` is
-    lowercase, the record can be safely ignored.
-
-    Currently known records:
-
-    L: the node of the "local" part of the merge (hexified version)
-    O: the node of the "other" part of the merge (hexified version)
-    F: a file to be merged entry
-    C: a change/delete or delete/change conflict
-    D: a file that the external merge driver will merge internally
-       (experimental)
-    P: a path conflict (file vs directory)
-    m: the external merge driver defined for this merge plus its run state
-       (experimental)
-    f: a (filename, dictionary) tuple of optional values for a given file
-    X: unsupported mandatory record type (used in tests)
-    x: unsupported advisory record type (used in tests)
-    l: the labels for the parts of the merge.
-
-    Merge driver run states (experimental):
-    u: driver-resolved files unmarked -- needs to be run next time we're about
-       to resolve or commit
-    m: driver-resolved files marked -- only needs to be run before commit
-    s: success/skipped -- does not need to be run any more
-
-    Merge record states (stored in self._state, indexed by filename):
-    u: unresolved conflict
-    r: resolved conflict
-    pu: unresolved path conflict (file conflicts with directory)
-    pr: resolved path conflict
-    d: driver-resolved conflict
-
-    The resolve command transitions between 'u' and 'r' for conflicts and
-    'pu' and 'pr' for path conflicts.
-    '''
-
-    statepathv1 = b'merge/state'
-    statepathv2 = b'merge/state2'
-
-    @staticmethod
-    def clean(repo, node=None, other=None, labels=None):
-        """Initialize a brand new merge state, removing any existing state on
-        disk."""
-        ms = mergestate(repo)
-        ms.reset(node, other, labels)
-        return ms
-
-    @staticmethod
-    def read(repo):
-        """Initialize the merge state, reading it from disk."""
-        ms = mergestate(repo)
-        ms._read()
-        return ms
-
-    def __init__(self, repo):
-        """Initialize the merge state.
-
-        Do not use this directly! Instead call read() or clean()."""
-        self._repo = repo
-        self._dirty = False
-        self._labels = None
-
-    def reset(self, node=None, other=None, labels=None):
-        self._state = {}
-        self._stateextras = {}
-        self._local = None
-        self._other = None
-        self._labels = labels
-        for var in ('localctx', 'otherctx'):
-            if var in vars(self):
-                delattr(self, var)
-        if node:
-            self._local = node
-            self._other = other
-        self._readmergedriver = None
-        if self.mergedriver:
-            self._mdstate = MERGE_DRIVER_STATE_SUCCESS
-        else:
-            self._mdstate = MERGE_DRIVER_STATE_UNMARKED
-        shutil.rmtree(self._repo.vfs.join(b'merge'), True)
-        self._results = {}
-        self._dirty = False
-
-    def _read(self):
-        """Analyse each record content to restore a serialized state from disk
-
-        This function process "record" entry produced by the de-serialization
-        of on disk file.
-        """
-        self._state = {}
-        self._stateextras = {}
-        self._local = None
-        self._other = None
-        for var in ('localctx', 'otherctx'):
-            if var in vars(self):
-                delattr(self, var)
-        self._readmergedriver = None
-        self._mdstate = MERGE_DRIVER_STATE_SUCCESS
-        unsupported = set()
-        records = self._readrecords()
-        for rtype, record in records:
-            if rtype == RECORD_LOCAL:
-                self._local = bin(record)
-            elif rtype == RECORD_OTHER:
-                self._other = bin(record)
-            elif rtype == RECORD_MERGE_DRIVER_STATE:
-                bits = record.split(b'\0', 1)
-                mdstate = bits[1]
-                if len(mdstate) != 1 or mdstate not in (
-                    MERGE_DRIVER_STATE_UNMARKED,
-                    MERGE_DRIVER_STATE_MARKED,
-                    MERGE_DRIVER_STATE_SUCCESS,
-                ):
-                    # the merge driver should be idempotent, so just rerun it
-                    mdstate = MERGE_DRIVER_STATE_UNMARKED
-
-                self._readmergedriver = bits[0]
-                self._mdstate = mdstate
-            elif rtype in (
-                RECORD_MERGED,
-                RECORD_CHANGEDELETE_CONFLICT,
-                RECORD_PATH_CONFLICT,
-                RECORD_MERGE_DRIVER_MERGE,
-                RECORD_RESOLVED_OTHER,
-            ):
-                bits = record.split(b'\0')
-                self._state[bits[0]] = bits[1:]
-            elif rtype == RECORD_FILE_VALUES:
-                filename, rawextras = record.split(b'\0', 1)
-                extraparts = rawextras.split(b'\0')
-                extras = {}
-                i = 0
-                while i < len(extraparts):
-                    extras[extraparts[i]] = extraparts[i + 1]
-                    i += 2
-
-                self._stateextras[filename] = extras
-            elif rtype == RECORD_LABELS:
-                labels = record.split(b'\0', 2)
-                self._labels = [l for l in labels if len(l) > 0]
-            elif not rtype.islower():
-                unsupported.add(rtype)
-        self._results = {}
-        self._dirty = False
-
-        if unsupported:
-            raise error.UnsupportedMergeRecords(unsupported)
-
-    def _readrecords(self):
-        """Read merge state from disk and return a list of record (TYPE, data)
-
-        We read data from both v1 and v2 files and decide which one to use.
-
-        V1 has been used by version prior to 2.9.1 and contains less data than
-        v2. We read both versions and check if no data in v2 contradicts
-        v1. If there is not contradiction we can safely assume that both v1
-        and v2 were written at the same time and use the extract data in v2. If
-        there is contradiction we ignore v2 content as we assume an old version
-        of Mercurial has overwritten the mergestate file and left an old v2
-        file around.
-
-        returns list of record [(TYPE, data), ...]"""
-        v1records = self._readrecordsv1()
-        v2records = self._readrecordsv2()
-        if self._v1v2match(v1records, v2records):
-            return v2records
-        else:
-            # v1 file is newer than v2 file, use it
-            # we have to infer the "other" changeset of the merge
-            # we cannot do better than that with v1 of the format
-            mctx = self._repo[None].parents()[-1]
-            v1records.append((RECORD_OTHER, mctx.hex()))
-            # add place holder "other" file node information
-            # nobody is using it yet so we do no need to fetch the data
-            # if mctx was wrong `mctx[bits[-2]]` may fails.
-            for idx, r in enumerate(v1records):
-                if r[0] == RECORD_MERGED:
-                    bits = r[1].split(b'\0')
-                    bits.insert(-2, b'')
-                    v1records[idx] = (r[0], b'\0'.join(bits))
-            return v1records
-
-    def _v1v2match(self, v1records, v2records):
-        oldv2 = set()  # old format version of v2 record
-        for rec in v2records:
-            if rec[0] == RECORD_LOCAL:
-                oldv2.add(rec)
-            elif rec[0] == RECORD_MERGED:
-                # drop the onode data (not contained in v1)
-                oldv2.add((RECORD_MERGED, _droponode(rec[1])))
-        for rec in v1records:
-            if rec not in oldv2:
-                return False
-        else:
-            return True
-
-    def _readrecordsv1(self):
-        """read on disk merge state for version 1 file
-
-        returns list of record [(TYPE, data), ...]
-
-        Note: the "F" data from this file are one entry short
-              (no "other file node" entry)
-        """
-        records = []
-        try:
-            f = self._repo.vfs(self.statepathv1)
-            for i, l in enumerate(f):
-                if i == 0:
-                    records.append((RECORD_LOCAL, l[:-1]))
-                else:
-                    records.append((RECORD_MERGED, l[:-1]))
-            f.close()
-        except IOError as err:
-            if err.errno != errno.ENOENT:
-                raise
-        return records
-
-    def _readrecordsv2(self):
-        """read on disk merge state for version 2 file
-
-        This format is a list of arbitrary records of the form:
-
-          [type][length][content]
-
-        `type` is a single character, `length` is a 4 byte integer, and
-        `content` is an arbitrary byte sequence of length `length`.
-
-        Mercurial versions prior to 3.7 have a bug where if there are
-        unsupported mandatory merge records, attempting to clear out the merge
-        state with hg update --clean or similar aborts. The 't' record type
-        works around that by writing out what those versions treat as an
-        advisory record, but later versions interpret as special: the first
-        character is the 'real' record type and everything onwards is the data.
-
-        Returns list of records [(TYPE, data), ...]."""
-        records = []
-        try:
-            f = self._repo.vfs(self.statepathv2)
-            data = f.read()
-            off = 0
-            end = len(data)
-            while off < end:
-                rtype = data[off : off + 1]
-                off += 1
-                length = _unpack(b'>I', data[off : (off + 4)])[0]
-                off += 4
-                record = data[off : (off + length)]
-                off += length
-                if rtype == RECORD_OVERRIDE:
-                    rtype, record = record[0:1], record[1:]
-                records.append((rtype, record))
-            f.close()
-        except IOError as err:
-            if err.errno != errno.ENOENT:
-                raise
-        return records
-
-    @util.propertycache
-    def mergedriver(self):
-        # protect against the following:
-        # - A configures a malicious merge driver in their hgrc, then
-        #   pauses the merge
-        # - A edits their hgrc to remove references to the merge driver
-        # - A gives a copy of their entire repo, including .hg, to B
-        # - B inspects .hgrc and finds it to be clean
-        # - B then continues the merge and the malicious merge driver
-        #  gets invoked
-        configmergedriver = self._repo.ui.config(
-            b'experimental', b'mergedriver'
-        )
-        if (
-            self._readmergedriver is not None
-            and self._readmergedriver != configmergedriver
-        ):
-            raise error.ConfigError(
-                _(b"merge driver changed since merge started"),
-                hint=_(b"revert merge driver change or abort merge"),
-            )
-
-        return configmergedriver
-
-    @util.propertycache
-    def local(self):
-        if self._local is None:
-            msg = b"local accessed but self._local isn't set"
-            raise error.ProgrammingError(msg)
-        return self._local
-
-    @util.propertycache
-    def localctx(self):
-        return self._repo[self.local]
-
-    @util.propertycache
-    def other(self):
-        if self._other is None:
-            msg = b"other accessed but self._other isn't set"
-            raise error.ProgrammingError(msg)
-        return self._other
-
-    @util.propertycache
-    def otherctx(self):
-        return self._repo[self.other]
-
-    def active(self):
-        """Whether mergestate is active.
-
-        Returns True if there appears to be mergestate. This is a rough proxy
-        for "is a merge in progress."
-        """
-        return bool(self._local) or bool(self._state)
-
-    def commit(self):
-        """Write current state on disk (if necessary)"""
-        if self._dirty:
-            records = self._makerecords()
-            self._writerecords(records)
-            self._dirty = False
-
-    def _makerecords(self):
-        records = []
-        records.append((RECORD_LOCAL, hex(self._local)))
-        records.append((RECORD_OTHER, hex(self._other)))
-        if self.mergedriver:
-            records.append(
-                (
-                    RECORD_MERGE_DRIVER_STATE,
-                    b'\0'.join([self.mergedriver, self._mdstate]),
-                )
-            )
-        # Write out state items. In all cases, the value of the state map entry
-        # is written as the contents of the record. The record type depends on
-        # the type of state that is stored, and capital-letter records are used
-        # to prevent older versions of Mercurial that do not support the feature
-        # from loading them.
-        for filename, v in pycompat.iteritems(self._state):
-            if v[0] == MERGE_RECORD_DRIVER_RESOLVED:
-                # Driver-resolved merge. These are stored in 'D' records.
-                records.append(
-                    (RECORD_MERGE_DRIVER_MERGE, b'\0'.join([filename] + v))
-                )
-            elif v[0] in (
-                MERGE_RECORD_UNRESOLVED_PATH,
-                MERGE_RECORD_RESOLVED_PATH,
-            ):
-                # Path conflicts. These are stored in 'P' records.  The current
-                # resolution state ('pu' or 'pr') is stored within the record.
-                records.append(
-                    (RECORD_PATH_CONFLICT, b'\0'.join([filename] + v))
-                )
-            elif v[0] == MERGE_RECORD_MERGED_OTHER:
-                records.append(
-                    (RECORD_RESOLVED_OTHER, b'\0'.join([filename] + v))
-                )
-            elif v[1] == nullhex or v[6] == nullhex:
-                # Change/Delete or Delete/Change conflicts. These are stored in
-                # 'C' records. v[1] is the local file, and is nullhex when the
-                # file is deleted locally ('dc'). v[6] is the remote file, and
-                # is nullhex when the file is deleted remotely ('cd').
-                records.append(
-                    (RECORD_CHANGEDELETE_CONFLICT, b'\0'.join([filename] + v))
-                )
-            else:
-                # Normal files.  These are stored in 'F' records.
-                records.append((RECORD_MERGED, b'\0'.join([filename] + v)))
-        for filename, extras in sorted(pycompat.iteritems(self._stateextras)):
-            rawextras = b'\0'.join(
-                b'%s\0%s' % (k, v) for k, v in pycompat.iteritems(extras)
-            )
-            records.append(
-                (RECORD_FILE_VALUES, b'%s\0%s' % (filename, rawextras))
-            )
-        if self._labels is not None:
-            labels = b'\0'.join(self._labels)
-            records.append((RECORD_LABELS, labels))
-        return records
-
-    def _writerecords(self, records):
-        """Write current state on disk (both v1 and v2)"""
-        self._writerecordsv1(records)
-        self._writerecordsv2(records)
-
-    def _writerecordsv1(self, records):
-        """Write current state on disk in a version 1 file"""
-        f = self._repo.vfs(self.statepathv1, b'wb')
-        irecords = iter(records)
-        lrecords = next(irecords)
-        assert lrecords[0] == RECORD_LOCAL
-        f.write(hex(self._local) + b'\n')
-        for rtype, data in irecords:
-            if rtype == RECORD_MERGED:
-                f.write(b'%s\n' % _droponode(data))
-        f.close()
-
-    def _writerecordsv2(self, records):
-        """Write current state on disk in a version 2 file
-
-        See the docstring for _readrecordsv2 for why we use 't'."""
-        # these are the records that all version 2 clients can read
-        allowlist = (RECORD_LOCAL, RECORD_OTHER, RECORD_MERGED)
-        f = self._repo.vfs(self.statepathv2, b'wb')
-        for key, data in records:
-            assert len(key) == 1
-            if key not in allowlist:
-                key, data = RECORD_OVERRIDE, b'%s%s' % (key, data)
-            format = b'>sI%is' % len(data)
-            f.write(_pack(format, key, len(data), data))
-        f.close()
-
-    @staticmethod
-    def getlocalkey(path):
-        """hash the path of a local file context for storage in the .hg/merge
-        directory."""
-
-        return hex(hashutil.sha1(path).digest())
-
-    def add(self, fcl, fco, fca, fd):
-        """add a new (potentially?) conflicting file the merge state
-        fcl: file context for local,
-        fco: file context for remote,
-        fca: file context for ancestors,
-        fd:  file path of the resulting merge.
-
-        note: also write the local version to the `.hg/merge` directory.
-        """
-        if fcl.isabsent():
-            localkey = nullhex
-        else:
-            localkey = mergestate.getlocalkey(fcl.path())
-            self._repo.vfs.write(b'merge/' + localkey, fcl.data())
-        self._state[fd] = [
-            MERGE_RECORD_UNRESOLVED,
-            localkey,
-            fcl.path(),
-            fca.path(),
-            hex(fca.filenode()),
-            fco.path(),
-            hex(fco.filenode()),
-            fcl.flags(),
-        ]
-        self._stateextras[fd] = {b'ancestorlinknode': hex(fca.node())}
-        self._dirty = True
-
-    def addpath(self, path, frename, forigin):
-        """add a new conflicting path to the merge state
-        path:    the path that conflicts
-        frename: the filename the conflicting file was renamed to
-        forigin: origin of the file ('l' or 'r' for local/remote)
-        """
-        self._state[path] = [MERGE_RECORD_UNRESOLVED_PATH, frename, forigin]
-        self._dirty = True
-
-    def addmergedother(self, path):
-        self._state[path] = [MERGE_RECORD_MERGED_OTHER, nullhex, nullhex]
-        self._dirty = True
-
-    def __contains__(self, dfile):
-        return dfile in self._state
-
-    def __getitem__(self, dfile):
-        return self._state[dfile][0]
-
-    def __iter__(self):
-        return iter(sorted(self._state))
-
-    def files(self):
-        return self._state.keys()
-
-    def mark(self, dfile, state):
-        self._state[dfile][0] = state
-        self._dirty = True
-
-    def mdstate(self):
-        return self._mdstate
-
-    def unresolved(self):
-        """Obtain the paths of unresolved files."""
-
-        for f, entry in pycompat.iteritems(self._state):
-            if entry[0] in (
-                MERGE_RECORD_UNRESOLVED,
-                MERGE_RECORD_UNRESOLVED_PATH,
-            ):
-                yield f
-
-    def driverresolved(self):
-        """Obtain the paths of driver-resolved files."""
-
-        for f, entry in self._state.items():
-            if entry[0] == MERGE_RECORD_DRIVER_RESOLVED:
-                yield f
-
-    def extras(self, filename):
-        return self._stateextras.setdefault(filename, {})
-
-    def _resolve(self, preresolve, dfile, wctx):
-        """rerun merge process for file path `dfile`"""
-        if self[dfile] in (MERGE_RECORD_RESOLVED, MERGE_RECORD_DRIVER_RESOLVED):
-            return True, 0
-        if self._state[dfile][0] == MERGE_RECORD_MERGED_OTHER:
-            return True, 0
-        stateentry = self._state[dfile]
-        state, localkey, lfile, afile, anode, ofile, onode, flags = stateentry
-        octx = self._repo[self._other]
-        extras = self.extras(dfile)
-        anccommitnode = extras.get(b'ancestorlinknode')
-        if anccommitnode:
-            actx = self._repo[anccommitnode]
-        else:
-            actx = None
-        fcd = self._filectxorabsent(localkey, wctx, dfile)
-        fco = self._filectxorabsent(onode, octx, ofile)
-        # TODO: move this to filectxorabsent
-        fca = self._repo.filectx(afile, fileid=anode, changectx=actx)
-        # "premerge" x flags
-        flo = fco.flags()
-        fla = fca.flags()
-        if b'x' in flags + flo + fla and b'l' not in flags + flo + fla:
-            if fca.node() == nullid and flags != flo:
-                if preresolve:
-                    self._repo.ui.warn(
-                        _(
-                            b'warning: cannot merge flags for %s '
-                            b'without common ancestor - keeping local flags\n'
-                        )
-                        % afile
-                    )
-            elif flags == fla:
-                flags = flo
-        if preresolve:
-            # restore local
-            if localkey != nullhex:
-                f = self._repo.vfs(b'merge/' + localkey)
-                wctx[dfile].write(f.read(), flags)
-                f.close()
-            else:
-                wctx[dfile].remove(ignoremissing=True)
-            complete, r, deleted = filemerge.premerge(
-                self._repo,
-                wctx,
-                self._local,
-                lfile,
-                fcd,
-                fco,
-                fca,
-                labels=self._labels,
-            )
-        else:
-            complete, r, deleted = filemerge.filemerge(
-                self._repo,
-                wctx,
-                self._local,
-                lfile,
-                fcd,
-                fco,
-                fca,
-                labels=self._labels,
-            )
-        if r is None:
-            # no real conflict
-            del self._state[dfile]
-            self._stateextras.pop(dfile, None)
-            self._dirty = True
-        elif not r:
-            self.mark(dfile, MERGE_RECORD_RESOLVED)
-
-        if complete:
-            action = None
-            if deleted:
-                if fcd.isabsent():
-                    # dc: local picked. Need to drop if present, which may
-                    # happen on re-resolves.
-                    action = ACTION_FORGET
-                else:
-                    # cd: remote picked (or otherwise deleted)
-                    action = ACTION_REMOVE
-            else:
-                if fcd.isabsent():  # dc: remote picked
-                    action = ACTION_GET
-                elif fco.isabsent():  # cd: local picked
-                    if dfile in self.localctx:
-                        action = ACTION_ADD_MODIFIED
-                    else:
-                        action = ACTION_ADD
-                # else: regular merges (no action necessary)
-            self._results[dfile] = r, action
-
-        return complete, r
-
-    def _filectxorabsent(self, hexnode, ctx, f):
-        if hexnode == nullhex:
-            return filemerge.absentfilectx(ctx, f)
-        else:
-            return ctx[f]
-
-    def preresolve(self, dfile, wctx):
-        """run premerge process for dfile
-
-        Returns whether the merge is complete, and the exit code."""
-        return self._resolve(True, dfile, wctx)
-
-    def resolve(self, dfile, wctx):
-        """run merge process (assuming premerge was run) for dfile
-
-        Returns the exit code of the merge."""
-        return self._resolve(False, dfile, wctx)[1]
-
-    def counts(self):
-        """return counts for updated, merged and removed files in this
-        session"""
-        updated, merged, removed = 0, 0, 0
-        for r, action in pycompat.itervalues(self._results):
-            if r is None:
-                updated += 1
-            elif r == 0:
-                if action == ACTION_REMOVE:
-                    removed += 1
-                else:
-                    merged += 1
-        return updated, merged, removed
-
-    def unresolvedcount(self):
-        """get unresolved count for this merge (persistent)"""
-        return len(list(self.unresolved()))
-
-    def actions(self):
-        """return lists of actions to perform on the dirstate"""
-        actions = {
-            ACTION_REMOVE: [],
-            ACTION_FORGET: [],
-            ACTION_ADD: [],
-            ACTION_ADD_MODIFIED: [],
-            ACTION_GET: [],
-        }
-        for f, (r, action) in pycompat.iteritems(self._results):
-            if action is not None:
-                actions[action].append((f, None, b"merge result"))
-        return actions
-
-    def recordactions(self):
-        """record remove/add/get actions in the dirstate"""
-        branchmerge = self._repo.dirstate.p2() != nullid
-        recordupdates(self._repo, self.actions(), branchmerge, None)
-
-    def queueremove(self, f):
-        """queues a file to be removed from the dirstate
-
-        Meant for use by custom merge drivers."""
-        self._results[f] = 0, ACTION_REMOVE
-
-    def queueadd(self, f):
-        """queues a file to be added to the dirstate
-
-        Meant for use by custom merge drivers."""
-        self._results[f] = 0, ACTION_ADD
-
-    def queueget(self, f):
-        """queues a file to be marked modified in the dirstate
-
-        Meant for use by custom merge drivers."""
-        self._results[f] = 0, ACTION_GET
-
-
 def _getcheckunknownconfig(repo, section, name):
     config = repo.ui.config(section, name)
     valid = [b'abort', b'ignore', b'warn']
@@ -885,14 +151,17 @@
 
         checkunknowndirs = _unknowndirschecker()
         for f, (m, args, msg) in pycompat.iteritems(actions):
-            if m in (ACTION_CREATED, ACTION_DELETED_CHANGED):
+            if m in (
+                mergestatemod.ACTION_CREATED,
+                mergestatemod.ACTION_DELETED_CHANGED,
+            ):
                 if _checkunknownfile(repo, wctx, mctx, f):
                     fileconflicts.add(f)
                 elif pathconfig and f not in wctx:
                     path = checkunknowndirs(repo, wctx, f)
                     if path is not None:
                         pathconflicts.add(path)
-            elif m == ACTION_LOCAL_DIR_RENAME_GET:
+            elif m == mergestatemod.ACTION_LOCAL_DIR_RENAME_GET:
                 if _checkunknownfile(repo, wctx, mctx, f, args[0]):
                     fileconflicts.add(f)
 
@@ -903,7 +172,7 @@
         collectconflicts(unknownconflicts, unknownconfig)
     else:
         for f, (m, args, msg) in pycompat.iteritems(actions):
-            if m == ACTION_CREATED_MERGE:
+            if m == mergestatemod.ACTION_CREATED_MERGE:
                 fl2, anc = args
                 different = _checkunknownfile(repo, wctx, mctx, f)
                 if repo.dirstate._ignore(f):
@@ -924,10 +193,14 @@
                 #     don't like an abort happening in the middle of
                 #     merge.update.
                 if not different:
-                    actions[f] = (ACTION_GET, (fl2, False), b'remote created')
+                    actions[f] = (
+                        mergestatemod.ACTION_GET,
+                        (fl2, False),
+                        b'remote created',
+                    )
                 elif mergeforce or config == b'abort':
                     actions[f] = (
-                        ACTION_MERGE,
+                        mergestatemod.ACTION_MERGE,
                         (f, f, None, False, anc),
                         b'remote differs from untracked local',
                     )
@@ -936,7 +209,11 @@
                 else:
                     if config == b'warn':
                         warnconflicts.add(f)
-                    actions[f] = (ACTION_GET, (fl2, True), b'remote created')
+                    actions[f] = (
+                        mergestatemod.ACTION_GET,
+                        (fl2, True),
+                        b'remote created',
+                    )
 
     for f in sorted(abortconflicts):
         warn = repo.ui.warn
@@ -962,14 +239,14 @@
             repo.ui.warn(_(b"%s: replacing untracked files in directory\n") % f)
 
     for f, (m, args, msg) in pycompat.iteritems(actions):
-        if m == ACTION_CREATED:
+        if m == mergestatemod.ACTION_CREATED:
             backup = (
                 f in fileconflicts
                 or f in pathconflicts
                 or any(p in pathconflicts for p in pathutil.finddirs(f))
             )
             (flags,) = args
-            actions[f] = (ACTION_GET, (flags, backup), msg)
+            actions[f] = (mergestatemod.ACTION_GET, (flags, backup), msg)
 
 
 def _forgetremoved(wctx, mctx, branchmerge):
@@ -988,9 +265,9 @@
     """
 
     actions = {}
-    m = ACTION_FORGET
+    m = mergestatemod.ACTION_FORGET
     if branchmerge:
-        m = ACTION_REMOVE
+        m = mergestatemod.ACTION_REMOVE
     for f in wctx.deleted():
         if f not in mctx:
             actions[f] = m, None, b"forget deleted"
@@ -998,7 +275,11 @@
     if not branchmerge:
         for f in wctx.removed():
             if f not in mctx:
-                actions[f] = ACTION_FORGET, None, b"forget removed"
+                actions[f] = (
+                    mergestatemod.ACTION_FORGET,
+                    None,
+                    b"forget removed",
+                )
 
     return actions
 
@@ -1026,24 +307,24 @@
     if actions:
         # KEEP and EXEC are no-op
         for m in (
-            ACTION_ADD,
-            ACTION_ADD_MODIFIED,
-            ACTION_FORGET,
-            ACTION_GET,
-            ACTION_CHANGED_DELETED,
-            ACTION_DELETED_CHANGED,
+            mergestatemod.ACTION_ADD,
+            mergestatemod.ACTION_ADD_MODIFIED,
+            mergestatemod.ACTION_FORGET,
+            mergestatemod.ACTION_GET,
+            mergestatemod.ACTION_CHANGED_DELETED,
+            mergestatemod.ACTION_DELETED_CHANGED,
         ):
             for f, args, msg in actions[m]:
                 pmmf.add(f)
-        for f, args, msg in actions[ACTION_REMOVE]:
+        for f, args, msg in actions[mergestatemod.ACTION_REMOVE]:
             pmmf.discard(f)
-        for f, args, msg in actions[ACTION_DIR_RENAME_MOVE_LOCAL]:
+        for f, args, msg in actions[mergestatemod.ACTION_DIR_RENAME_MOVE_LOCAL]:
             f2, flags = args
             pmmf.discard(f2)
             pmmf.add(f)
-        for f, args, msg in actions[ACTION_LOCAL_DIR_RENAME_GET]:
+        for f, args, msg in actions[mergestatemod.ACTION_LOCAL_DIR_RENAME_GET]:
             pmmf.add(f)
-        for f, args, msg in actions[ACTION_MERGE]:
+        for f, args, msg in actions[mergestatemod.ACTION_MERGE]:
             f1, f2, fa, move, anc = args
             if move:
                 pmmf.discard(f1)
@@ -1128,10 +409,10 @@
 
     for f, (m, args, msg) in actions.items():
         if m in (
-            ACTION_CREATED,
-            ACTION_DELETED_CHANGED,
-            ACTION_MERGE,
-            ACTION_CREATED_MERGE,
+            mergestatemod.ACTION_CREATED,
+            mergestatemod.ACTION_DELETED_CHANGED,
+            mergestatemod.ACTION_MERGE,
+            mergestatemod.ACTION_CREATED_MERGE,
         ):
             # This action may create a new local file.
             createdfiledirs.update(pathutil.finddirs(f))
@@ -1141,13 +422,13 @@
                 # will be checked once we know what all the deleted files are.
                 remoteconflicts.add(f)
         # Track the names of all deleted files.
-        if m == ACTION_REMOVE:
+        if m == mergestatemod.ACTION_REMOVE:
             deletedfiles.add(f)
-        if m == ACTION_MERGE:
+        if m == mergestatemod.ACTION_MERGE:
             f1, f2, fa, move, anc = args
             if move:
                 deletedfiles.add(f1)
-        if m == ACTION_DIR_RENAME_MOVE_LOCAL:
+        if m == mergestatemod.ACTION_DIR_RENAME_MOVE_LOCAL:
             f2, flags = args
             deletedfiles.add(f2)
 
@@ -1164,10 +445,10 @@
                 # We will need to rename the local file.
                 localconflicts.add(p)
         if p in actions and actions[p][0] in (
-            ACTION_CREATED,
-            ACTION_DELETED_CHANGED,
-            ACTION_MERGE,
-            ACTION_CREATED_MERGE,
+            mergestatemod.ACTION_CREATED,
+            mergestatemod.ACTION_DELETED_CHANGED,
+            mergestatemod.ACTION_MERGE,
+            mergestatemod.ACTION_CREATED_MERGE,
         ):
             # The file is in a directory which aliases a remote file.
             # This is an internal inconsistency within the remote
@@ -1180,11 +461,15 @@
             ctxname = bytes(wctx).rstrip(b'+')
             pnew = util.safename(p, ctxname, wctx, set(actions.keys()))
             actions[pnew] = (
-                ACTION_PATH_CONFLICT_RESOLVE,
+                mergestatemod.ACTION_PATH_CONFLICT_RESOLVE,
                 (p,),
                 b'local path conflict',
             )
-            actions[p] = (ACTION_PATH_CONFLICT, (pnew, b'l'), b'path conflict')
+            actions[p] = (
+                mergestatemod.ACTION_PATH_CONFLICT,
+                (pnew, b'l'),
+                b'path conflict',
+            )
 
     if remoteconflicts:
         # Check if all files in the conflicting directories have been removed.
@@ -1193,20 +478,23 @@
             if f not in deletedfiles:
                 m, args, msg = actions[p]
                 pnew = util.safename(p, ctxname, wctx, set(actions.keys()))
-                if m in (ACTION_DELETED_CHANGED, ACTION_MERGE):
+                if m in (
+                    mergestatemod.ACTION_DELETED_CHANGED,
+                    mergestatemod.ACTION_MERGE,
+                ):
                     # Action was merge, just update target.
                     actions[pnew] = (m, args, msg)
                 else:
                     # Action was create, change to renamed get action.
                     fl = args[0]
                     actions[pnew] = (
-                        ACTION_LOCAL_DIR_RENAME_GET,
+                        mergestatemod.ACTION_LOCAL_DIR_RENAME_GET,
                         (p, fl),
                         b'remote path conflict',
                     )
                 actions[p] = (
-                    ACTION_PATH_CONFLICT,
-                    (pnew, ACTION_REMOVE),
+                    mergestatemod.ACTION_PATH_CONFLICT,
+                    (pnew, mergestatemod.ACTION_REMOVE),
                     b'path conflict',
                 )
                 remoteconflicts.remove(p)
@@ -1340,13 +628,13 @@
                 ) or branch_copies2.copy.get(f, None)
                 if fa is not None:
                     actions[f] = (
-                        ACTION_MERGE,
+                        mergestatemod.ACTION_MERGE,
                         (f, f, fa, False, pa.node()),
                         b'both renamed from %s' % fa,
                     )
                 else:
                     actions[f] = (
-                        ACTION_MERGE,
+                        mergestatemod.ACTION_MERGE,
                         (f, f, None, False, pa.node()),
                         b'both created',
                     )
@@ -1355,35 +643,43 @@
                 fla = ma.flags(f)
                 nol = b'l' not in fl1 + fl2 + fla
                 if n2 == a and fl2 == fla:
-                    actions[f] = (ACTION_KEEP, (), b'remote unchanged')
+                    actions[f] = (
+                        mergestatemod.ACTION_KEEP,
+                        (),
+                        b'remote unchanged',
+                    )
                 elif n1 == a and fl1 == fla:  # local unchanged - use remote
                     if n1 == n2:  # optimization: keep local content
                         actions[f] = (
-                            ACTION_EXEC,
+                            mergestatemod.ACTION_EXEC,
                             (fl2,),
                             b'update permissions',
                         )
                     else:
                         actions[f] = (
-                            ACTION_GET_OTHER_AND_STORE
+                            mergestatemod.ACTION_GET_OTHER_AND_STORE
                             if branchmerge
-                            else ACTION_GET,
+                            else mergestatemod.ACTION_GET,
                             (fl2, False),
                             b'remote is newer',
                         )
                 elif nol and n2 == a:  # remote only changed 'x'
-                    actions[f] = (ACTION_EXEC, (fl2,), b'update permissions')
+                    actions[f] = (
+                        mergestatemod.ACTION_EXEC,
+                        (fl2,),
+                        b'update permissions',
+                    )
                 elif nol and n1 == a:  # local only changed 'x'
                     actions[f] = (
-                        ACTION_GET_OTHER_AND_STORE
+                        mergestatemod.ACTION_GET_OTHER_AND_STORE
                         if branchmerge
-                        else ACTION_GET,
+                        else mergestatemod.ACTION_GET,
                         (fl1, False),
                         b'remote is newer',
                     )
                 else:  # both changed something
                     actions[f] = (
-                        ACTION_MERGE,
+                        mergestatemod.ACTION_MERGE,
                         (f, f, f, False, pa.node()),
                         b'versions differ',
                     )
@@ -1396,30 +692,34 @@
                 f2 = branch_copies1.movewithdir[f]
                 if f2 in m2:
                     actions[f2] = (
-                        ACTION_MERGE,
+                        mergestatemod.ACTION_MERGE,
                         (f, f2, None, True, pa.node()),
                         b'remote directory rename, both created',
                     )
                 else:
                     actions[f2] = (
-                        ACTION_DIR_RENAME_MOVE_LOCAL,
+                        mergestatemod.ACTION_DIR_RENAME_MOVE_LOCAL,
                         (f, fl1),
                         b'remote directory rename - move from %s' % f,
                     )
             elif f in branch_copies1.copy:
                 f2 = branch_copies1.copy[f]
                 actions[f] = (
-                    ACTION_MERGE,
+                    mergestatemod.ACTION_MERGE,
                     (f, f2, f2, False, pa.node()),
                     b'local copied/moved from %s' % f2,
                 )
             elif f in ma:  # clean, a different, no remote
                 if n1 != ma[f]:
                     if acceptremote:
-                        actions[f] = (ACTION_REMOVE, None, b'remote delete')
+                        actions[f] = (
+                            mergestatemod.ACTION_REMOVE,
+                            None,
+                            b'remote delete',
+                        )
                     else:
                         actions[f] = (
-                            ACTION_CHANGED_DELETED,
+                            mergestatemod.ACTION_CHANGED_DELETED,
                             (f, None, f, False, pa.node()),
                             b'prompt changed/deleted',
                         )
@@ -1427,9 +727,17 @@
                     # This extra 'a' is added by working copy manifest to mark
                     # the file as locally added. We should forget it instead of
                     # deleting it.
-                    actions[f] = (ACTION_FORGET, None, b'remote deleted')
+                    actions[f] = (
+                        mergestatemod.ACTION_FORGET,
+                        None,
+                        b'remote deleted',
+                    )
                 else:
-                    actions[f] = (ACTION_REMOVE, None, b'other deleted')
+                    actions[f] = (
+                        mergestatemod.ACTION_REMOVE,
+                        None,
+                        b'other deleted',
+                    )
         elif n2:  # file exists only on remote side
             if f in copied1:
                 pass  # we'll deal with it on m1 side
@@ -1437,13 +745,13 @@
                 f2 = branch_copies2.movewithdir[f]
                 if f2 in m1:
                     actions[f2] = (
-                        ACTION_MERGE,
+                        mergestatemod.ACTION_MERGE,
                         (f2, f, None, False, pa.node()),
                         b'local directory rename, both created',
                     )
                 else:
                     actions[f2] = (
-                        ACTION_LOCAL_DIR_RENAME_GET,
+                        mergestatemod.ACTION_LOCAL_DIR_RENAME_GET,
                         (f, fl2),
                         b'local directory rename - get from %s' % f,
                     )
@@ -1451,13 +759,13 @@
                 f2 = branch_copies2.copy[f]
                 if f2 in m2:
                     actions[f] = (
-                        ACTION_MERGE,
+                        mergestatemod.ACTION_MERGE,
                         (f2, f, f2, False, pa.node()),
                         b'remote copied from %s' % f2,
                     )
                 else:
                     actions[f] = (
-                        ACTION_MERGE,
+                        mergestatemod.ACTION_MERGE,
                         (f2, f, f2, True, pa.node()),
                         b'remote moved from %s' % f2,
                     )
@@ -1474,12 +782,20 @@
                 # Checking whether the files are different is expensive, so we
                 # don't do that when we can avoid it.
                 if not force:
-                    actions[f] = (ACTION_CREATED, (fl2,), b'remote created')
+                    actions[f] = (
+                        mergestatemod.ACTION_CREATED,
+                        (fl2,),
+                        b'remote created',
+                    )
                 elif not branchmerge:
-                    actions[f] = (ACTION_CREATED, (fl2,), b'remote created')
+                    actions[f] = (
+                        mergestatemod.ACTION_CREATED,
+                        (fl2,),
+                        b'remote created',
+                    )
                 else:
                     actions[f] = (
-                        ACTION_CREATED_MERGE,
+                        mergestatemod.ACTION_CREATED_MERGE,
                         (fl2, pa.node()),
                         b'remote created, get or merge',
                     )
@@ -1492,16 +808,20 @@
                         break
                 if df is not None and df in m1:
                     actions[df] = (
-                        ACTION_MERGE,
+                        mergestatemod.ACTION_MERGE,
                         (df, f, f, False, pa.node()),
                         b'local directory rename - respect move '
                         b'from %s' % f,
                     )
                 elif acceptremote:
-                    actions[f] = (ACTION_CREATED, (fl2,), b'remote recreating')
+                    actions[f] = (
+                        mergestatemod.ACTION_CREATED,
+                        (fl2,),
+                        b'remote recreating',
+                    )
                 else:
                     actions[f] = (
-                        ACTION_DELETED_CHANGED,
+                        mergestatemod.ACTION_DELETED_CHANGED,
                         (None, f, f, False, pa.node()),
                         b'prompt deleted/changed',
                     )
@@ -1528,14 +848,14 @@
     # actions as we resolve trivial conflicts.
     for f, (m, args, msg) in list(actions.items()):
         if (
-            m == ACTION_CHANGED_DELETED
+            m == mergestatemod.ACTION_CHANGED_DELETED
             and f in ancestor
             and not wctx[f].cmp(ancestor[f])
         ):
             # local did change but ended up with same content
-            actions[f] = ACTION_REMOVE, None, b'prompt same'
+            actions[f] = mergestatemod.ACTION_REMOVE, None, b'prompt same'
         elif (
-            m == ACTION_DELETED_CHANGED
+            m == mergestatemod.ACTION_DELETED_CHANGED
             and f in ancestor
             and not mctx[f].cmp(ancestor[f])
         ):
@@ -1613,8 +933,8 @@
 
             for f, a in sorted(pycompat.iteritems(actions)):
                 m, args, msg = a
-                if m == ACTION_GET_OTHER_AND_STORE:
-                    m = ACTION_GET
+                if m == mergestatemod.ACTION_GET_OTHER_AND_STORE:
+                    m = mergestatemod.ACTION_GET
                 repo.ui.debug(b' %s: %s -> %s\n' % (f, msg, m))
                 if f in fbids:
                     d = fbids[f]
@@ -1638,14 +958,14 @@
                     actions[f] = l[0]
                     continue
             # If keep is an option, just do it.
-            if ACTION_KEEP in bids:
+            if mergestatemod.ACTION_KEEP in bids:
                 repo.ui.note(_(b" %s: picking 'keep' action\n") % f)
-                actions[f] = bids[ACTION_KEEP][0]
+                actions[f] = bids[mergestatemod.ACTION_KEEP][0]
                 continue
             # If there are gets and they all agree [how could they not?], do it.
-            if ACTION_GET in bids:
-                ga0 = bids[ACTION_GET][0]
-                if all(a == ga0 for a in bids[ACTION_GET][1:]):
+            if mergestatemod.ACTION_GET in bids:
+                ga0 = bids[mergestatemod.ACTION_GET][0]
+                if all(a == ga0 for a in bids[mergestatemod.ACTION_GET][1:]):
                     repo.ui.note(_(b" %s: picking 'get' action\n") % f)
                     actions[f] = ga0
                     continue
@@ -1790,10 +1110,10 @@
     oplist = [
         actions[a]
         for a in (
-            ACTION_GET,
-            ACTION_DELETED_CHANGED,
-            ACTION_LOCAL_DIR_RENAME_GET,
-            ACTION_MERGE,
+            mergestatemod.ACTION_GET,
+            mergestatemod.ACTION_DELETED_CHANGED,
+            mergestatemod.ACTION_LOCAL_DIR_RENAME_GET,
+            mergestatemod.ACTION_MERGE,
         )
     ]
     prefetch = scmutil.prefetchfiles
@@ -1826,21 +1146,21 @@
     return {
         m: []
         for m in (
-            ACTION_ADD,
-            ACTION_ADD_MODIFIED,
-            ACTION_FORGET,
-            ACTION_GET,
-            ACTION_CHANGED_DELETED,
-            ACTION_DELETED_CHANGED,
-            ACTION_REMOVE,
-            ACTION_DIR_RENAME_MOVE_LOCAL,
-            ACTION_LOCAL_DIR_RENAME_GET,
-            ACTION_MERGE,
-            ACTION_EXEC,
-            ACTION_KEEP,
-            ACTION_PATH_CONFLICT,
-            ACTION_PATH_CONFLICT_RESOLVE,
-            ACTION_GET_OTHER_AND_STORE,
+            mergestatemod.ACTION_ADD,
+            mergestatemod.ACTION_ADD_MODIFIED,
+            mergestatemod.ACTION_FORGET,
+            mergestatemod.ACTION_GET,
+            mergestatemod.ACTION_CHANGED_DELETED,
+            mergestatemod.ACTION_DELETED_CHANGED,
+            mergestatemod.ACTION_REMOVE,
+            mergestatemod.ACTION_DIR_RENAME_MOVE_LOCAL,
+            mergestatemod.ACTION_LOCAL_DIR_RENAME_GET,
+            mergestatemod.ACTION_MERGE,
+            mergestatemod.ACTION_EXEC,
+            mergestatemod.ACTION_KEEP,
+            mergestatemod.ACTION_PATH_CONFLICT,
+            mergestatemod.ACTION_PATH_CONFLICT_RESOLVE,
+            mergestatemod.ACTION_GET_OTHER_AND_STORE,
         )
     }
 
@@ -1862,10 +1182,12 @@
     _prefetchfiles(repo, mctx, actions)
 
     updated, merged, removed = 0, 0, 0
-    ms = mergestate.clean(repo, wctx.p1().node(), mctx.node(), labels)
+    ms = mergestatemod.mergestate.clean(
+        repo, wctx.p1().node(), mctx.node(), labels
+    )
 
     # add ACTION_GET_OTHER_AND_STORE to mergestate
-    for e in actions[ACTION_GET_OTHER_AND_STORE]:
+    for e in actions[mergestatemod.ACTION_GET_OTHER_AND_STORE]:
         ms.addmergedother(e[0])
 
     moves = []
@@ -1873,9 +1195,9 @@
         l.sort()
 
     # 'cd' and 'dc' actions are treated like other merge conflicts
-    mergeactions = sorted(actions[ACTION_CHANGED_DELETED])
-    mergeactions.extend(sorted(actions[ACTION_DELETED_CHANGED]))
-    mergeactions.extend(actions[ACTION_MERGE])
+    mergeactions = sorted(actions[mergestatemod.ACTION_CHANGED_DELETED])
+    mergeactions.extend(sorted(actions[mergestatemod.ACTION_DELETED_CHANGED]))
+    mergeactions.extend(actions[mergestatemod.ACTION_MERGE])
     for f, args, msg in mergeactions:
         f1, f2, fa, move, anc = args
         if f == b'.hgsubstate':  # merged internally
@@ -1906,16 +1228,22 @@
             wctx[f].audit()
             wctx[f].remove()
 
-    numupdates = sum(len(l) for m, l in actions.items() if m != ACTION_KEEP)
+    numupdates = sum(
+        len(l) for m, l in actions.items() if m != mergestatemod.ACTION_KEEP
+    )
     progress = repo.ui.makeprogress(
         _(b'updating'), unit=_(b'files'), total=numupdates
     )
 
-    if [a for a in actions[ACTION_REMOVE] if a[0] == b'.hgsubstate']:
+    if [
+        a
+        for a in actions[mergestatemod.ACTION_REMOVE]
+        if a[0] == b'.hgsubstate'
+    ]:
         subrepoutil.submerge(repo, wctx, mctx, wctx, overwrite, labels)
 
     # record path conflicts
-    for f, args, msg in actions[ACTION_PATH_CONFLICT]:
+    for f, args, msg in actions[mergestatemod.ACTION_PATH_CONFLICT]:
         f1, fo = args
         s = repo.ui.status
         s(
@@ -1939,14 +1267,18 @@
 
     # remove in parallel (must come before resolving path conflicts and getting)
     prog = worker.worker(
-        repo.ui, cost, batchremove, (repo, wctx), actions[ACTION_REMOVE]
+        repo.ui,
+        cost,
+        batchremove,
+        (repo, wctx),
+        actions[mergestatemod.ACTION_REMOVE],
     )
     for i, item in prog:
         progress.increment(step=i, item=item)
-    removed = len(actions[ACTION_REMOVE])
+    removed = len(actions[mergestatemod.ACTION_REMOVE])
 
     # resolve path conflicts (must come before getting)
-    for f, args, msg in actions[ACTION_PATH_CONFLICT_RESOLVE]:
+    for f, args, msg in actions[mergestatemod.ACTION_PATH_CONFLICT_RESOLVE]:
         repo.ui.debug(b" %s: %s -> pr\n" % (f, msg))
         (f0,) = args
         if wctx[f0].lexists():
@@ -1965,7 +1297,7 @@
         cost,
         batchget,
         (repo, mctx, wctx, wantfiledata),
-        actions[ACTION_GET],
+        actions[mergestatemod.ACTION_GET],
         threadsafe=threadsafe,
         hasretval=True,
     )
@@ -1976,33 +1308,33 @@
         else:
             i, item = res
             progress.increment(step=i, item=item)
-    updated = len(actions[ACTION_GET])
+    updated = len(actions[mergestatemod.ACTION_GET])
 
-    if [a for a in actions[ACTION_GET] if a[0] == b'.hgsubstate']:
+    if [a for a in actions[mergestatemod.ACTION_GET] if a[0] == b'.hgsubstate']:
         subrepoutil.submerge(repo, wctx, mctx, wctx, overwrite, labels)
 
     # forget (manifest only, just log it) (must come first)
-    for f, args, msg in actions[ACTION_FORGET]:
+    for f, args, msg in actions[mergestatemod.ACTION_FORGET]:
         repo.ui.debug(b" %s: %s -> f\n" % (f, msg))
         progress.increment(item=f)
 
     # re-add (manifest only, just log it)
-    for f, args, msg in actions[ACTION_ADD]:
+    for f, args, msg in actions[mergestatemod.ACTION_ADD]:
         repo.ui.debug(b" %s: %s -> a\n" % (f, msg))
         progress.increment(item=f)
 
     # re-add/mark as modified (manifest only, just log it)
-    for f, args, msg in actions[ACTION_ADD_MODIFIED]:
+    for f, args, msg in actions[mergestatemod.ACTION_ADD_MODIFIED]:
         repo.ui.debug(b" %s: %s -> am\n" % (f, msg))
         progress.increment(item=f)
 
     # keep (noop, just log it)
-    for f, args, msg in actions[ACTION_KEEP]:
+    for f, args, msg in actions[mergestatemod.ACTION_KEEP]:
         repo.ui.debug(b" %s: %s -> k\n" % (f, msg))
         # no progress
 
     # directory rename, move local
-    for f, args, msg in actions[ACTION_DIR_RENAME_MOVE_LOCAL]:
+    for f, args, msg in actions[mergestatemod.ACTION_DIR_RENAME_MOVE_LOCAL]:
         repo.ui.debug(b" %s: %s -> dm\n" % (f, msg))
         progress.increment(item=f)
         f0, flags = args
@@ -2013,7 +1345,7 @@
         updated += 1
 
     # local directory rename, get
-    for f, args, msg in actions[ACTION_LOCAL_DIR_RENAME_GET]:
+    for f, args, msg in actions[mergestatemod.ACTION_LOCAL_DIR_RENAME_GET]:
         repo.ui.debug(b" %s: %s -> dg\n" % (f, msg))
         progress.increment(item=f)
         f0, flags = args
@@ -2022,7 +1354,7 @@
         updated += 1
 
     # exec
-    for f, args, msg in actions[ACTION_EXEC]:
+    for f, args, msg in actions[mergestatemod.ACTION_EXEC]:
         repo.ui.debug(b" %s: %s -> e\n" % (f, msg))
         progress.increment(item=f)
         (flags,) = args
@@ -2087,7 +1419,7 @@
     if (
         usemergedriver
         and not unresolved
-        and ms.mdstate() != MERGE_DRIVER_STATE_SUCCESS
+        and ms.mdstate() != mergestatemod.MERGE_DRIVER_STATE_SUCCESS
     ):
         if not driverconclude(repo, ms, wctx, labels=labels):
             # XXX setting unresolved to at least 1 is a hack to make sure we
@@ -2103,10 +1435,10 @@
 
     extraactions = ms.actions()
     if extraactions:
-        mfiles = {a[0] for a in actions[ACTION_MERGE]}
+        mfiles = {a[0] for a in actions[mergestatemod.ACTION_MERGE]}
         for k, acts in pycompat.iteritems(extraactions):
             actions[k].extend(acts)
-            if k == ACTION_GET and wantfiledata:
+            if k == mergestatemod.ACTION_GET and wantfiledata:
                 # no filedata until mergestate is updated to provide it
                 for a in acts:
                     getfiledata[a[0]] = None
@@ -2128,112 +1460,17 @@
             # those lists aren't consulted again.
             mfiles.difference_update(a[0] for a in acts)
 
-        actions[ACTION_MERGE] = [
-            a for a in actions[ACTION_MERGE] if a[0] in mfiles
+        actions[mergestatemod.ACTION_MERGE] = [
+            a for a in actions[mergestatemod.ACTION_MERGE] if a[0] in mfiles
         ]
 
     progress.complete()
-    assert len(getfiledata) == (len(actions[ACTION_GET]) if wantfiledata else 0)
+    assert len(getfiledata) == (
+        len(actions[mergestatemod.ACTION_GET]) if wantfiledata else 0
+    )
     return updateresult(updated, merged, removed, unresolved), getfiledata
 
 
-def recordupdates(repo, actions, branchmerge, getfiledata):
-    """record merge actions to the dirstate"""
-    # remove (must come first)
-    for f, args, msg in actions.get(ACTION_REMOVE, []):
-        if branchmerge:
-            repo.dirstate.remove(f)
-        else:
-            repo.dirstate.drop(f)
-
-    # forget (must come first)
-    for f, args, msg in actions.get(ACTION_FORGET, []):
-        repo.dirstate.drop(f)
-
-    # resolve path conflicts
-    for f, args, msg in actions.get(ACTION_PATH_CONFLICT_RESOLVE, []):
-        (f0,) = args
-        origf0 = repo.dirstate.copied(f0) or f0
-        repo.dirstate.add(f)
-        repo.dirstate.copy(origf0, f)
-        if f0 == origf0:
-            repo.dirstate.remove(f0)
-        else:
-            repo.dirstate.drop(f0)
-
-    # re-add
-    for f, args, msg in actions.get(ACTION_ADD, []):
-        repo.dirstate.add(f)
-
-    # re-add/mark as modified
-    for f, args, msg in actions.get(ACTION_ADD_MODIFIED, []):
-        if branchmerge:
-            repo.dirstate.normallookup(f)
-        else:
-            repo.dirstate.add(f)
-
-    # exec change
-    for f, args, msg in actions.get(ACTION_EXEC, []):
-        repo.dirstate.normallookup(f)
-
-    # keep
-    for f, args, msg in actions.get(ACTION_KEEP, []):
-        pass
-
-    # get
-    for f, args, msg in actions.get(ACTION_GET, []):
-        if branchmerge:
-            repo.dirstate.otherparent(f)
-        else:
-            parentfiledata = getfiledata[f] if getfiledata else None
-            repo.dirstate.normal(f, parentfiledata=parentfiledata)
-
-    # merge
-    for f, args, msg in actions.get(ACTION_MERGE, []):
-        f1, f2, fa, move, anc = args
-        if branchmerge:
-            # We've done a branch merge, mark this file as merged
-            # so that we properly record the merger later
-            repo.dirstate.merge(f)
-            if f1 != f2:  # copy/rename
-                if move:
-                    repo.dirstate.remove(f1)
-                if f1 != f:
-                    repo.dirstate.copy(f1, f)
-                else:
-                    repo.dirstate.copy(f2, f)
-        else:
-            # We've update-merged a locally modified file, so
-            # we set the dirstate to emulate a normal checkout
-            # of that file some time in the past. Thus our
-            # merge will appear as a normal local file
-            # modification.
-            if f2 == f:  # file not locally copied/moved
-                repo.dirstate.normallookup(f)
-            if move:
-                repo.dirstate.drop(f1)
-
-    # directory rename, move local
-    for f, args, msg in actions.get(ACTION_DIR_RENAME_MOVE_LOCAL, []):
-        f0, flag = args
-        if branchmerge:
-            repo.dirstate.add(f)
-            repo.dirstate.remove(f0)
-            repo.dirstate.copy(f0, f)
-        else:
-            repo.dirstate.normal(f)
-            repo.dirstate.drop(f0)
-
-    # directory rename, get
-    for f, args, msg in actions.get(ACTION_LOCAL_DIR_RENAME_GET, []):
-        f0, flag = args
-        if branchmerge:
-            repo.dirstate.add(f)
-            repo.dirstate.copy(f0, f)
-        else:
-            repo.dirstate.normal(f)
-
-
 UPDATECHECK_ABORT = b'abort'  # handled at higher layers
 UPDATECHECK_NONE = b'none'
 UPDATECHECK_LINEAR = b'linear'
@@ -2356,7 +1593,7 @@
         if not overwrite:
             if len(pl) > 1:
                 raise error.Abort(_(b"outstanding uncommitted merge"))
-            ms = mergestate.read(repo)
+            ms = mergestatemod.mergestate.read(repo)
             if list(ms.unresolved()):
                 raise error.Abort(
                     _(b"outstanding merge conflicts"),
@@ -2443,12 +1680,12 @@
         if updatecheck == UPDATECHECK_NO_CONFLICT:
             for f, (m, args, msg) in pycompat.iteritems(actionbyfile):
                 if m not in (
-                    ACTION_GET,
-                    ACTION_KEEP,
-                    ACTION_EXEC,
-                    ACTION_REMOVE,
-                    ACTION_PATH_CONFLICT_RESOLVE,
-                    ACTION_GET_OTHER_AND_STORE,
+                    mergestatemod.ACTION_GET,
+                    mergestatemod.ACTION_KEEP,
+                    mergestatemod.ACTION_EXEC,
+                    mergestatemod.ACTION_REMOVE,
+                    mergestatemod.ACTION_PATH_CONFLICT_RESOLVE,
+                    mergestatemod.ACTION_GET_OTHER_AND_STORE,
                 ):
                     msg = _(b"conflicting changes")
                     hint = _(b"commit or update --clean to discard changes")
@@ -2462,7 +1699,7 @@
             m, args, msg = actionbyfile[f]
             prompts = filemerge.partextras(labels)
             prompts[b'f'] = f
-            if m == ACTION_CHANGED_DELETED:
+            if m == mergestatemod.ACTION_CHANGED_DELETED:
                 if repo.ui.promptchoice(
                     _(
                         b"local%(l)s changed %(f)s which other%(o)s deleted\n"
@@ -2472,16 +1709,24 @@
                     % prompts,
                     0,
                 ):
-                    actionbyfile[f] = (ACTION_REMOVE, None, b'prompt delete')
+                    actionbyfile[f] = (
+                        mergestatemod.ACTION_REMOVE,
+                        None,
+                        b'prompt delete',
+                    )
                 elif f in p1:
                     actionbyfile[f] = (
-                        ACTION_ADD_MODIFIED,
+                        mergestatemod.ACTION_ADD_MODIFIED,
                         None,
                         b'prompt keep',
                     )
                 else:
-                    actionbyfile[f] = (ACTION_ADD, None, b'prompt keep')
-            elif m == ACTION_DELETED_CHANGED:
+                    actionbyfile[f] = (
+                        mergestatemod.ACTION_ADD,
+                        None,
+                        b'prompt keep',
+                    )
+            elif m == mergestatemod.ACTION_DELETED_CHANGED:
                 f1, f2, fa, move, anc = args
                 flags = p2[f2].flags()
                 if (
@@ -2497,7 +1742,7 @@
                     == 0
                 ):
                     actionbyfile[f] = (
-                        ACTION_GET,
+                        mergestatemod.ACTION_GET,
                         (flags, False),
                         b'prompt recreating',
                     )
@@ -2511,9 +1756,9 @@
                 actions[m] = []
             actions[m].append((f, args, msg))
 
-        # ACTION_GET_OTHER_AND_STORE is a ACTION_GET + store in mergestate
-        for e in actions[ACTION_GET_OTHER_AND_STORE]:
-            actions[ACTION_GET].append(e)
+        # ACTION_GET_OTHER_AND_STORE is a mergestatemod.ACTION_GET + store in mergestate
+        for e in actions[mergestatemod.ACTION_GET_OTHER_AND_STORE]:
+            actions[mergestatemod.ACTION_GET].append(e)
 
         if not util.fscasesensitive(repo.path):
             # check collision between files only in p2 for clean update
@@ -2590,7 +1835,7 @@
             fsmonitorwarning
             and not fsmonitorenabled
             and p1.node() == nullid
-            and len(actions[ACTION_GET]) >= fsmonitorthreshold
+            and len(actions[mergestatemod.ACTION_GET]) >= fsmonitorthreshold
             and pycompat.sysplatform.startswith((b'linux', b'darwin'))
         ):
             repo.ui.warn(
@@ -2609,7 +1854,9 @@
         if updatedirstate:
             with repo.dirstate.parentchange():
                 repo.setparents(fp1, fp2)
-                recordupdates(repo, actions, branchmerge, getfiledata)
+                mergestatemod.recordupdates(
+                    repo, actions, branchmerge, getfiledata
+                )
                 # update completed, clear state
                 util.unlink(repo.vfs.join(b'updatestate'))
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mercurial/mergestate.py	Mon May 18 14:59:59 2020 -0400
@@ -0,0 +1,850 @@
+from __future__ import absolute_import
+
+import errno
+import shutil
+import struct
+
+from .i18n import _
+from .node import (
+    bin,
+    hex,
+    nullhex,
+    nullid,
+)
+from .pycompat import delattr
+from . import (
+    error,
+    filemerge,
+    pycompat,
+    util,
+)
+from .utils import hashutil
+
+_pack = struct.pack
+_unpack = struct.unpack
+
+
+def _droponode(data):
+    # used for compatibility for v1
+    bits = data.split(b'\0')
+    bits = bits[:-2] + bits[-1:]
+    return b'\0'.join(bits)
+
+
+# Merge state record types. See ``mergestate`` docs for more.
+RECORD_LOCAL = b'L'
+RECORD_OTHER = b'O'
+RECORD_MERGED = b'F'
+RECORD_CHANGEDELETE_CONFLICT = b'C'
+RECORD_MERGE_DRIVER_MERGE = b'D'
+RECORD_PATH_CONFLICT = b'P'
+RECORD_MERGE_DRIVER_STATE = b'm'
+RECORD_FILE_VALUES = b'f'
+RECORD_LABELS = b'l'
+RECORD_OVERRIDE = b't'
+RECORD_UNSUPPORTED_MANDATORY = b'X'
+RECORD_UNSUPPORTED_ADVISORY = b'x'
+RECORD_RESOLVED_OTHER = b'R'
+
+MERGE_DRIVER_STATE_UNMARKED = b'u'
+MERGE_DRIVER_STATE_MARKED = b'm'
+MERGE_DRIVER_STATE_SUCCESS = b's'
+
+MERGE_RECORD_UNRESOLVED = b'u'
+MERGE_RECORD_RESOLVED = b'r'
+MERGE_RECORD_UNRESOLVED_PATH = b'pu'
+MERGE_RECORD_RESOLVED_PATH = b'pr'
+MERGE_RECORD_DRIVER_RESOLVED = b'd'
+# represents that the file was automatically merged in favor
+# of other version. This info is used on commit.
+MERGE_RECORD_MERGED_OTHER = b'o'
+
+ACTION_FORGET = b'f'
+ACTION_REMOVE = b'r'
+ACTION_ADD = b'a'
+ACTION_GET = b'g'
+ACTION_PATH_CONFLICT = b'p'
+ACTION_PATH_CONFLICT_RESOLVE = b'pr'
+ACTION_ADD_MODIFIED = b'am'
+ACTION_CREATED = b'c'
+ACTION_DELETED_CHANGED = b'dc'
+ACTION_CHANGED_DELETED = b'cd'
+ACTION_MERGE = b'm'
+ACTION_LOCAL_DIR_RENAME_GET = b'dg'
+ACTION_DIR_RENAME_MOVE_LOCAL = b'dm'
+ACTION_KEEP = b'k'
+ACTION_EXEC = b'e'
+ACTION_CREATED_MERGE = b'cm'
+# GET the other/remote side and store this info in mergestate
+ACTION_GET_OTHER_AND_STORE = b'gs'
+
+
+class mergestate(object):
+    '''track 3-way merge state of individual files
+
+    The merge state is stored on disk when needed. Two files are used: one with
+    an old format (version 1), and one with a new format (version 2). Version 2
+    stores a superset of the data in version 1, including new kinds of records
+    in the future. For more about the new format, see the documentation for
+    `_readrecordsv2`.
+
+    Each record can contain arbitrary content, and has an associated type. This
+    `type` should be a letter. If `type` is uppercase, the record is mandatory:
+    versions of Mercurial that don't support it should abort. If `type` is
+    lowercase, the record can be safely ignored.
+
+    Currently known records:
+
+    L: the node of the "local" part of the merge (hexified version)
+    O: the node of the "other" part of the merge (hexified version)
+    F: a file to be merged entry
+    C: a change/delete or delete/change conflict
+    D: a file that the external merge driver will merge internally
+       (experimental)
+    P: a path conflict (file vs directory)
+    m: the external merge driver defined for this merge plus its run state
+       (experimental)
+    f: a (filename, dictionary) tuple of optional values for a given file
+    X: unsupported mandatory record type (used in tests)
+    x: unsupported advisory record type (used in tests)
+    l: the labels for the parts of the merge.
+
+    Merge driver run states (experimental):
+    u: driver-resolved files unmarked -- needs to be run next time we're about
+       to resolve or commit
+    m: driver-resolved files marked -- only needs to be run before commit
+    s: success/skipped -- does not need to be run any more
+
+    Merge record states (stored in self._state, indexed by filename):
+    u: unresolved conflict
+    r: resolved conflict
+    pu: unresolved path conflict (file conflicts with directory)
+    pr: resolved path conflict
+    d: driver-resolved conflict
+
+    The resolve command transitions between 'u' and 'r' for conflicts and
+    'pu' and 'pr' for path conflicts.
+    '''
+
+    statepathv1 = b'merge/state'
+    statepathv2 = b'merge/state2'
+
+    @staticmethod
+    def clean(repo, node=None, other=None, labels=None):
+        """Initialize a brand new merge state, removing any existing state on
+        disk."""
+        ms = mergestate(repo)
+        ms.reset(node, other, labels)
+        return ms
+
+    @staticmethod
+    def read(repo):
+        """Initialize the merge state, reading it from disk."""
+        ms = mergestate(repo)
+        ms._read()
+        return ms
+
+    def __init__(self, repo):
+        """Initialize the merge state.
+
+        Do not use this directly! Instead call read() or clean()."""
+        self._repo = repo
+        self._dirty = False
+        self._labels = None
+
+    def reset(self, node=None, other=None, labels=None):
+        self._state = {}
+        self._stateextras = {}
+        self._local = None
+        self._other = None
+        self._labels = labels
+        for var in ('localctx', 'otherctx'):
+            if var in vars(self):
+                delattr(self, var)
+        if node:
+            self._local = node
+            self._other = other
+        self._readmergedriver = None
+        if self.mergedriver:
+            self._mdstate = MERGE_DRIVER_STATE_SUCCESS
+        else:
+            self._mdstate = MERGE_DRIVER_STATE_UNMARKED
+        shutil.rmtree(self._repo.vfs.join(b'merge'), True)
+        self._results = {}
+        self._dirty = False
+
+    def _read(self):
+        """Analyse each record content to restore a serialized state from disk
+
+        This function process "record" entry produced by the de-serialization
+        of on disk file.
+        """
+        self._state = {}
+        self._stateextras = {}
+        self._local = None
+        self._other = None
+        for var in ('localctx', 'otherctx'):
+            if var in vars(self):
+                delattr(self, var)
+        self._readmergedriver = None
+        self._mdstate = MERGE_DRIVER_STATE_SUCCESS
+        unsupported = set()
+        records = self._readrecords()
+        for rtype, record in records:
+            if rtype == RECORD_LOCAL:
+                self._local = bin(record)
+            elif rtype == RECORD_OTHER:
+                self._other = bin(record)
+            elif rtype == RECORD_MERGE_DRIVER_STATE:
+                bits = record.split(b'\0', 1)
+                mdstate = bits[1]
+                if len(mdstate) != 1 or mdstate not in (
+                    MERGE_DRIVER_STATE_UNMARKED,
+                    MERGE_DRIVER_STATE_MARKED,
+                    MERGE_DRIVER_STATE_SUCCESS,
+                ):
+                    # the merge driver should be idempotent, so just rerun it
+                    mdstate = MERGE_DRIVER_STATE_UNMARKED
+
+                self._readmergedriver = bits[0]
+                self._mdstate = mdstate
+            elif rtype in (
+                RECORD_MERGED,
+                RECORD_CHANGEDELETE_CONFLICT,
+                RECORD_PATH_CONFLICT,
+                RECORD_MERGE_DRIVER_MERGE,
+                RECORD_RESOLVED_OTHER,
+            ):
+                bits = record.split(b'\0')
+                self._state[bits[0]] = bits[1:]
+            elif rtype == RECORD_FILE_VALUES:
+                filename, rawextras = record.split(b'\0', 1)
+                extraparts = rawextras.split(b'\0')
+                extras = {}
+                i = 0
+                while i < len(extraparts):
+                    extras[extraparts[i]] = extraparts[i + 1]
+                    i += 2
+
+                self._stateextras[filename] = extras
+            elif rtype == RECORD_LABELS:
+                labels = record.split(b'\0', 2)
+                self._labels = [l for l in labels if len(l) > 0]
+            elif not rtype.islower():
+                unsupported.add(rtype)
+        self._results = {}
+        self._dirty = False
+
+        if unsupported:
+            raise error.UnsupportedMergeRecords(unsupported)
+
+    def _readrecords(self):
+        """Read merge state from disk and return a list of record (TYPE, data)
+
+        We read data from both v1 and v2 files and decide which one to use.
+
+        V1 has been used by version prior to 2.9.1 and contains less data than
+        v2. We read both versions and check if no data in v2 contradicts
+        v1. If there is not contradiction we can safely assume that both v1
+        and v2 were written at the same time and use the extract data in v2. If
+        there is contradiction we ignore v2 content as we assume an old version
+        of Mercurial has overwritten the mergestate file and left an old v2
+        file around.
+
+        returns list of record [(TYPE, data), ...]"""
+        v1records = self._readrecordsv1()
+        v2records = self._readrecordsv2()
+        if self._v1v2match(v1records, v2records):
+            return v2records
+        else:
+            # v1 file is newer than v2 file, use it
+            # we have to infer the "other" changeset of the merge
+            # we cannot do better than that with v1 of the format
+            mctx = self._repo[None].parents()[-1]
+            v1records.append((RECORD_OTHER, mctx.hex()))
+            # add place holder "other" file node information
+            # nobody is using it yet so we do no need to fetch the data
+            # if mctx was wrong `mctx[bits[-2]]` may fails.
+            for idx, r in enumerate(v1records):
+                if r[0] == RECORD_MERGED:
+                    bits = r[1].split(b'\0')
+                    bits.insert(-2, b'')
+                    v1records[idx] = (r[0], b'\0'.join(bits))
+            return v1records
+
+    def _v1v2match(self, v1records, v2records):
+        oldv2 = set()  # old format version of v2 record
+        for rec in v2records:
+            if rec[0] == RECORD_LOCAL:
+                oldv2.add(rec)
+            elif rec[0] == RECORD_MERGED:
+                # drop the onode data (not contained in v1)
+                oldv2.add((RECORD_MERGED, _droponode(rec[1])))
+        for rec in v1records:
+            if rec not in oldv2:
+                return False
+        else:
+            return True
+
+    def _readrecordsv1(self):
+        """read on disk merge state for version 1 file
+
+        returns list of record [(TYPE, data), ...]
+
+        Note: the "F" data from this file are one entry short
+              (no "other file node" entry)
+        """
+        records = []
+        try:
+            f = self._repo.vfs(self.statepathv1)
+            for i, l in enumerate(f):
+                if i == 0:
+                    records.append((RECORD_LOCAL, l[:-1]))
+                else:
+                    records.append((RECORD_MERGED, l[:-1]))
+            f.close()
+        except IOError as err:
+            if err.errno != errno.ENOENT:
+                raise
+        return records
+
+    def _readrecordsv2(self):
+        """read on disk merge state for version 2 file
+
+        This format is a list of arbitrary records of the form:
+
+          [type][length][content]
+
+        `type` is a single character, `length` is a 4 byte integer, and
+        `content` is an arbitrary byte sequence of length `length`.
+
+        Mercurial versions prior to 3.7 have a bug where if there are
+        unsupported mandatory merge records, attempting to clear out the merge
+        state with hg update --clean or similar aborts. The 't' record type
+        works around that by writing out what those versions treat as an
+        advisory record, but later versions interpret as special: the first
+        character is the 'real' record type and everything onwards is the data.
+
+        Returns list of records [(TYPE, data), ...]."""
+        records = []
+        try:
+            f = self._repo.vfs(self.statepathv2)
+            data = f.read()
+            off = 0
+            end = len(data)
+            while off < end:
+                rtype = data[off : off + 1]
+                off += 1
+                length = _unpack(b'>I', data[off : (off + 4)])[0]
+                off += 4
+                record = data[off : (off + length)]
+                off += length
+                if rtype == RECORD_OVERRIDE:
+                    rtype, record = record[0:1], record[1:]
+                records.append((rtype, record))
+            f.close()
+        except IOError as err:
+            if err.errno != errno.ENOENT:
+                raise
+        return records
+
+    @util.propertycache
+    def mergedriver(self):
+        # protect against the following:
+        # - A configures a malicious merge driver in their hgrc, then
+        #   pauses the merge
+        # - A edits their hgrc to remove references to the merge driver
+        # - A gives a copy of their entire repo, including .hg, to B
+        # - B inspects .hgrc and finds it to be clean
+        # - B then continues the merge and the malicious merge driver
+        #  gets invoked
+        configmergedriver = self._repo.ui.config(
+            b'experimental', b'mergedriver'
+        )
+        if (
+            self._readmergedriver is not None
+            and self._readmergedriver != configmergedriver
+        ):
+            raise error.ConfigError(
+                _(b"merge driver changed since merge started"),
+                hint=_(b"revert merge driver change or abort merge"),
+            )
+
+        return configmergedriver
+
+    @util.propertycache
+    def local(self):
+        if self._local is None:
+            msg = b"local accessed but self._local isn't set"
+            raise error.ProgrammingError(msg)
+        return self._local
+
+    @util.propertycache
+    def localctx(self):
+        return self._repo[self.local]
+
+    @util.propertycache
+    def other(self):
+        if self._other is None:
+            msg = b"other accessed but self._other isn't set"
+            raise error.ProgrammingError(msg)
+        return self._other
+
+    @util.propertycache
+    def otherctx(self):
+        return self._repo[self.other]
+
+    def active(self):
+        """Whether mergestate is active.
+
+        Returns True if there appears to be mergestate. This is a rough proxy
+        for "is a merge in progress."
+        """
+        return bool(self._local) or bool(self._state)
+
+    def commit(self):
+        """Write current state on disk (if necessary)"""
+        if self._dirty:
+            records = self._makerecords()
+            self._writerecords(records)
+            self._dirty = False
+
+    def _makerecords(self):
+        records = []
+        records.append((RECORD_LOCAL, hex(self._local)))
+        records.append((RECORD_OTHER, hex(self._other)))
+        if self.mergedriver:
+            records.append(
+                (
+                    RECORD_MERGE_DRIVER_STATE,
+                    b'\0'.join([self.mergedriver, self._mdstate]),
+                )
+            )
+        # Write out state items. In all cases, the value of the state map entry
+        # is written as the contents of the record. The record type depends on
+        # the type of state that is stored, and capital-letter records are used
+        # to prevent older versions of Mercurial that do not support the feature
+        # from loading them.
+        for filename, v in pycompat.iteritems(self._state):
+            if v[0] == MERGE_RECORD_DRIVER_RESOLVED:
+                # Driver-resolved merge. These are stored in 'D' records.
+                records.append(
+                    (RECORD_MERGE_DRIVER_MERGE, b'\0'.join([filename] + v))
+                )
+            elif v[0] in (
+                MERGE_RECORD_UNRESOLVED_PATH,
+                MERGE_RECORD_RESOLVED_PATH,
+            ):
+                # Path conflicts. These are stored in 'P' records.  The current
+                # resolution state ('pu' or 'pr') is stored within the record.
+                records.append(
+                    (RECORD_PATH_CONFLICT, b'\0'.join([filename] + v))
+                )
+            elif v[0] == MERGE_RECORD_MERGED_OTHER:
+                records.append(
+                    (RECORD_RESOLVED_OTHER, b'\0'.join([filename] + v))
+                )
+            elif v[1] == nullhex or v[6] == nullhex:
+                # Change/Delete or Delete/Change conflicts. These are stored in
+                # 'C' records. v[1] is the local file, and is nullhex when the
+                # file is deleted locally ('dc'). v[6] is the remote file, and
+                # is nullhex when the file is deleted remotely ('cd').
+                records.append(
+                    (RECORD_CHANGEDELETE_CONFLICT, b'\0'.join([filename] + v))
+                )
+            else:
+                # Normal files.  These are stored in 'F' records.
+                records.append((RECORD_MERGED, b'\0'.join([filename] + v)))
+        for filename, extras in sorted(pycompat.iteritems(self._stateextras)):
+            rawextras = b'\0'.join(
+                b'%s\0%s' % (k, v) for k, v in pycompat.iteritems(extras)
+            )
+            records.append(
+                (RECORD_FILE_VALUES, b'%s\0%s' % (filename, rawextras))
+            )
+        if self._labels is not None:
+            labels = b'\0'.join(self._labels)
+            records.append((RECORD_LABELS, labels))
+        return records
+
+    def _writerecords(self, records):
+        """Write current state on disk (both v1 and v2)"""
+        self._writerecordsv1(records)
+        self._writerecordsv2(records)
+
+    def _writerecordsv1(self, records):
+        """Write current state on disk in a version 1 file"""
+        f = self._repo.vfs(self.statepathv1, b'wb')
+        irecords = iter(records)
+        lrecords = next(irecords)
+        assert lrecords[0] == RECORD_LOCAL
+        f.write(hex(self._local) + b'\n')
+        for rtype, data in irecords:
+            if rtype == RECORD_MERGED:
+                f.write(b'%s\n' % _droponode(data))
+        f.close()
+
+    def _writerecordsv2(self, records):
+        """Write current state on disk in a version 2 file
+
+        See the docstring for _readrecordsv2 for why we use 't'."""
+        # these are the records that all version 2 clients can read
+        allowlist = (RECORD_LOCAL, RECORD_OTHER, RECORD_MERGED)
+        f = self._repo.vfs(self.statepathv2, b'wb')
+        for key, data in records:
+            assert len(key) == 1
+            if key not in allowlist:
+                key, data = RECORD_OVERRIDE, b'%s%s' % (key, data)
+            format = b'>sI%is' % len(data)
+            f.write(_pack(format, key, len(data), data))
+        f.close()
+
+    @staticmethod
+    def getlocalkey(path):
+        """hash the path of a local file context for storage in the .hg/merge
+        directory."""
+
+        return hex(hashutil.sha1(path).digest())
+
+    def add(self, fcl, fco, fca, fd):
+        """add a new (potentially?) conflicting file the merge state
+        fcl: file context for local,
+        fco: file context for remote,
+        fca: file context for ancestors,
+        fd:  file path of the resulting merge.
+
+        note: also write the local version to the `.hg/merge` directory.
+        """
+        if fcl.isabsent():
+            localkey = nullhex
+        else:
+            localkey = mergestate.getlocalkey(fcl.path())
+            self._repo.vfs.write(b'merge/' + localkey, fcl.data())
+        self._state[fd] = [
+            MERGE_RECORD_UNRESOLVED,
+            localkey,
+            fcl.path(),
+            fca.path(),
+            hex(fca.filenode()),
+            fco.path(),
+            hex(fco.filenode()),
+            fcl.flags(),
+        ]
+        self._stateextras[fd] = {b'ancestorlinknode': hex(fca.node())}
+        self._dirty = True
+
+    def addpath(self, path, frename, forigin):
+        """add a new conflicting path to the merge state
+        path:    the path that conflicts
+        frename: the filename the conflicting file was renamed to
+        forigin: origin of the file ('l' or 'r' for local/remote)
+        """
+        self._state[path] = [MERGE_RECORD_UNRESOLVED_PATH, frename, forigin]
+        self._dirty = True
+
+    def addmergedother(self, path):
+        self._state[path] = [MERGE_RECORD_MERGED_OTHER, nullhex, nullhex]
+        self._dirty = True
+
+    def __contains__(self, dfile):
+        return dfile in self._state
+
+    def __getitem__(self, dfile):
+        return self._state[dfile][0]
+
+    def __iter__(self):
+        return iter(sorted(self._state))
+
+    def files(self):
+        return self._state.keys()
+
+    def mark(self, dfile, state):
+        self._state[dfile][0] = state
+        self._dirty = True
+
+    def mdstate(self):
+        return self._mdstate
+
+    def unresolved(self):
+        """Obtain the paths of unresolved files."""
+
+        for f, entry in pycompat.iteritems(self._state):
+            if entry[0] in (
+                MERGE_RECORD_UNRESOLVED,
+                MERGE_RECORD_UNRESOLVED_PATH,
+            ):
+                yield f
+
+    def driverresolved(self):
+        """Obtain the paths of driver-resolved files."""
+
+        for f, entry in self._state.items():
+            if entry[0] == MERGE_RECORD_DRIVER_RESOLVED:
+                yield f
+
+    def extras(self, filename):
+        return self._stateextras.setdefault(filename, {})
+
+    def _resolve(self, preresolve, dfile, wctx):
+        """rerun merge process for file path `dfile`"""
+        if self[dfile] in (MERGE_RECORD_RESOLVED, MERGE_RECORD_DRIVER_RESOLVED):
+            return True, 0
+        if self._state[dfile][0] == MERGE_RECORD_MERGED_OTHER:
+            return True, 0
+        stateentry = self._state[dfile]
+        state, localkey, lfile, afile, anode, ofile, onode, flags = stateentry
+        octx = self._repo[self._other]
+        extras = self.extras(dfile)
+        anccommitnode = extras.get(b'ancestorlinknode')
+        if anccommitnode:
+            actx = self._repo[anccommitnode]
+        else:
+            actx = None
+        fcd = self._filectxorabsent(localkey, wctx, dfile)
+        fco = self._filectxorabsent(onode, octx, ofile)
+        # TODO: move this to filectxorabsent
+        fca = self._repo.filectx(afile, fileid=anode, changectx=actx)
+        # "premerge" x flags
+        flo = fco.flags()
+        fla = fca.flags()
+        if b'x' in flags + flo + fla and b'l' not in flags + flo + fla:
+            if fca.node() == nullid and flags != flo:
+                if preresolve:
+                    self._repo.ui.warn(
+                        _(
+                            b'warning: cannot merge flags for %s '
+                            b'without common ancestor - keeping local flags\n'
+                        )
+                        % afile
+                    )
+            elif flags == fla:
+                flags = flo
+        if preresolve:
+            # restore local
+            if localkey != nullhex:
+                f = self._repo.vfs(b'merge/' + localkey)
+                wctx[dfile].write(f.read(), flags)
+                f.close()
+            else:
+                wctx[dfile].remove(ignoremissing=True)
+            complete, r, deleted = filemerge.premerge(
+                self._repo,
+                wctx,
+                self._local,
+                lfile,
+                fcd,
+                fco,
+                fca,
+                labels=self._labels,
+            )
+        else:
+            complete, r, deleted = filemerge.filemerge(
+                self._repo,
+                wctx,
+                self._local,
+                lfile,
+                fcd,
+                fco,
+                fca,
+                labels=self._labels,
+            )
+        if r is None:
+            # no real conflict
+            del self._state[dfile]
+            self._stateextras.pop(dfile, None)
+            self._dirty = True
+        elif not r:
+            self.mark(dfile, MERGE_RECORD_RESOLVED)
+
+        if complete:
+            action = None
+            if deleted:
+                if fcd.isabsent():
+                    # dc: local picked. Need to drop if present, which may
+                    # happen on re-resolves.
+                    action = ACTION_FORGET
+                else:
+                    # cd: remote picked (or otherwise deleted)
+                    action = ACTION_REMOVE
+            else:
+                if fcd.isabsent():  # dc: remote picked
+                    action = ACTION_GET
+                elif fco.isabsent():  # cd: local picked
+                    if dfile in self.localctx:
+                        action = ACTION_ADD_MODIFIED
+                    else:
+                        action = ACTION_ADD
+                # else: regular merges (no action necessary)
+            self._results[dfile] = r, action
+
+        return complete, r
+
+    def _filectxorabsent(self, hexnode, ctx, f):
+        if hexnode == nullhex:
+            return filemerge.absentfilectx(ctx, f)
+        else:
+            return ctx[f]
+
+    def preresolve(self, dfile, wctx):
+        """run premerge process for dfile
+
+        Returns whether the merge is complete, and the exit code."""
+        return self._resolve(True, dfile, wctx)
+
+    def resolve(self, dfile, wctx):
+        """run merge process (assuming premerge was run) for dfile
+
+        Returns the exit code of the merge."""
+        return self._resolve(False, dfile, wctx)[1]
+
+    def counts(self):
+        """return counts for updated, merged and removed files in this
+        session"""
+        updated, merged, removed = 0, 0, 0
+        for r, action in pycompat.itervalues(self._results):
+            if r is None:
+                updated += 1
+            elif r == 0:
+                if action == ACTION_REMOVE:
+                    removed += 1
+                else:
+                    merged += 1
+        return updated, merged, removed
+
+    def unresolvedcount(self):
+        """get unresolved count for this merge (persistent)"""
+        return len(list(self.unresolved()))
+
+    def actions(self):
+        """return lists of actions to perform on the dirstate"""
+        actions = {
+            ACTION_REMOVE: [],
+            ACTION_FORGET: [],
+            ACTION_ADD: [],
+            ACTION_ADD_MODIFIED: [],
+            ACTION_GET: [],
+        }
+        for f, (r, action) in pycompat.iteritems(self._results):
+            if action is not None:
+                actions[action].append((f, None, b"merge result"))
+        return actions
+
+    def recordactions(self):
+        """record remove/add/get actions in the dirstate"""
+        branchmerge = self._repo.dirstate.p2() != nullid
+        recordupdates(self._repo, self.actions(), branchmerge, None)
+
+    def queueremove(self, f):
+        """queues a file to be removed from the dirstate
+
+        Meant for use by custom merge drivers."""
+        self._results[f] = 0, ACTION_REMOVE
+
+    def queueadd(self, f):
+        """queues a file to be added to the dirstate
+
+        Meant for use by custom merge drivers."""
+        self._results[f] = 0, ACTION_ADD
+
+    def queueget(self, f):
+        """queues a file to be marked modified in the dirstate
+
+        Meant for use by custom merge drivers."""
+        self._results[f] = 0, ACTION_GET
+
+
+def recordupdates(repo, actions, branchmerge, getfiledata):
+    """record merge actions to the dirstate"""
+    # remove (must come first)
+    for f, args, msg in actions.get(ACTION_REMOVE, []):
+        if branchmerge:
+            repo.dirstate.remove(f)
+        else:
+            repo.dirstate.drop(f)
+
+    # forget (must come first)
+    for f, args, msg in actions.get(ACTION_FORGET, []):
+        repo.dirstate.drop(f)
+
+    # resolve path conflicts
+    for f, args, msg in actions.get(ACTION_PATH_CONFLICT_RESOLVE, []):
+        (f0,) = args
+        origf0 = repo.dirstate.copied(f0) or f0
+        repo.dirstate.add(f)
+        repo.dirstate.copy(origf0, f)
+        if f0 == origf0:
+            repo.dirstate.remove(f0)
+        else:
+            repo.dirstate.drop(f0)
+
+    # re-add
+    for f, args, msg in actions.get(ACTION_ADD, []):
+        repo.dirstate.add(f)
+
+    # re-add/mark as modified
+    for f, args, msg in actions.get(ACTION_ADD_MODIFIED, []):
+        if branchmerge:
+            repo.dirstate.normallookup(f)
+        else:
+            repo.dirstate.add(f)
+
+    # exec change
+    for f, args, msg in actions.get(ACTION_EXEC, []):
+        repo.dirstate.normallookup(f)
+
+    # keep
+    for f, args, msg in actions.get(ACTION_KEEP, []):
+        pass
+
+    # get
+    for f, args, msg in actions.get(ACTION_GET, []):
+        if branchmerge:
+            repo.dirstate.otherparent(f)
+        else:
+            parentfiledata = getfiledata[f] if getfiledata else None
+            repo.dirstate.normal(f, parentfiledata=parentfiledata)
+
+    # merge
+    for f, args, msg in actions.get(ACTION_MERGE, []):
+        f1, f2, fa, move, anc = args
+        if branchmerge:
+            # We've done a branch merge, mark this file as merged
+            # so that we properly record the merger later
+            repo.dirstate.merge(f)
+            if f1 != f2:  # copy/rename
+                if move:
+                    repo.dirstate.remove(f1)
+                if f1 != f:
+                    repo.dirstate.copy(f1, f)
+                else:
+                    repo.dirstate.copy(f2, f)
+        else:
+            # We've update-merged a locally modified file, so
+            # we set the dirstate to emulate a normal checkout
+            # of that file some time in the past. Thus our
+            # merge will appear as a normal local file
+            # modification.
+            if f2 == f:  # file not locally copied/moved
+                repo.dirstate.normallookup(f)
+            if move:
+                repo.dirstate.drop(f1)
+
+    # directory rename, move local
+    for f, args, msg in actions.get(ACTION_DIR_RENAME_MOVE_LOCAL, []):
+        f0, flag = args
+        if branchmerge:
+            repo.dirstate.add(f)
+            repo.dirstate.remove(f0)
+            repo.dirstate.copy(f0, f)
+        else:
+            repo.dirstate.normal(f)
+            repo.dirstate.drop(f0)
+
+    # directory rename, get
+    for f, args, msg in actions.get(ACTION_LOCAL_DIR_RENAME_GET, []):
+        f0, flag = args
+        if branchmerge:
+            repo.dirstate.add(f)
+            repo.dirstate.copy(f0, f)
+        else:
+            repo.dirstate.normal(f)
--- a/mercurial/narrowspec.py	Mon May 18 12:45:45 2020 -0400
+++ b/mercurial/narrowspec.py	Mon May 18 14:59:59 2020 -0400
@@ -14,6 +14,7 @@
     error,
     match as matchmod,
     merge,
+    mergestate as mergestatemod,
     scmutil,
     sparse,
     util,
@@ -272,7 +273,7 @@
 
 def _writeaddedfiles(repo, pctx, files):
     actions = merge.emptyactions()
-    addgaction = actions[merge.ACTION_GET].append
+    addgaction = actions[mergestatemod.ACTION_GET].append
     mf = repo[b'.'].manifest()
     for f in files:
         if not repo.wvfs.exists(f):
--- a/mercurial/revset.py	Mon May 18 12:45:45 2020 -0400
+++ b/mercurial/revset.py	Mon May 18 14:59:59 2020 -0400
@@ -789,9 +789,9 @@
     "merge" here includes merge conflicts from e.g. 'hg rebase' or 'hg graft'.
     """
     getargs(x, 0, 0, _(b"conflictlocal takes no arguments"))
-    from . import merge
-
-    mergestate = merge.mergestate.read(repo)
+    from . import mergestate as mergestatemod
+
+    mergestate = mergestatemod.mergestate.read(repo)
     if mergestate.active() and repo.changelog.hasnode(mergestate.local):
         return subset & {repo.changelog.rev(mergestate.local)}
 
@@ -805,9 +805,9 @@
     "merge" here includes merge conflicts from e.g. 'hg rebase' or 'hg graft'.
     """
     getargs(x, 0, 0, _(b"conflictother takes no arguments"))
-    from . import merge
-
-    mergestate = merge.mergestate.read(repo)
+    from . import mergestate as mergestatemod
+
+    mergestate = mergestatemod.mergestate.read(repo)
     if mergestate.active() and repo.changelog.hasnode(mergestate.other):
         return subset & {repo.changelog.rev(mergestate.other)}
 
--- a/mercurial/shelve.py	Mon May 18 12:45:45 2020 -0400
+++ b/mercurial/shelve.py	Mon May 18 14:59:59 2020 -0400
@@ -42,6 +42,7 @@
     lock as lockmod,
     mdiff,
     merge,
+    mergestate as mergestatemod,
     node as nodemod,
     patch,
     phases,
@@ -801,7 +802,7 @@
     basename = state.name
     with repo.lock():
         checkparents(repo, state)
-        ms = merge.mergestate.read(repo)
+        ms = mergestatemod.mergestate.read(repo)
         if list(ms.unresolved()):
             raise error.Abort(
                 _(b"unresolved conflicts, can't continue"),
--- a/mercurial/sparse.py	Mon May 18 12:45:45 2020 -0400
+++ b/mercurial/sparse.py	Mon May 18 14:59:59 2020 -0400
@@ -18,6 +18,7 @@
     error,
     match as matchmod,
     merge as mergemod,
+    mergestate as mergestatemod,
     pathutil,
     pycompat,
     scmutil,
@@ -406,7 +407,7 @@
         elif file in wctx:
             prunedactions[file] = (b'r', args, msg)
 
-        if branchmerge and type == mergemod.ACTION_MERGE:
+        if branchmerge and type == mergestatemod.ACTION_MERGE:
             f1, f2, fa, move, anc = args
             if not sparsematch(f1):
                 temporaryfiles.append(f1)
--- a/mercurial/templatekw.py	Mon May 18 12:45:45 2020 -0400
+++ b/mercurial/templatekw.py	Mon May 18 14:59:59 2020 -0400
@@ -419,9 +419,9 @@
     else:
         merge_nodes = cache.get(b'merge_nodes')
         if merge_nodes is None:
-            from . import merge
+            from . import mergestate as mergestatemod
 
-            mergestate = merge.mergestate.read(repo)
+            mergestate = mergestatemod.mergestate.read(repo)
             if mergestate.active():
                 merge_nodes = (mergestate.local, mergestate.other)
             else:
--- a/relnotes/next	Mon May 18 12:45:45 2020 -0400
+++ b/relnotes/next	Mon May 18 14:59:59 2020 -0400
@@ -11,3 +11,6 @@
 
  * logcmdutil.diffordiffstat() now takes contexts instead of nodes.
 
+ * The `mergestate` class along with some related methods and constants have
+   moved from `mercurial.merge` to a new `mercurial.mergestate` module.
+
--- a/tests/fakemergerecord.py	Mon May 18 12:45:45 2020 -0400
+++ b/tests/fakemergerecord.py	Mon May 18 14:59:59 2020 -0400
@@ -5,7 +5,7 @@
 from __future__ import absolute_import
 
 from mercurial import (
-    merge,
+    mergestate as mergestatemod,
     registrar,
 )
 
@@ -23,7 +23,7 @@
 )
 def fakemergerecord(ui, repo, *pats, **opts):
     with repo.wlock():
-        ms = merge.mergestate.read(repo)
+        ms = mergestatemod.mergestate.read(repo)
         records = ms._makerecords()
         if opts.get('mandatory'):
             records.append((b'X', b'mandatory record'))
--- a/tests/test-dirstate.t	Mon May 18 12:45:45 2020 -0400
+++ b/tests/test-dirstate.t	Mon May 18 14:59:59 2020 -0400
@@ -70,14 +70,15 @@
   > from mercurial import (
   >   error,
   >   extensions,
-  >   merge,
+  >   mergestate as mergestatemod,
   > )
   > 
   > def wraprecordupdates(*args):
   >     raise error.Abort("simulated error while recording dirstateupdates")
   > 
   > def reposetup(ui, repo):
-  >     extensions.wrapfunction(merge, 'recordupdates', wraprecordupdates)
+  >     extensions.wrapfunction(mergestatemod, 'recordupdates',
+  >                             wraprecordupdates)
   > EOF
 
   $ hg rm a
--- a/tests/test-resolve.t	Mon May 18 12:45:45 2020 -0400
+++ b/tests/test-resolve.t	Mon May 18 14:59:59 2020 -0400
@@ -92,7 +92,7 @@
   $ cat > $TESTTMP/markdriver.py << EOF
   > '''mark and unmark files as driver-resolved'''
   > from mercurial import (
-  >    merge,
+  >    mergestate,
   >    pycompat,
   >    registrar,
   >    scmutil,
@@ -106,7 +106,7 @@
   >     wlock = repo.wlock()
   >     opts = pycompat.byteskwargs(opts)
   >     try:
-  >         ms = merge.mergestate.read(repo)
+  >         ms = mergestate.mergestate.read(repo)
   >         m = scmutil.match(repo[None], pats, opts)
   >         for f in ms:
   >             if not m(f):