changeset 48281:96aa3a68d3b5

branching: merge stable into default
author Raphaël Gomès <rgomes@octobus.net>
date Thu, 21 Oct 2021 17:25:41 +0200
parents d86908050375 (diff) f3e218ae3fd9 (current diff)
children fd3d4b7f8e62
files mercurial/merge.py mercurial/revlogutils/rewrite.py rust/hgcli/pyoxidizer.bzl tests/run-tests.py tests/test-chg.t tests/test-persistent-nodemap.t
diffstat 246 files changed, 8430 insertions(+), 8257 deletions(-) [+]
line wrap: on
line diff
--- a/contrib/automation/requirements.txt	Thu Oct 21 14:03:33 2021 +0200
+++ b/contrib/automation/requirements.txt	Thu Oct 21 17:25:41 2021 +0200
@@ -37,9 +37,9 @@
     --hash=sha256:397585a7881230274afb8d1877ef69a661b0a311745cd324f14a052fb2a2863a \
     --hash=sha256:4496f8da89cb496462a831897ad248e13e431d9fa7e41e06d426fd6658ab6e59 \
     # via boto3, s3transfer
-certifi==2019.9.11 \
-    --hash=sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50 \
-    --hash=sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef \
+certifi==2021.5.30 \
+    --hash=sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee \
+    --hash=sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8 \
     # via requests
 cffi==1.12.3 \
     --hash=sha256:041c81822e9f84b1d9c401182e174996f0bae9991f33725d059b771744290774 \
--- a/contrib/dirstatenonnormalcheck.py	Thu Oct 21 14:03:33 2021 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,69 +0,0 @@
-# dirstatenonnormalcheck.py - extension to check the consistency of the
-# dirstate's non-normal map
-#
-# For most operations on dirstate, this extensions checks that the nonnormalset
-# contains the right entries.
-# It compares the nonnormal file to a nonnormalset built from the map of all
-# the files in the dirstate to check that they contain the same files.
-
-from __future__ import absolute_import
-
-from mercurial import (
-    dirstate,
-    extensions,
-    pycompat,
-)
-
-
-def nonnormalentries(dmap):
-    """Compute nonnormal entries from dirstate's dmap"""
-    res = set()
-    for f, e in dmap.iteritems():
-        if e.state != b'n' or e.mtime == -1:
-            res.add(f)
-    return res
-
-
-def checkconsistency(ui, orig, dmap, _nonnormalset, label):
-    """Compute nonnormalset from dmap, check that it matches _nonnormalset"""
-    nonnormalcomputedmap = nonnormalentries(dmap)
-    if _nonnormalset != nonnormalcomputedmap:
-        b_orig = pycompat.sysbytes(repr(orig))
-        ui.develwarn(b"%s call to %s\n" % (label, b_orig), config=b'dirstate')
-        ui.develwarn(b"inconsistency in nonnormalset\n", config=b'dirstate')
-        b_nonnormal = pycompat.sysbytes(repr(_nonnormalset))
-        ui.develwarn(b"[nonnormalset] %s\n" % b_nonnormal, config=b'dirstate')
-        b_nonnormalcomputed = pycompat.sysbytes(repr(nonnormalcomputedmap))
-        ui.develwarn(b"[map] %s\n" % b_nonnormalcomputed, config=b'dirstate')
-
-
-def _checkdirstate(orig, self, *args, **kwargs):
-    """Check nonnormal set consistency before and after the call to orig"""
-    checkconsistency(
-        self._ui, orig, self._map, self._map.nonnormalset, b"before"
-    )
-    r = orig(self, *args, **kwargs)
-    checkconsistency(
-        self._ui, orig, self._map, self._map.nonnormalset, b"after"
-    )
-    return r
-
-
-def extsetup(ui):
-    """Wrap functions modifying dirstate to check nonnormalset consistency"""
-    dirstatecl = dirstate.dirstate
-    devel = ui.configbool(b'devel', b'all-warnings')
-    paranoid = ui.configbool(b'experimental', b'nonnormalparanoidcheck')
-    if devel:
-        extensions.wrapfunction(dirstatecl, '_writedirstate', _checkdirstate)
-        if paranoid:
-            # We don't do all these checks when paranoid is disable as it would
-            # make the extension run very slowly on large repos
-            extensions.wrapfunction(dirstatecl, 'normallookup', _checkdirstate)
-            extensions.wrapfunction(dirstatecl, 'otherparent', _checkdirstate)
-            extensions.wrapfunction(dirstatecl, 'normal', _checkdirstate)
-            extensions.wrapfunction(dirstatecl, 'write', _checkdirstate)
-            extensions.wrapfunction(dirstatecl, 'add', _checkdirstate)
-            extensions.wrapfunction(dirstatecl, 'remove', _checkdirstate)
-            extensions.wrapfunction(dirstatecl, 'merge', _checkdirstate)
-            extensions.wrapfunction(dirstatecl, 'drop', _checkdirstate)
--- a/contrib/packaging/requirements-windows-py2.txt	Thu Oct 21 14:03:33 2021 +0200
+++ b/contrib/packaging/requirements-windows-py2.txt	Thu Oct 21 17:25:41 2021 +0200
@@ -4,9 +4,9 @@
 #
 #    pip-compile --generate-hashes --output-file=contrib/packaging/requirements-windows-py2.txt contrib/packaging/requirements-windows.txt.in
 #
-certifi==2020.6.20 \
-    --hash=sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3 \
-    --hash=sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41 \
+certifi==2021.5.30 \
+    --hash=sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee \
+    --hash=sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8 \
     # via dulwich
 configparser==4.0.2 \
     --hash=sha256:254c1d9c79f60c45dfde850850883d5aaa7f19a23f13561243a050d5a7c3fe4c \
--- a/contrib/packaging/requirements-windows-py3.txt	Thu Oct 21 14:03:33 2021 +0200
+++ b/contrib/packaging/requirements-windows-py3.txt	Thu Oct 21 17:25:41 2021 +0200
@@ -16,9 +16,9 @@
     --hash=sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130 \
     --hash=sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0 \
     # via pygit2
-certifi==2020.6.20 \
-    --hash=sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3 \
-    --hash=sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41 \
+certifi==2021.5.30 \
+    --hash=sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee \
+    --hash=sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8 \
     # via dulwich
 cffi==1.14.4 \
     --hash=sha256:00a1ba5e2e95684448de9b89888ccd02c98d512064b4cb987d48f4b40aa0421e \
--- a/contrib/synthrepo.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/contrib/synthrepo.py	Thu Oct 21 17:25:41 2021 +0200
@@ -57,10 +57,10 @@
     diffutil,
     error,
     hg,
+    logcmdutil,
     patch,
     pycompat,
     registrar,
-    scmutil,
 )
 from mercurial.utils import dateutil
 
@@ -180,7 +180,7 @@
 
     # If a mercurial repo is available, also model the commit history.
     if repo:
-        revs = scmutil.revrange(repo, revs)
+        revs = logcmdutil.revrange(repo, revs)
         revs.sort()
 
         progress = ui.makeprogress(
--- a/hgext/censor.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/censor.py	Thu Oct 21 17:25:41 2021 +0200
@@ -35,6 +35,7 @@
 
 from mercurial import (
     error,
+    logcmdutil,
     registrar,
     scmutil,
 )
@@ -84,7 +85,7 @@
     if not len(flog):
         raise error.Abort(_(b'cannot censor file with no history'))
 
-    rev = scmutil.revsingle(repo, rev, rev).rev()
+    rev = logcmdutil.revsingle(repo, rev, rev).rev()
     try:
         ctx = repo[rev]
     except KeyError:
--- a/hgext/children.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/children.py	Thu Oct 21 17:25:41 2021 +0200
@@ -22,7 +22,6 @@
     logcmdutil,
     pycompat,
     registrar,
-    scmutil,
 )
 
 templateopts = cmdutil.templateopts
@@ -71,7 +70,7 @@
     """
     opts = pycompat.byteskwargs(opts)
     rev = opts.get(b'rev')
-    ctx = scmutil.revsingle(repo, rev)
+    ctx = logcmdutil.revsingle(repo, rev)
     if file_:
         fctx = repo.filectx(file_, changeid=ctx.rev())
         childctxs = [fcctx.changectx() for fcctx in fctx.children()]
--- a/hgext/closehead.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/closehead.py	Thu Oct 21 17:25:41 2021 +0200
@@ -13,9 +13,9 @@
     cmdutil,
     context,
     error,
+    logcmdutil,
     pycompat,
     registrar,
-    scmutil,
 )
 
 cmdtable = {}
@@ -68,7 +68,7 @@
     opts = pycompat.byteskwargs(opts)
 
     revs += tuple(opts.get(b'rev', []))
-    revs = scmutil.revrange(repo, revs)
+    revs = logcmdutil.revrange(repo, revs)
 
     if not revs:
         raise error.Abort(_(b'no revisions specified'))
--- a/hgext/convert/hg.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/convert/hg.py	Thu Oct 21 17:25:41 2021 +0200
@@ -36,10 +36,10 @@
     exchange,
     hg,
     lock as lockmod,
+    logcmdutil,
     merge as mergemod,
     phases,
     pycompat,
-    scmutil,
     util,
 )
 from mercurial.utils import dateutil
@@ -145,7 +145,7 @@
                     _(b'pulling from %s into %s\n') % (pbranch, branch)
                 )
                 exchange.pull(
-                    self.repo, prepo, [prepo.lookup(h) for h in heads]
+                    self.repo, prepo, heads=[prepo.lookup(h) for h in heads]
                 )
             self.before()
 
@@ -564,7 +564,7 @@
                 )
             nodes = set()
             parents = set()
-            for r in scmutil.revrange(self.repo, [hgrevs]):
+            for r in logcmdutil.revrange(self.repo, [hgrevs]):
                 ctx = self.repo[r]
                 nodes.add(ctx.node())
                 parents.update(p.node() for p in ctx.parents())
--- a/hgext/eol.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/eol.py	Thu Oct 21 17:25:41 2021 +0200
@@ -423,7 +423,7 @@
                 try:
                     wlock = self.wlock()
                     for f in self.dirstate:
-                        if self.dirstate[f] != b'n':
+                        if not self.dirstate.get_entry(f).maybe_clean:
                             continue
                         if oldeol is not None:
                             if not oldeol.match(f) and not neweol.match(f):
--- a/hgext/extdiff.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/extdiff.py	Thu Oct 21 17:25:41 2021 +0200
@@ -101,6 +101,7 @@
     error,
     filemerge,
     formatter,
+    logcmdutil,
     pycompat,
     registrar,
     scmutil,
@@ -558,17 +559,17 @@
     do3way = b'$parent2' in cmdline
 
     if change:
-        ctx2 = scmutil.revsingle(repo, change, None)
+        ctx2 = logcmdutil.revsingle(repo, change, None)
         ctx1a, ctx1b = ctx2.p1(), ctx2.p2()
     elif from_rev or to_rev:
         repo = scmutil.unhidehashlikerevs(
             repo, [from_rev] + [to_rev], b'nowarn'
         )
-        ctx1a = scmutil.revsingle(repo, from_rev, None)
+        ctx1a = logcmdutil.revsingle(repo, from_rev, None)
         ctx1b = repo[nullrev]
-        ctx2 = scmutil.revsingle(repo, to_rev, None)
+        ctx2 = logcmdutil.revsingle(repo, to_rev, None)
     else:
-        ctx1a, ctx2 = scmutil.revpair(repo, revs)
+        ctx1a, ctx2 = logcmdutil.revpair(repo, revs)
         if not revs:
             ctx1b = repo[None].p2()
         else:
--- a/hgext/fastannotate/commands.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/fastannotate/commands.py	Thu Oct 21 17:25:41 2021 +0200
@@ -15,6 +15,7 @@
     encoding,
     error,
     extensions,
+    logcmdutil,
     patch,
     pycompat,
     registrar,
@@ -75,7 +76,7 @@
         def bad(x, y):
             raise error.Abort(b"%s: %s" % (x, y))
 
-        ctx = scmutil.revsingle(repo, rev)
+        ctx = logcmdutil.revsingle(repo, rev)
         m = scmutil.match(ctx, pats, opts, badfn=bad)
         for p in ctx.walk(m):
             yield p
@@ -317,7 +318,7 @@
         )
     if ui.configbool(b'fastannotate', b'unfilteredrepo'):
         repo = repo.unfiltered()
-    ctx = scmutil.revsingle(repo, rev)
+    ctx = logcmdutil.revsingle(repo, rev)
     m = scmutil.match(ctx, pats, opts)
     paths = list(ctx.walk(m))
     if util.safehasattr(repo, 'prefetchfastannotate'):
--- a/hgext/fastannotate/protocol.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/fastannotate/protocol.py	Thu Oct 21 17:25:41 2021 +0200
@@ -140,12 +140,10 @@
         def getannotate(self, path, lastnode=None):
             if not self.capable(b'getannotate'):
                 ui.warn(_(b'remote peer cannot provide annotate cache\n'))
-                yield None, None
+                return None, None
             else:
                 args = {b'path': path, b'lastnode': lastnode or b''}
-                f = wireprotov1peer.future()
-                yield args, f
-                yield _parseresponse(f.value)
+                return args, _parseresponse
 
     peer.__class__ = fastannotatepeer
 
--- a/hgext/fastexport.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/fastexport.py	Thu Oct 21 17:25:41 2021 +0200
@@ -15,6 +15,7 @@
 from mercurial.utils import stringutil
 from mercurial import (
     error,
+    logcmdutil,
     pycompat,
     registrar,
     scmutil,
@@ -182,7 +183,7 @@
     if not revs:
         revs = scmutil.revrange(repo, [b":"])
     else:
-        revs = scmutil.revrange(repo, revs)
+        revs = logcmdutil.revrange(repo, revs)
     if not revs:
         raise error.Abort(_(b"no revisions matched"))
     authorfile = opts.get(b"authormap")
--- a/hgext/fix.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/fix.py	Thu Oct 21 17:25:41 2021 +0200
@@ -144,6 +144,7 @@
     context,
     copies,
     error,
+    logcmdutil,
     match as matchmod,
     mdiff,
     merge,
@@ -283,20 +284,29 @@
         # There are no data dependencies between the workers fixing each file
         # revision, so we can use all available parallelism.
         def getfixes(items):
-            for rev, path in items:
-                ctx = repo[rev]
+            for srcrev, path, dstrevs in items:
+                ctx = repo[srcrev]
                 olddata = ctx[path].data()
                 metadata, newdata = fixfile(
-                    ui, repo, opts, fixers, ctx, path, basepaths, basectxs[rev]
+                    ui,
+                    repo,
+                    opts,
+                    fixers,
+                    ctx,
+                    path,
+                    basepaths,
+                    basectxs[srcrev],
                 )
-                # Don't waste memory/time passing unchanged content back, but
-                # produce one result per item either way.
-                yield (
-                    rev,
-                    path,
-                    metadata,
-                    newdata if newdata != olddata else None,
-                )
+                # We ungroup the work items now, because the code that consumes
+                # these results has to handle each dstrev separately, and in
+                # topological order. Because these are handled in topological
+                # order, it's important that we pass around references to
+                # "newdata" instead of copying it. Otherwise, we would be
+                # keeping more copies of file content in memory at a time than
+                # if we hadn't bothered to group/deduplicate the work items.
+                data = newdata if newdata != olddata else None
+                for dstrev in dstrevs:
+                    yield (dstrev, path, metadata, data)
 
         results = worker.worker(
             ui, 1.0, getfixes, tuple(), workqueue, threadsafe=False
@@ -376,23 +386,32 @@
 
 
 def getworkqueue(ui, repo, pats, opts, revstofix, basectxs):
-    """Constructs the list of files to be fixed at specific revisions
+    """Constructs a list of files to fix and which revisions each fix applies to
 
-    It is up to the caller how to consume the work items, and the only
-    dependence between them is that replacement revisions must be committed in
-    topological order. Each work item represents a file in the working copy or
-    in some revision that should be fixed and written back to the working copy
-    or into a replacement revision.
+    To avoid duplicating work, there is usually only one work item for each file
+    revision that might need to be fixed. There can be multiple work items per
+    file revision if the same file needs to be fixed in multiple changesets with
+    different baserevs. Each work item also contains a list of changesets where
+    the file's data should be replaced with the fixed data. The work items for
+    earlier changesets come earlier in the work queue, to improve pipelining by
+    allowing the first changeset to be replaced while fixes are still being
+    computed for later changesets.
 
-    Work items for the same revision are grouped together, so that a worker
-    pool starting with the first N items in parallel is likely to finish the
-    first revision's work before other revisions. This can allow us to write
-    the result to disk and reduce memory footprint. At time of writing, the
-    partition strategy in worker.py seems favorable to this. We also sort the
-    items by ascending revision number to match the order in which we commit
-    the fixes later.
+    Also returned is a map from changesets to the count of work items that might
+    affect each changeset. This is used later to count when all of a changeset's
+    work items have been finished, without having to inspect the remaining work
+    queue in each worker subprocess.
+
+    The example work item (1, "foo/bar.txt", (1, 2, 3)) means that the data of
+    bar.txt should be read from revision 1, then fixed, and written back to
+    revisions 1, 2 and 3. Revision 1 is called the "srcrev" and the list of
+    revisions is called the "dstrevs". In practice the srcrev is always one of
+    the dstrevs, and we make that choice when constructing the work item so that
+    the choice can't be made inconsistently later on. The dstrevs should all
+    have the same file revision for the given path, so the choice of srcrev is
+    arbitrary. The wdirrev can be a dstrev and a srcrev.
     """
-    workqueue = []
+    dstrevmap = collections.defaultdict(list)
     numitems = collections.defaultdict(int)
     maxfilesize = ui.configbytes(b'fix', b'maxfilesize')
     for rev in sorted(revstofix):
@@ -410,8 +429,21 @@
                     % (util.bytecount(maxfilesize), path)
                 )
                 continue
-            workqueue.append((rev, path))
+            baserevs = tuple(ctx.rev() for ctx in basectxs[rev])
+            dstrevmap[(fctx.filerev(), baserevs, path)].append(rev)
             numitems[rev] += 1
+    workqueue = [
+        (min(dstrevs), path, dstrevs)
+        for (_filerev, _baserevs, path), dstrevs in dstrevmap.items()
+    ]
+    # Move work items for earlier changesets to the front of the queue, so we
+    # might be able to replace those changesets (in topological order) while
+    # we're still processing later work items. Note the min() in the previous
+    # expression, which means we don't need a custom comparator here. The path
+    # is also important in the sort order to make the output order stable. There
+    # are some situations where this doesn't help much, but some situations
+    # where it lets us buffer O(1) files instead of O(n) files.
+    workqueue.sort()
     return workqueue, numitems
 
 
@@ -420,7 +452,7 @@
     if opts[b'all']:
         revs = repo.revs(b'(not public() and not obsolete()) or wdir()')
     elif opts[b'source']:
-        source_revs = scmutil.revrange(repo, opts[b'source'])
+        source_revs = logcmdutil.revrange(repo, opts[b'source'])
         revs = set(repo.revs(b'(%ld::) - obsolete()', source_revs))
         if wdirrev in source_revs:
             # `wdir()::` is currently empty, so manually add wdir
@@ -428,7 +460,7 @@
         if repo[b'.'].rev() in revs:
             revs.add(wdirrev)
     else:
-        revs = set(scmutil.revrange(repo, opts[b'rev']))
+        revs = set(logcmdutil.revrange(repo, opts[b'rev']))
         if opts.get(b'working_dir'):
             revs.add(wdirrev)
         for rev in revs:
@@ -516,9 +548,9 @@
         return {}
 
     basepaths = {}
-    for rev, path in workqueue:
-        fixctx = repo[rev]
-        for basectx in basectxs[rev]:
+    for srcrev, path, _dstrevs in workqueue:
+        fixctx = repo[srcrev]
+        for basectx in basectxs[srcrev]:
             basepath = copies.pathcopies(basectx, fixctx).get(path, path)
             if basepath in basectx:
                 basepaths[(basectx.rev(), fixctx.rev(), path)] = basepath
@@ -618,7 +650,7 @@
     # The --base flag overrides the usual logic, and we give every revision
     # exactly the set of baserevs that the user specified.
     if opts.get(b'base'):
-        baserevs = set(scmutil.revrange(repo, opts.get(b'base')))
+        baserevs = set(logcmdutil.revrange(repo, opts.get(b'base')))
         if not baserevs:
             baserevs = {nullrev}
         basectxs = {repo[rev] for rev in baserevs}
@@ -641,10 +673,10 @@
     toprefetch = set()
 
     # Prefetch the files that will be fixed.
-    for rev, path in workqueue:
-        if rev == wdirrev:
+    for srcrev, path, _dstrevs in workqueue:
+        if srcrev == wdirrev:
             continue
-        toprefetch.add((rev, path))
+        toprefetch.add((srcrev, path))
 
     # Prefetch the base contents for lineranges().
     for (baserev, fixrev, path), basepath in basepaths.items():
--- a/hgext/histedit.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/histedit.py	Thu Oct 21 17:25:41 2021 +0200
@@ -282,6 +282,11 @@
     default=None,
 )
 configitem(b'histedit', b'summary-template', default=b'{rev} {desc|firstline}')
+# TODO: Teach the text-based histedit interface to respect this config option
+# before we make it non-experimental.
+configitem(
+    b'histedit', b'later-commits-first', default=False, experimental=True
+)
 
 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
@@ -749,7 +754,7 @@
 
 
 def abortdirty():
-    raise error.Abort(
+    raise error.StateError(
         _(b'working copy has pending changes'),
         hint=_(
             b'amend, commit, or revert them and run histedit '
@@ -1052,12 +1057,12 @@
 
     outgoing = discovery.findcommonoutgoing(repo, other, revs, force=force)
     if not outgoing.missing:
-        raise error.Abort(_(b'no outgoing ancestors'))
+        raise error.StateError(_(b'no outgoing ancestors'))
     roots = list(repo.revs(b"roots(%ln)", outgoing.missing))
     if len(roots) > 1:
         msg = _(b'there are ambiguous outgoing revisions')
         hint = _(b"see 'hg help histedit' for more detail")
-        raise error.Abort(msg, hint=hint)
+        raise error.StateError(msg, hint=hint)
     return repo[roots[0]].node()
 
 
@@ -1193,166 +1198,6 @@
         return self.conflicts
 
 
-# ============ EVENTS ===============
-def movecursor(state, oldpos, newpos):
-    """Change the rule/changeset that the cursor is pointing to, regardless of
-    current mode (you can switch between patches from the view patch window)."""
-    state[b'pos'] = newpos
-
-    mode, _ = state[b'mode']
-    if mode == MODE_RULES:
-        # Scroll through the list by updating the view for MODE_RULES, so that
-        # even if we are not currently viewing the rules, switching back will
-        # result in the cursor's rule being visible.
-        modestate = state[b'modes'][MODE_RULES]
-        if newpos < modestate[b'line_offset']:
-            modestate[b'line_offset'] = newpos
-        elif newpos > modestate[b'line_offset'] + state[b'page_height'] - 1:
-            modestate[b'line_offset'] = newpos - state[b'page_height'] + 1
-
-    # Reset the patch view region to the top of the new patch.
-    state[b'modes'][MODE_PATCH][b'line_offset'] = 0
-
-
-def changemode(state, mode):
-    curmode, _ = state[b'mode']
-    state[b'mode'] = (mode, curmode)
-    if mode == MODE_PATCH:
-        state[b'modes'][MODE_PATCH][b'patchcontents'] = patchcontents(state)
-
-
-def makeselection(state, pos):
-    state[b'selected'] = pos
-
-
-def swap(state, oldpos, newpos):
-    """Swap two positions and calculate necessary conflicts in
-    O(|newpos-oldpos|) time"""
-
-    rules = state[b'rules']
-    assert 0 <= oldpos < len(rules) and 0 <= newpos < len(rules)
-
-    rules[oldpos], rules[newpos] = rules[newpos], rules[oldpos]
-
-    # TODO: swap should not know about histeditrule's internals
-    rules[newpos].pos = newpos
-    rules[oldpos].pos = oldpos
-
-    start = min(oldpos, newpos)
-    end = max(oldpos, newpos)
-    for r in pycompat.xrange(start, end + 1):
-        rules[newpos].checkconflicts(rules[r])
-        rules[oldpos].checkconflicts(rules[r])
-
-    if state[b'selected']:
-        makeselection(state, newpos)
-
-
-def changeaction(state, pos, action):
-    """Change the action state on the given position to the new action"""
-    rules = state[b'rules']
-    assert 0 <= pos < len(rules)
-    rules[pos].action = action
-
-
-def cycleaction(state, pos, next=False):
-    """Changes the action state the next or the previous action from
-    the action list"""
-    rules = state[b'rules']
-    assert 0 <= pos < len(rules)
-    current = rules[pos].action
-
-    assert current in KEY_LIST
-
-    index = KEY_LIST.index(current)
-    if next:
-        index += 1
-    else:
-        index -= 1
-    changeaction(state, pos, KEY_LIST[index % len(KEY_LIST)])
-
-
-def changeview(state, delta, unit):
-    """Change the region of whatever is being viewed (a patch or the list of
-    changesets). 'delta' is an amount (+/- 1) and 'unit' is 'page' or 'line'."""
-    mode, _ = state[b'mode']
-    if mode != MODE_PATCH:
-        return
-    mode_state = state[b'modes'][mode]
-    num_lines = len(mode_state[b'patchcontents'])
-    page_height = state[b'page_height']
-    unit = page_height if unit == b'page' else 1
-    num_pages = 1 + (num_lines - 1) // page_height
-    max_offset = (num_pages - 1) * page_height
-    newline = mode_state[b'line_offset'] + delta * unit
-    mode_state[b'line_offset'] = max(0, min(max_offset, newline))
-
-
-def event(state, ch):
-    """Change state based on the current character input
-
-    This takes the current state and based on the current character input from
-    the user we change the state.
-    """
-    selected = state[b'selected']
-    oldpos = state[b'pos']
-    rules = state[b'rules']
-
-    if ch in (curses.KEY_RESIZE, b"KEY_RESIZE"):
-        return E_RESIZE
-
-    lookup_ch = ch
-    if ch is not None and b'0' <= ch <= b'9':
-        lookup_ch = b'0'
-
-    curmode, prevmode = state[b'mode']
-    action = KEYTABLE[curmode].get(
-        lookup_ch, KEYTABLE[b'global'].get(lookup_ch)
-    )
-    if action is None:
-        return
-    if action in (b'down', b'move-down'):
-        newpos = min(oldpos + 1, len(rules) - 1)
-        movecursor(state, oldpos, newpos)
-        if selected is not None or action == b'move-down':
-            swap(state, oldpos, newpos)
-    elif action in (b'up', b'move-up'):
-        newpos = max(0, oldpos - 1)
-        movecursor(state, oldpos, newpos)
-        if selected is not None or action == b'move-up':
-            swap(state, oldpos, newpos)
-    elif action == b'next-action':
-        cycleaction(state, oldpos, next=True)
-    elif action == b'prev-action':
-        cycleaction(state, oldpos, next=False)
-    elif action == b'select':
-        selected = oldpos if selected is None else None
-        makeselection(state, selected)
-    elif action == b'goto' and int(ch) < len(rules) and len(rules) <= 10:
-        newrule = next((r for r in rules if r.origpos == int(ch)))
-        movecursor(state, oldpos, newrule.pos)
-        if selected is not None:
-            swap(state, oldpos, newrule.pos)
-    elif action.startswith(b'action-'):
-        changeaction(state, oldpos, action[7:])
-    elif action == b'showpatch':
-        changemode(state, MODE_PATCH if curmode != MODE_PATCH else prevmode)
-    elif action == b'help':
-        changemode(state, MODE_HELP if curmode != MODE_HELP else prevmode)
-    elif action == b'quit':
-        return E_QUIT
-    elif action == b'histedit':
-        return E_HISTEDIT
-    elif action == b'page-down':
-        return E_PAGEDOWN
-    elif action == b'page-up':
-        return E_PAGEUP
-    elif action == b'line-down':
-        return E_LINEDOWN
-    elif action == b'line-up':
-        return E_LINEUP
-
-
 def makecommands(rules):
     """Returns a list of commands consumable by histedit --commands based on
     our list of rules"""
@@ -1390,52 +1235,38 @@
     return line[: n - 2] + b' >'
 
 
-def patchcontents(state):
-    repo = state[b'repo']
-    rule = state[b'rules'][state[b'pos']]
-    displayer = logcmdutil.changesetdisplayer(
-        repo.ui, repo, {b"patch": True, b"template": b"status"}, buffered=True
-    )
-    overrides = {(b'ui', b'verbose'): True}
-    with repo.ui.configoverride(overrides, source=b'histedit'):
-        displayer.show(rule.ctx)
-        displayer.close()
-    return displayer.hunk[rule.ctx.rev()].splitlines()
-
-
-def _chisteditmain(repo, rules, stdscr):
-    try:
-        curses.use_default_colors()
-    except curses.error:
-        pass
-
-    # initialize color pattern
-    curses.init_pair(COLOR_HELP, curses.COLOR_WHITE, curses.COLOR_BLUE)
-    curses.init_pair(COLOR_SELECTED, curses.COLOR_BLACK, curses.COLOR_WHITE)
-    curses.init_pair(COLOR_WARN, curses.COLOR_BLACK, curses.COLOR_YELLOW)
-    curses.init_pair(COLOR_OK, curses.COLOR_BLACK, curses.COLOR_GREEN)
-    curses.init_pair(COLOR_CURRENT, curses.COLOR_WHITE, curses.COLOR_MAGENTA)
-    curses.init_pair(COLOR_DIFF_ADD_LINE, curses.COLOR_GREEN, -1)
-    curses.init_pair(COLOR_DIFF_DEL_LINE, curses.COLOR_RED, -1)
-    curses.init_pair(COLOR_DIFF_OFFSET, curses.COLOR_MAGENTA, -1)
-    curses.init_pair(COLOR_ROLL, curses.COLOR_RED, -1)
-    curses.init_pair(
-        COLOR_ROLL_CURRENT, curses.COLOR_BLACK, curses.COLOR_MAGENTA
-    )
-    curses.init_pair(COLOR_ROLL_SELECTED, curses.COLOR_RED, curses.COLOR_WHITE)
-
-    # don't display the cursor
-    try:
-        curses.curs_set(0)
-    except curses.error:
-        pass
-
-    def rendercommit(win, state):
+class _chistedit_state(object):
+    def __init__(
+        self,
+        repo,
+        rules,
+        stdscr,
+    ):
+        self.repo = repo
+        self.rules = rules
+        self.stdscr = stdscr
+        self.later_on_top = repo.ui.configbool(
+            b'histedit', b'later-commits-first'
+        )
+        # The current item in display order, initialized to point to the top
+        # of the screen.
+        self.pos = 0
+        self.selected = None
+        self.mode = (MODE_INIT, MODE_INIT)
+        self.page_height = None
+        self.modes = {
+            MODE_RULES: {
+                b'line_offset': 0,
+            },
+            MODE_PATCH: {
+                b'line_offset': 0,
+            },
+        }
+
+    def render_commit(self, win):
         """Renders the commit window that shows the log of the current selected
         commit"""
-        pos = state[b'pos']
-        rules = state[b'rules']
-        rule = rules[pos]
+        rule = self.rules[self.display_pos_to_rule_pos(self.pos)]
 
         ctx = rule.ctx
         win.box()
@@ -1449,7 +1280,7 @@
         line = b"user:      %s" % ctx.user()
         win.addstr(2, 1, line[:length])
 
-        bms = repo.nodebookmarks(ctx.node())
+        bms = self.repo.nodebookmarks(ctx.node())
         line = b"bookmark:  %s" % b' '.join(bms)
         win.addstr(3, 1, line[:length])
 
@@ -1481,8 +1312,8 @@
         win.addstr(y, 1, conflictstr[:length])
         win.noutrefresh()
 
-    def helplines(mode):
-        if mode == MODE_PATCH:
+    def helplines(self):
+        if self.mode[0] == MODE_PATCH:
             help = b"""\
 ?: help, k/up: line up, j/down: line down, v: stop viewing patch
 pgup: prev page, space/pgdn: next page, c: commit, q: abort
@@ -1495,40 +1326,70 @@
 """
         return help.splitlines()
 
-    def renderhelp(win, state):
+    def render_help(self, win):
         maxy, maxx = win.getmaxyx()
-        mode, _ = state[b'mode']
-        for y, line in enumerate(helplines(mode)):
+        for y, line in enumerate(self.helplines()):
             if y >= maxy:
                 break
             addln(win, y, 0, line, curses.color_pair(COLOR_HELP))
         win.noutrefresh()
 
-    def renderrules(rulesscr, state):
-        rules = state[b'rules']
-        pos = state[b'pos']
-        selected = state[b'selected']
-        start = state[b'modes'][MODE_RULES][b'line_offset']
-
-        conflicts = [r.ctx for r in rules if r.conflicts]
+    def layout(self):
+        maxy, maxx = self.stdscr.getmaxyx()
+        helplen = len(self.helplines())
+        mainlen = maxy - helplen - 12
+        if mainlen < 1:
+            raise error.Abort(
+                _(b"terminal dimensions %d by %d too small for curses histedit")
+                % (maxy, maxx),
+                hint=_(
+                    b"enlarge your terminal or use --config ui.interface=text"
+                ),
+            )
+        return {
+            b'commit': (12, maxx),
+            b'help': (helplen, maxx),
+            b'main': (mainlen, maxx),
+        }
+
+    def display_pos_to_rule_pos(self, display_pos):
+        """Converts a position in display order to rule order.
+
+        The `display_pos` is the order from the top in display order, not
+        considering which items are currently visible on the screen. Thus,
+        `display_pos=0` is the item at the top (possibly after scrolling to
+        the top)
+        """
+        if self.later_on_top:
+            return len(self.rules) - 1 - display_pos
+        else:
+            return display_pos
+
+    def render_rules(self, rulesscr):
+        start = self.modes[MODE_RULES][b'line_offset']
+
+        conflicts = [r.ctx for r in self.rules if r.conflicts]
         if len(conflicts) > 0:
             line = b"potential conflict in %s" % b','.join(
                 map(pycompat.bytestr, conflicts)
             )
             addln(rulesscr, -1, 0, line, curses.color_pair(COLOR_WARN))
 
-        for y, rule in enumerate(rules[start:]):
-            if y >= state[b'page_height']:
-                break
+        for display_pos in range(start, len(self.rules)):
+            y = display_pos - start
+            if y < 0 or y >= self.page_height:
+                continue
+            rule_pos = self.display_pos_to_rule_pos(display_pos)
+            rule = self.rules[rule_pos]
             if len(rule.conflicts) > 0:
                 rulesscr.addstr(y, 0, b" ", curses.color_pair(COLOR_WARN))
             else:
                 rulesscr.addstr(y, 0, b" ", curses.COLOR_BLACK)
 
-            if y + start == selected:
+            if display_pos == self.selected:
                 rollcolor = COLOR_ROLL_SELECTED
                 addln(rulesscr, y, 2, rule, curses.color_pair(COLOR_SELECTED))
-            elif y + start == pos:
+            elif display_pos == self.pos:
                 rollcolor = COLOR_ROLL_CURRENT
                 addln(
                     rulesscr,
@@ -1551,7 +1412,7 @@
 
         rulesscr.noutrefresh()
 
-    def renderstring(win, state, output, diffcolors=False):
+    def render_string(self, win, output, diffcolors=False):
         maxy, maxx = win.getmaxyx()
         length = min(maxy - 1, len(output))
         for y in range(0, length):
@@ -1573,77 +1434,239 @@
                 win.addstr(y, 0, line)
         win.noutrefresh()
 
-    def renderpatch(win, state):
-        start = state[b'modes'][MODE_PATCH][b'line_offset']
-        content = state[b'modes'][MODE_PATCH][b'patchcontents']
-        renderstring(win, state, content[start:], diffcolors=True)
-
-    def layout(mode):
-        maxy, maxx = stdscr.getmaxyx()
-        helplen = len(helplines(mode))
-        mainlen = maxy - helplen - 12
-        if mainlen < 1:
-            raise error.Abort(
-                _(b"terminal dimensions %d by %d too small for curses histedit")
-                % (maxy, maxx),
-                hint=_(
-                    b"enlarge your terminal or use --config ui.interface=text"
-                ),
-            )
-        return {
-            b'commit': (12, maxx),
-            b'help': (helplen, maxx),
-            b'main': (mainlen, maxx),
-        }
+    def render_patch(self, win):
+        start = self.modes[MODE_PATCH][b'line_offset']
+        content = self.modes[MODE_PATCH][b'patchcontents']
+        self.render_string(win, content[start:], diffcolors=True)
+
+    def event(self, ch):
+        """Change state based on the current character input
+
+        This takes the current state and based on the current character input from
+        the user we change the state.
+        """
+        oldpos = self.pos
+
+        if ch in (curses.KEY_RESIZE, b"KEY_RESIZE"):
+            return E_RESIZE
+
+        lookup_ch = ch
+        if ch is not None and b'0' <= ch <= b'9':
+            lookup_ch = b'0'
+
+        curmode, prevmode = self.mode
+        action = KEYTABLE[curmode].get(
+            lookup_ch, KEYTABLE[b'global'].get(lookup_ch)
+        )
+        if action is None:
+            return
+        if action in (b'down', b'move-down'):
+            newpos = min(oldpos + 1, len(self.rules) - 1)
+            self.move_cursor(oldpos, newpos)
+            if self.selected is not None or action == b'move-down':
+                self.swap(oldpos, newpos)
+        elif action in (b'up', b'move-up'):
+            newpos = max(0, oldpos - 1)
+            self.move_cursor(oldpos, newpos)
+            if self.selected is not None or action == b'move-up':
+                self.swap(oldpos, newpos)
+        elif action == b'next-action':
+            self.cycle_action(oldpos, next=True)
+        elif action == b'prev-action':
+            self.cycle_action(oldpos, next=False)
+        elif action == b'select':
+            self.selected = oldpos if self.selected is None else None
+            self.make_selection(self.selected)
+        elif action == b'goto' and int(ch) < len(self.rules) <= 10:
+            newrule = next((r for r in self.rules if r.origpos == int(ch)))
+            self.move_cursor(oldpos, newrule.pos)
+            if self.selected is not None:
+                self.swap(oldpos, newrule.pos)
+        elif action.startswith(b'action-'):
+            self.change_action(oldpos, action[7:])
+        elif action == b'showpatch':
+            self.change_mode(MODE_PATCH if curmode != MODE_PATCH else prevmode)
+        elif action == b'help':
+            self.change_mode(MODE_HELP if curmode != MODE_HELP else prevmode)
+        elif action == b'quit':
+            return E_QUIT
+        elif action == b'histedit':
+            return E_HISTEDIT
+        elif action == b'page-down':
+            return E_PAGEDOWN
+        elif action == b'page-up':
+            return E_PAGEUP
+        elif action == b'line-down':
+            return E_LINEDOWN
+        elif action == b'line-up':
+            return E_LINEUP
+
+    def patch_contents(self):
+        repo = self.repo
+        rule = self.rules[self.display_pos_to_rule_pos(self.pos)]
+        displayer = logcmdutil.changesetdisplayer(
+            repo.ui,
+            repo,
+            {b"patch": True, b"template": b"status"},
+            buffered=True,
+        )
+        overrides = {(b'ui', b'verbose'): True}
+        with repo.ui.configoverride(overrides, source=b'histedit'):
+            displayer.show(rule.ctx)
+            displayer.close()
+        return displayer.hunk[rule.ctx.rev()].splitlines()
+
+    def move_cursor(self, oldpos, newpos):
+        """Change the rule/changeset that the cursor is pointing to, regardless of
+        current mode (you can switch between patches from the view patch window)."""
+        self.pos = newpos
+
+        mode, _ = self.mode
+        if mode == MODE_RULES:
+            # Scroll through the list by updating the view for MODE_RULES, so that
+            # even if we are not currently viewing the rules, switching back will
+            # result in the cursor's rule being visible.
+            modestate = self.modes[MODE_RULES]
+            if newpos < modestate[b'line_offset']:
+                modestate[b'line_offset'] = newpos
+            elif newpos > modestate[b'line_offset'] + self.page_height - 1:
+                modestate[b'line_offset'] = newpos - self.page_height + 1
+
+        # Reset the patch view region to the top of the new patch.
+        self.modes[MODE_PATCH][b'line_offset'] = 0
+
+    def change_mode(self, mode):
+        curmode, _ = self.mode
+        self.mode = (mode, curmode)
+        if mode == MODE_PATCH:
+            self.modes[MODE_PATCH][b'patchcontents'] = self.patch_contents()
+
+    def make_selection(self, pos):
+        self.selected = pos
+
+    def swap(self, oldpos, newpos):
+        """Swap two positions and calculate necessary conflicts in
+        O(|newpos-oldpos|) time"""
+        old_rule_pos = self.display_pos_to_rule_pos(oldpos)
+        new_rule_pos = self.display_pos_to_rule_pos(newpos)
+
+        rules = self.rules
+        assert 0 <= old_rule_pos < len(rules) and 0 <= new_rule_pos < len(rules)
+
+        rules[old_rule_pos], rules[new_rule_pos] = (
+            rules[new_rule_pos],
+            rules[old_rule_pos],
+        )
+
+        # TODO: swap should not know about histeditrule's internals
+        rules[new_rule_pos].pos = new_rule_pos
+        rules[old_rule_pos].pos = old_rule_pos
+
+        start = min(old_rule_pos, new_rule_pos)
+        end = max(old_rule_pos, new_rule_pos)
+        for r in pycompat.xrange(start, end + 1):
+            rules[new_rule_pos].checkconflicts(rules[r])
+            rules[old_rule_pos].checkconflicts(rules[r])
+
+        if self.selected:
+            self.make_selection(newpos)
+
+    def change_action(self, pos, action):
+        """Change the action state on the given position to the new action"""
+        assert 0 <= pos < len(self.rules)
+        self.rules[pos].action = action
+
+    def cycle_action(self, pos, next=False):
+        """Changes the action state the next or the previous action from
+        the action list"""
+        assert 0 <= pos < len(self.rules)
+        current = self.rules[pos].action
+
+        assert current in KEY_LIST
+
+        index = KEY_LIST.index(current)
+        if next:
+            index += 1
+        else:
+            index -= 1
+        self.change_action(pos, KEY_LIST[index % len(KEY_LIST)])
+
+    def change_view(self, delta, unit):
+        """Change the region of whatever is being viewed (a patch or the list of
+        changesets). 'delta' is an amount (+/- 1) and 'unit' is 'page' or 'line'."""
+        mode, _ = self.mode
+        if mode != MODE_PATCH:
+            return
+        mode_state = self.modes[mode]
+        num_lines = len(mode_state[b'patchcontents'])
+        page_height = self.page_height
+        unit = page_height if unit == b'page' else 1
+        num_pages = 1 + (num_lines - 1) // page_height
+        max_offset = (num_pages - 1) * page_height
+        newline = mode_state[b'line_offset'] + delta * unit
+        mode_state[b'line_offset'] = max(0, min(max_offset, newline))
+
+
+def _chisteditmain(repo, rules, stdscr):
+    try:
+        curses.use_default_colors()
+    except curses.error:
+        pass
+
+    # initialize color pattern
+    curses.init_pair(COLOR_HELP, curses.COLOR_WHITE, curses.COLOR_BLUE)
+    curses.init_pair(COLOR_SELECTED, curses.COLOR_BLACK, curses.COLOR_WHITE)
+    curses.init_pair(COLOR_WARN, curses.COLOR_BLACK, curses.COLOR_YELLOW)
+    curses.init_pair(COLOR_OK, curses.COLOR_BLACK, curses.COLOR_GREEN)
+    curses.init_pair(COLOR_CURRENT, curses.COLOR_WHITE, curses.COLOR_MAGENTA)
+    curses.init_pair(COLOR_DIFF_ADD_LINE, curses.COLOR_GREEN, -1)
+    curses.init_pair(COLOR_DIFF_DEL_LINE, curses.COLOR_RED, -1)
+    curses.init_pair(COLOR_DIFF_OFFSET, curses.COLOR_MAGENTA, -1)
+    curses.init_pair(COLOR_ROLL, curses.COLOR_RED, -1)
+    curses.init_pair(
+        COLOR_ROLL_CURRENT, curses.COLOR_BLACK, curses.COLOR_MAGENTA
+    )
+    curses.init_pair(COLOR_ROLL_SELECTED, curses.COLOR_RED, curses.COLOR_WHITE)
+
+    # don't display the cursor
+    try:
+        curses.curs_set(0)
+    except curses.error:
+        pass
 
     def drawvertwin(size, y, x):
         win = curses.newwin(size[0], size[1], y, x)
         y += size[0]
         return win, y, x
 
-    state = {
-        b'pos': 0,
-        b'rules': rules,
-        b'selected': None,
-        b'mode': (MODE_INIT, MODE_INIT),
-        b'page_height': None,
-        b'modes': {
-            MODE_RULES: {
-                b'line_offset': 0,
-            },
-            MODE_PATCH: {
-                b'line_offset': 0,
-            },
-        },
-        b'repo': repo,
-    }
+    state = _chistedit_state(repo, rules, stdscr)
 
     # eventloop
     ch = None
     stdscr.clear()
     stdscr.refresh()
     while True:
-        oldmode, unused = state[b'mode']
+        oldmode, unused = state.mode
         if oldmode == MODE_INIT:
-            changemode(state, MODE_RULES)
-        e = event(state, ch)
+            state.change_mode(MODE_RULES)
+        e = state.event(ch)
 
         if e == E_QUIT:
             return False
         if e == E_HISTEDIT:
-            return state[b'rules']
+            return state.rules
         else:
             if e == E_RESIZE:
                 size = screen_size()
                 if size != stdscr.getmaxyx():
                     curses.resizeterm(*size)
 
-            curmode, unused = state[b'mode']
-            sizes = layout(curmode)
+            sizes = state.layout()
+            curmode, unused = state.mode
             if curmode != oldmode:
-                state[b'page_height'] = sizes[b'main'][0]
+                state.page_height = sizes[b'main'][0]
                 # Adjust the view to fit the current screen size.
-                movecursor(state, state[b'pos'], state[b'pos'])
+                state.move_cursor(state.pos, state.pos)
 
             # Pack the windows against the top, each pane spread across the
             # full width of the screen.
@@ -1654,26 +1677,26 @@
 
             if e in (E_PAGEDOWN, E_PAGEUP, E_LINEDOWN, E_LINEUP):
                 if e == E_PAGEDOWN:
-                    changeview(state, +1, b'page')
+                    state.change_view(+1, b'page')
                 elif e == E_PAGEUP:
-                    changeview(state, -1, b'page')
+                    state.change_view(-1, b'page')
                 elif e == E_LINEDOWN:
-                    changeview(state, +1, b'line')
+                    state.change_view(+1, b'line')
                 elif e == E_LINEUP:
-                    changeview(state, -1, b'line')
+                    state.change_view(-1, b'line')
 
             # start rendering
             commitwin.erase()
             helpwin.erase()
             mainwin.erase()
             if curmode == MODE_PATCH:
-                renderpatch(mainwin, state)
+                state.render_patch(mainwin)
             elif curmode == MODE_HELP:
-                renderstring(mainwin, state, __doc__.strip().splitlines())
+                state.render_string(mainwin, __doc__.strip().splitlines())
             else:
-                renderrules(mainwin, state)
-                rendercommit(commitwin, state)
-            renderhelp(helpwin, state)
+                state.render_rules(mainwin)
+                state.render_commit(commitwin)
+            state.render_help(helpwin)
             curses.doupdate()
             # done rendering
             ch = encoding.strtolocal(stdscr.getkey())
@@ -1697,26 +1720,19 @@
         cmdutil.checkunfinished(repo)
         cmdutil.bailifchanged(repo)
 
-        if os.path.exists(os.path.join(repo.path, b'histedit-state')):
-            raise error.Abort(
-                _(
-                    b'history edit already in progress, try '
-                    b'--continue or --abort'
-                )
-            )
         revs.extend(freeargs)
         if not revs:
             defaultrev = destutil.desthistedit(ui, repo)
             if defaultrev is not None:
                 revs.append(defaultrev)
         if len(revs) != 1:
-            raise error.Abort(
+            raise error.InputError(
                 _(b'histedit requires exactly one ancestor revision')
             )
 
-        rr = list(repo.set(b'roots(%ld)', scmutil.revrange(repo, revs)))
+        rr = list(repo.set(b'roots(%ld)', logcmdutil.revrange(repo, revs)))
         if len(rr) != 1:
-            raise error.Abort(
+            raise error.InputError(
                 _(
                     b'The specified revisions must have '
                     b'exactly one common root'
@@ -1727,15 +1743,15 @@
         topmost = repo.dirstate.p1()
         revs = between(repo, root, topmost, keep)
         if not revs:
-            raise error.Abort(
+            raise error.InputError(
                 _(b'%s is not an ancestor of working directory') % short(root)
             )
 
-        ctxs = []
+        rules = []
         for i, r in enumerate(revs):
-            ctxs.append(histeditrule(ui, repo[r], i))
+            rules.append(histeditrule(ui, repo[r], i))
         with util.with_lc_ctype():
-            rc = curses.wrapper(functools.partial(_chisteditmain, repo, ctxs))
+            rc = curses.wrapper(functools.partial(_chisteditmain, repo, rules))
         curses.echo()
         curses.endwin()
         if rc is False:
@@ -1928,12 +1944,12 @@
             return f.read()
 
 
-def _validateargs(ui, repo, state, freeargs, opts, goal, rules, revs):
+def _validateargs(ui, repo, freeargs, opts, goal, rules, revs):
     # TODO only abort if we try to histedit mq patches, not just
     # blanket if mq patches are applied somewhere
     mq = getattr(repo, 'mq', None)
     if mq and mq.applied:
-        raise error.Abort(_(b'source has mq patches applied'))
+        raise error.StateError(_(b'source has mq patches applied'))
 
     # basic argument incompatibility processing
     outg = opts.get(b'outgoing')
@@ -1941,31 +1957,26 @@
     abort = opts.get(b'abort')
     force = opts.get(b'force')
     if force and not outg:
-        raise error.Abort(_(b'--force only allowed with --outgoing'))
+        raise error.InputError(_(b'--force only allowed with --outgoing'))
     if goal == b'continue':
         if any((outg, abort, revs, freeargs, rules, editplan)):
-            raise error.Abort(_(b'no arguments allowed with --continue'))
+            raise error.InputError(_(b'no arguments allowed with --continue'))
     elif goal == b'abort':
         if any((outg, revs, freeargs, rules, editplan)):
-            raise error.Abort(_(b'no arguments allowed with --abort'))
+            raise error.InputError(_(b'no arguments allowed with --abort'))
     elif goal == b'edit-plan':
         if any((outg, revs, freeargs)):
-            raise error.Abort(
+            raise error.InputError(
                 _(b'only --commands argument allowed with --edit-plan')
             )
     else:
-        if state.inprogress():
-            raise error.Abort(
-                _(
-                    b'history edit already in progress, try '
-                    b'--continue or --abort'
-                )
-            )
         if outg:
             if revs:
-                raise error.Abort(_(b'no revisions allowed with --outgoing'))
+                raise error.InputError(
+                    _(b'no revisions allowed with --outgoing')
+                )
             if len(freeargs) > 1:
-                raise error.Abort(
+                raise error.InputError(
                     _(b'only one repo argument allowed with --outgoing')
                 )
         else:
@@ -1976,7 +1987,7 @@
                     revs.append(defaultrev)
 
             if len(revs) != 1:
-                raise error.Abort(
+                raise error.InputError(
                     _(b'histedit requires exactly one ancestor revision')
                 )
 
@@ -1990,11 +2001,11 @@
     rules = opts.get(b'commands', b'')
     state.keep = opts.get(b'keep', False)
 
-    _validateargs(ui, repo, state, freeargs, opts, goal, rules, revs)
+    _validateargs(ui, repo, freeargs, opts, goal, rules, revs)
 
     hastags = False
     if revs:
-        revs = scmutil.revrange(repo, revs)
+        revs = logcmdutil.revrange(repo, revs)
         ctxs = [repo[rev] for rev in revs]
         for ctx in ctxs:
             tags = [tag for tag in ctx.tags() if tag != b'tip']
@@ -2009,7 +2020,7 @@
             ),
             default=1,
         ):
-            raise error.Abort(_(b'histedit cancelled\n'))
+            raise error.CanceledError(_(b'histedit cancelled\n'))
     # rebuild state
     if goal == goalcontinue:
         state.read()
@@ -2217,9 +2228,9 @@
             remote = None
         root = findoutgoing(ui, repo, remote, force, opts)
     else:
-        rr = list(repo.set(b'roots(%ld)', scmutil.revrange(repo, revs)))
+        rr = list(repo.set(b'roots(%ld)', logcmdutil.revrange(repo, revs)))
         if len(rr) != 1:
-            raise error.Abort(
+            raise error.InputError(
                 _(
                     b'The specified revisions must have '
                     b'exactly one common root'
@@ -2229,7 +2240,7 @@
 
     revs = between(repo, root, topmost, state.keep)
     if not revs:
-        raise error.Abort(
+        raise error.InputError(
             _(b'%s is not an ancestor of working directory') % short(root)
         )
 
@@ -2259,7 +2270,7 @@
                 followcopies=False,
             )
         except error.Abort:
-            raise error.Abort(
+            raise error.StateError(
                 _(
                     b"untracked files in working directory conflict with files in %s"
                 )
@@ -2337,7 +2348,9 @@
     if revs and not keep:
         rewriteutil.precheck(repo, revs, b'edit')
         if repo.revs(b'(%ld) and merge()', revs):
-            raise error.Abort(_(b'cannot edit history that contains merges'))
+            raise error.StateError(
+                _(b'cannot edit history that contains merges')
+            )
     return pycompat.maplist(repo.changelog.node, revs)
 
 
--- a/hgext/infinitepush/__init__.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/infinitepush/__init__.py	Thu Oct 21 17:25:41 2021 +0200
@@ -431,18 +431,19 @@
 @wireprotov1peer.batchable
 def listkeyspatterns(self, namespace, patterns):
     if not self.capable(b'pushkey'):
-        yield {}, None
-    f = wireprotov1peer.future()
+        return {}, None
     self.ui.debug(b'preparing listkeys for "%s"\n' % namespace)
-    yield {
+
+    def decode(d):
+        self.ui.debug(
+            b'received listkey for "%s": %i bytes\n' % (namespace, len(d))
+        )
+        return pushkey.decodekeys(d)
+
+    return {
         b'namespace': encoding.fromlocal(namespace),
         b'patterns': wireprototypes.encodelist(patterns),
-    }, f
-    d = f.value
-    self.ui.debug(
-        b'received listkey for "%s": %i bytes\n' % (namespace, len(d))
-    )
-    yield pushkey.decodekeys(d)
+    }, decode
 
 
 def _readbundlerevs(bundlerepo):
--- a/hgext/largefiles/lfcommands.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/largefiles/lfcommands.py	Thu Oct 21 17:25:41 2021 +0200
@@ -26,6 +26,7 @@
     exthelper,
     hg,
     lock,
+    logcmdutil,
     match as matchmod,
     pycompat,
     scmutil,
@@ -540,7 +541,7 @@
                     expecthash = lfutil.readasstandin(wctx[standin])
                     if expecthash != b'':
                         if lfile not in wctx:  # not switched to normal file
-                            if repo.dirstate[standin] != b'?':
+                            if repo.dirstate.get_entry(standin).any_tracked:
                                 wvfs.unlinkpath(lfile, ignoremissing=True)
                             else:
                                 dropped.add(lfile)
@@ -568,7 +569,7 @@
                         removed += 1
 
         # largefile processing might be slow and be interrupted - be prepared
-        lfdirstate.write()
+        lfdirstate.write(repo.currenttransaction())
 
         if lfiles:
             lfiles = [f for f in lfiles if f not in dropped]
@@ -577,7 +578,7 @@
                 repo.wvfs.unlinkpath(lfutil.standin(f))
                 # This needs to happen for dropped files, otherwise they stay in
                 # the M state.
-                lfdirstate._drop(f)
+                lfdirstate._map.reset_state(f)
 
             statuswriter(_(b'getting changed largefiles\n'))
             cachelfiles(ui, repo, None, lfiles)
@@ -618,7 +619,7 @@
 
                 lfutil.synclfdirstate(repo, lfdirstate, lfile, normallookup)
 
-        lfdirstate.write()
+        lfdirstate.write(repo.currenttransaction())
         if lfiles:
             statuswriter(
                 _(b'%d largefiles updated, %d removed\n') % (updated, removed)
@@ -657,7 +658,7 @@
     revs = opts.get('rev', [])
     if not revs:
         raise error.Abort(_(b'no revisions specified'))
-    revs = scmutil.revrange(repo, revs)
+    revs = logcmdutil.revrange(repo, revs)
 
     numcached = 0
     for rev in revs:
--- a/hgext/largefiles/lfutil.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/largefiles/lfutil.py	Thu Oct 21 17:25:41 2021 +0200
@@ -191,10 +191,12 @@
     def _ignore(self, f):
         return False
 
-    def write(self, tr=False):
+    def write(self, tr):
         # (1) disable PENDING mode always
         #     (lfdirstate isn't yet managed as a part of the transaction)
         # (2) avoid develwarn 'use dirstate.write with ....'
+        if tr:
+            tr.addbackup(b'largefiles/dirstate', location=b'plain')
         super(largefilesdirstate, self).write(None)
 
 
@@ -269,7 +271,7 @@
     return [
         splitstandin(f)
         for f in repo[rev].walk(matcher)
-        if rev is not None or repo.dirstate[f] != b'?'
+        if rev is not None or repo.dirstate.get_entry(f).any_tracked
     ]
 
 
@@ -558,24 +560,14 @@
     if lfstandin not in repo.dirstate:
         lfdirstate.update_file(lfile, p1_tracked=False, wc_tracked=False)
     else:
-        stat = repo.dirstate._map[lfstandin]
-        state, mtime = stat.state, stat.mtime
-        if state == b'n':
-            if normallookup or mtime < 0 or not repo.wvfs.exists(lfile):
-                # state 'n' doesn't ensure 'clean' in this case
-                lfdirstate.update_file(
-                    lfile, p1_tracked=True, wc_tracked=True, possibly_dirty=True
-                )
-            else:
-                lfdirstate.update_file(lfile, p1_tracked=True, wc_tracked=True)
-        elif state == b'm':
-            lfdirstate.update_file(
-                lfile, p1_tracked=True, wc_tracked=True, merged=True
-            )
-        elif state == b'r':
-            lfdirstate.update_file(lfile, p1_tracked=True, wc_tracked=False)
-        elif state == b'a':
-            lfdirstate.update_file(lfile, p1_tracked=False, wc_tracked=True)
+        entry = repo.dirstate.get_entry(lfstandin)
+        lfdirstate.update_file(
+            lfile,
+            wc_tracked=entry.tracked,
+            p1_tracked=entry.p1_tracked,
+            p2_info=entry.p2_info,
+            possibly_dirty=True,
+        )
 
 
 def markcommitted(orig, ctx, node):
@@ -598,7 +590,7 @@
             lfile = splitstandin(f)
             if lfile is not None:
                 synclfdirstate(repo, lfdirstate, lfile, False)
-    lfdirstate.write()
+    lfdirstate.write(repo.currenttransaction())
 
     # As part of committing, copy all of the largefiles into the cache.
     #
@@ -713,7 +705,7 @@
     lfdirstate = openlfdirstate(ui, repo)
     for fstandin in standins:
         lfile = splitstandin(fstandin)
-        if lfdirstate[lfile] != b'r':
+        if lfdirstate.get_entry(lfile).tracked:
             updatestandin(repo, lfile, fstandin)
 
     # Cook up a new matcher that only matches regular files or
@@ -737,10 +729,10 @@
         # standin removal, drop the normal file if it is unknown to dirstate.
         # Thus, skip plain largefile names but keep the standin.
         if f in lfiles or fstandin in standins:
-            if repo.dirstate[fstandin] != b'r':
-                if repo.dirstate[f] != b'r':
+            if not repo.dirstate.get_entry(fstandin).removed:
+                if not repo.dirstate.get_entry(f).removed:
                     continue
-            elif repo.dirstate[f] == b'?':
+            elif not repo.dirstate.get_entry(f).any_tracked:
                 continue
 
         actualfiles.append(f)
--- a/hgext/largefiles/overrides.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/largefiles/overrides.py	Thu Oct 21 17:25:41 2021 +0200
@@ -151,7 +151,7 @@
                 )
                 standins.append(standinname)
                 lfdirstate.set_tracked(f)
-            lfdirstate.write()
+            lfdirstate.write(repo.currenttransaction())
             bad += [
                 lfutil.splitstandin(f)
                 for f in repo[None].add(standins)
@@ -229,7 +229,7 @@
         for f in remove:
             lfdirstate.set_untracked(lfutil.splitstandin(f))
 
-        lfdirstate.write()
+        lfdirstate.write(repo.currenttransaction())
 
     return result
 
@@ -659,7 +659,7 @@
                 )
                 # make sure lfile doesn't get synclfdirstate'd as normal
                 lfdirstate.update_file(lfile, p1_tracked=False, wc_tracked=True)
-        lfdirstate.write()
+        lfdirstate.write(repo.currenttransaction())
 
     return orig(repo, actions, branchmerge, getfiledata)
 
@@ -864,7 +864,7 @@
                     util.copyfile(repo.wjoin(srclfile), repo.wjoin(destlfile))
 
                 lfdirstate.set_tracked(destlfile)
-        lfdirstate.write()
+        lfdirstate.write(repo.currenttransaction())
     except error.Abort as e:
         if e.message != _(b'no files to copy'):
             raise e
@@ -896,7 +896,7 @@
     with repo.wlock():
         lfdirstate = lfutil.openlfdirstate(ui, repo)
         s = lfutil.lfdirstatestatus(lfdirstate, repo)
-        lfdirstate.write()
+        lfdirstate.write(repo.currenttransaction())
         for lfile in s.modified:
             lfutil.updatestandin(repo, lfile, lfutil.standin(lfile))
         for lfile in s.deleted:
@@ -934,7 +934,7 @@
                 standin = lfutil.standin(f)
                 if standin in ctx or standin in mctx:
                     matchfiles.append(standin)
-                elif standin in wctx or lfdirstate[f] == b'r':
+                elif standin in wctx or lfdirstate.get_entry(f).removed:
                     continue
                 else:
                     matchfiles.append(f)
@@ -1000,7 +1000,7 @@
         numcached = 0
         repo.firstpulled = revsprepull  # for pulled() revset expression
         try:
-            for rev in scmutil.revrange(repo, lfrevs):
+            for rev in logcmdutil.revrange(repo, lfrevs):
                 ui.note(_(b'pulling largefiles for revision %d\n') % rev)
                 (cached, missing) = lfcommands.cachelfiles(ui, repo, rev)
                 numcached += len(cached)
@@ -1027,7 +1027,7 @@
     lfrevs = kwargs.pop('lfrev', None)
     if lfrevs:
         opargs = kwargs.setdefault('opargs', {})
-        opargs[b'lfrevs'] = scmutil.revrange(repo, lfrevs)
+        opargs[b'lfrevs'] = logcmdutil.revrange(repo, lfrevs)
     return orig(ui, repo, *args, **kwargs)
 
 
@@ -1383,7 +1383,7 @@
         lfdirstate = lfutil.openlfdirstate(ui, repo)
         for f in forget:
             lfdirstate.set_untracked(f)
-        lfdirstate.write()
+        lfdirstate.write(repo.currenttransaction())
         standins = [lfutil.standin(f) for f in forget]
         for f in standins:
             repo.wvfs.unlinkpath(f, ignoremissing=True)
@@ -1591,8 +1591,12 @@
             node1, node2, match, ignored, clean, unknown, listsubrepos
         )
         lfdirstate = lfutil.openlfdirstate(ui, repo)
-        unknown = [f for f in r.unknown if lfdirstate[f] == b'?']
-        ignored = [f for f in r.ignored if lfdirstate[f] == b'?']
+        unknown = [
+            f for f in r.unknown if not lfdirstate.get_entry(f).any_tracked
+        ]
+        ignored = [
+            f for f in r.ignored if not lfdirstate.get_entry(f).any_tracked
+        ]
         return scmutil.status(
             r.modified, r.added, r.removed, r.deleted, unknown, ignored, r.clean
         )
@@ -1609,7 +1613,7 @@
         orphans = {
             f
             for f in repo.dirstate
-            if lfutil.isstandin(f) and repo.dirstate[f] != b'r'
+            if lfutil.isstandin(f) and not repo.dirstate.get_entry(f).removed
         }
         result = orig(ui, repo, **opts)
         after = repo.dirstate.parents()
@@ -1620,7 +1624,7 @@
         for f in repo.dirstate:
             if lfutil.isstandin(f):
                 orphans.discard(f)
-                if repo.dirstate[f] == b'r':
+                if repo.dirstate.get_entry(f).removed:
                     repo.wvfs.unlinkpath(f, ignoremissing=True)
                 elif f in pctx:
                     fctx = pctx[f]
@@ -1632,18 +1636,6 @@
         for standin in orphans:
             repo.wvfs.unlinkpath(standin, ignoremissing=True)
 
-        lfdirstate = lfutil.openlfdirstate(ui, repo)
-        with lfdirstate.parentchange():
-            orphans = set(lfdirstate)
-            lfiles = lfutil.listlfiles(repo)
-            for file in lfiles:
-                lfutil.synclfdirstate(repo, lfdirstate, file, True)
-                orphans.discard(file)
-            for lfile in orphans:
-                lfdirstate.update_file(
-                    lfile, p1_tracked=False, wc_tracked=False
-                )
-        lfdirstate.write()
     return result
 
 
@@ -1663,7 +1655,7 @@
 @eh.wrapcommand(b'cat')
 def overridecat(orig, ui, repo, file1, *pats, **opts):
     opts = pycompat.byteskwargs(opts)
-    ctx = scmutil.revsingle(repo, opts.get(b'rev'))
+    ctx = logcmdutil.revsingle(repo, opts.get(b'rev'))
     err = 1
     notbad = set()
     m = scmutil.match(ctx, (file1,) + pats, opts)
@@ -1787,10 +1779,8 @@
         # mark all clean largefiles as dirty, just in case the update gets
         # interrupted before largefiles and lfdirstate are synchronized
         for lfile in oldclean:
-            entry = lfdirstate._map.get(lfile)
-            assert not (entry.merged_removed or entry.from_p2_removed)
             lfdirstate.set_possibly_dirty(lfile)
-        lfdirstate.write()
+        lfdirstate.write(repo.currenttransaction())
 
         oldstandins = lfutil.getstandinsstate(repo)
         wc = kwargs.get('wc')
@@ -1810,7 +1800,7 @@
             # all the ones that didn't change as clean
             for lfile in oldclean.difference(filelist):
                 lfdirstate.update_file(lfile, p1_tracked=True, wc_tracked=True)
-            lfdirstate.write()
+            lfdirstate.write(repo.currenttransaction())
 
             if branchmerge or force or partial:
                 filelist.extend(s.deleted + s.removed)
--- a/hgext/largefiles/proto.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/largefiles/proto.py	Thu Oct 21 17:25:41 2021 +0200
@@ -184,17 +184,18 @@
 
         @wireprotov1peer.batchable
         def statlfile(self, sha):
-            f = wireprotov1peer.future()
+            def decode(d):
+                try:
+                    return int(d)
+                except (ValueError, urlerr.httperror):
+                    # If the server returns anything but an integer followed by a
+                    # newline, newline, it's not speaking our language; if we get
+                    # an HTTP error, we can't be sure the largefile is present;
+                    # either way, consider it missing.
+                    return 2
+
             result = {b'sha': sha}
-            yield result, f
-            try:
-                yield int(f.value)
-            except (ValueError, urlerr.httperror):
-                # If the server returns anything but an integer followed by a
-                # newline, newline, it's not speaking our language; if we get
-                # an HTTP error, we can't be sure the largefile is present;
-                # either way, consider it missing.
-                yield 2
+            return result, decode
 
     repo.__class__ = lfileswirerepository
 
--- a/hgext/largefiles/reposetup.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/largefiles/reposetup.py	Thu Oct 21 17:25:41 2021 +0200
@@ -310,7 +310,7 @@
                     ]
 
                 if gotlock:
-                    lfdirstate.write()
+                    lfdirstate.write(self.currenttransaction())
 
             self.lfstatus = True
             return scmutil.status(*result)
--- a/hgext/lfs/__init__.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/lfs/__init__.py	Thu Oct 21 17:25:41 2021 +0200
@@ -137,6 +137,7 @@
     filelog,
     filesetlang,
     localrepo,
+    logcmdutil,
     minifileset,
     pycompat,
     revlog,
@@ -417,7 +418,7 @@
 def debuglfsupload(ui, repo, **opts):
     """upload lfs blobs added by the working copy parent or given revisions"""
     revs = opts.get('rev', [])
-    pointers = wrapper.extractpointers(repo, scmutil.revrange(repo, revs))
+    pointers = wrapper.extractpointers(repo, logcmdutil.revrange(repo, revs))
     wrapper.uploadblobs(repo, pointers)
 
 
--- a/hgext/mq.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/mq.py	Thu Oct 21 17:25:41 2021 +0200
@@ -1241,7 +1241,7 @@
         if opts.get(b'rev'):
             if not self.applied:
                 raise error.Abort(_(b'no patches applied'))
-            revs = scmutil.revrange(repo, opts.get(b'rev'))
+            revs = logcmdutil.revrange(repo, opts.get(b'rev'))
             revs.sort()
             revpatches = self._revpatches(repo, revs)
             realpatches += revpatches
@@ -1267,9 +1267,9 @@
         if any((b'.hgsubstate' in files for files in mar)):
             return  # already listed up
         # not yet listed up
-        if substatestate in b'a?':
+        if substatestate.added or not substatestate.any_tracked:
             mar[1].append(b'.hgsubstate')
-        elif substatestate in b'r':
+        elif substatestate.removed:
             mar[2].append(b'.hgsubstate')
         else:  # modified
             mar[0].append(b'.hgsubstate')
@@ -1377,7 +1377,7 @@
             self.checkpatchname(patchfn)
         inclsubs = checksubstate(repo)
         if inclsubs:
-            substatestate = repo.dirstate[b'.hgsubstate']
+            substatestate = repo.dirstate.get_entry(b'.hgsubstate')
         if opts.get(b'include') or opts.get(b'exclude') or pats:
             # detect missing files in pats
             def badfn(f, msg):
@@ -1908,7 +1908,7 @@
 
             inclsubs = checksubstate(repo, patchparent)
             if inclsubs:
-                substatestate = repo.dirstate[b'.hgsubstate']
+                substatestate = repo.dirstate.get_entry(b'.hgsubstate')
 
             ph = patchheader(self.join(patchfn), self.plainmode)
             diffopts = self.diffopts(
@@ -2417,7 +2417,7 @@
                 raise error.Abort(
                     _(b'option "-r" not valid when importing files')
                 )
-            rev = scmutil.revrange(repo, rev)
+            rev = logcmdutil.revrange(repo, rev)
             rev.sort(reverse=True)
         elif not files:
             raise error.Abort(_(b'no files or revisions specified'))
@@ -3638,7 +3638,7 @@
     if r and patch in r.dirstate:
         wctx = r[None]
         with r.wlock():
-            if r.dirstate[patch] == b'a':
+            if r.dirstate.get_entry(patch).added:
                 r.dirstate.set_untracked(patch)
                 r.dirstate.set_tracked(name)
             else:
@@ -3878,7 +3878,7 @@
         ui.status(_(b'no patches applied\n'))
         return 0
 
-    revs = scmutil.revrange(repo, revrange)
+    revs = logcmdutil.revrange(repo, revrange)
     if repo[b'.'].rev() in revs and repo[None].files():
         ui.warn(_(b'warning: uncommitted changes in the working directory\n'))
     # queue.finish may changes phases but leave the responsibility to lock the
--- a/hgext/narrow/narrowcommands.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/narrow/narrowcommands.py	Thu Oct 21 17:25:41 2021 +0200
@@ -289,7 +289,7 @@
                 repair.strip(ui, unfi, tostrip, topic=b'narrow', backup=backup)
 
         todelete = []
-        for t, f, f2, size in repo.store.datafiles():
+        for t, f, size in repo.store.datafiles():
             if f.startswith(b'data/'):
                 file = f[5:-2]
                 if not newmatch(file):
--- a/hgext/patchbomb.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/patchbomb.py	Thu Oct 21 17:25:41 2021 +0200
@@ -91,6 +91,7 @@
     error,
     formatter,
     hg,
+    logcmdutil,
     mail,
     patch,
     pycompat,
@@ -812,7 +813,7 @@
             raise error.Abort(_(b"bookmark '%s' not found") % bookmark)
         revs = scmutil.bookmarkrevs(repo, bookmark)
 
-    revs = scmutil.revrange(repo, revs)
+    revs = logcmdutil.revrange(repo, revs)
     if outgoing:
         revs = _getoutgoing(repo, dest, revs)
     if bundle:
--- a/hgext/phabricator.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/phabricator.py	Thu Oct 21 17:25:41 2021 +0200
@@ -1354,7 +1354,7 @@
     """
     opts = pycompat.byteskwargs(opts)
     revs = list(revs) + opts.get(b'rev', [])
-    revs = scmutil.revrange(repo, revs)
+    revs = logcmdutil.revrange(repo, revs)
     revs.sort()  # ascending order to preserve topological parent/child in phab
 
     if not revs:
@@ -2276,7 +2276,7 @@
         if specs:
             raise error.InputError(_(b'cannot specify both DREVSPEC and --rev'))
 
-        drevmap = getdrevmap(repo, scmutil.revrange(repo, [revs]))
+        drevmap = getdrevmap(repo, logcmdutil.revrange(repo, [revs]))
         specs = []
         unknown = []
         for r, d in pycompat.iteritems(drevmap):
--- a/hgext/rebase.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/rebase.py	Thu Oct 21 17:25:41 2021 +0200
@@ -35,6 +35,7 @@
     dirstateguard,
     error,
     extensions,
+    logcmdutil,
     merge as mergemod,
     mergestate as mergestatemod,
     mergeutil,
@@ -1302,19 +1303,19 @@
     dest = None
 
     if revf:
-        rebaseset = scmutil.revrange(repo, revf)
+        rebaseset = logcmdutil.revrange(repo, revf)
         if not rebaseset:
             ui.status(_(b'empty "rev" revision set - nothing to rebase\n'))
             return None
     elif srcf:
-        src = scmutil.revrange(repo, srcf)
+        src = logcmdutil.revrange(repo, srcf)
         if not src:
             ui.status(_(b'empty "source" revision set - nothing to rebase\n'))
             return None
         # `+  (%ld)` to work around `wdir()::` being empty
         rebaseset = repo.revs(b'(%ld):: + (%ld)', src, src)
     else:
-        base = scmutil.revrange(repo, basef or [b'.'])
+        base = logcmdutil.revrange(repo, basef or [b'.'])
         if not base:
             ui.status(
                 _(b'empty "base" revision set - ' b"can't compute rebase set\n")
@@ -1322,7 +1323,7 @@
             return None
         if destf:
             # --base does not support multiple destinations
-            dest = scmutil.revsingle(repo, destf)
+            dest = logcmdutil.revsingle(repo, destf)
         else:
             dest = repo[_destrebase(repo, base, destspace=destspace)]
             destf = bytes(dest)
--- a/hgext/releasenotes.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/releasenotes.py	Thu Oct 21 17:25:41 2021 +0200
@@ -24,10 +24,10 @@
     cmdutil,
     config,
     error,
+    logcmdutil,
     minirst,
     pycompat,
     registrar,
-    scmutil,
     util,
 )
 from mercurial.utils import (
@@ -676,7 +676,7 @@
         return _getadmonitionlist(ui, sections)
 
     rev = opts.get(b'rev')
-    revs = scmutil.revrange(repo, [rev or b'not public()'])
+    revs = logcmdutil.revrange(repo, [rev or b'not public()'])
     if opts.get(b'check'):
         return checkadmonitions(ui, repo, sections.names(), revs)
 
--- a/hgext/remotefilelog/contentstore.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/remotefilelog/contentstore.py	Thu Oct 21 17:25:41 2021 +0200
@@ -378,7 +378,7 @@
             ledger.markdataentry(self, treename, node)
             ledger.markhistoryentry(self, treename, node)
 
-        for t, path, encoded, size in self._store.datafiles():
+        for t, path, size in self._store.datafiles():
             if path[:5] != b'meta/' or path[-2:] != b'.i':
                 continue
 
--- a/hgext/remotefilelog/fileserverclient.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/remotefilelog/fileserverclient.py	Thu Oct 21 17:25:41 2021 +0200
@@ -63,12 +63,14 @@
                 raise error.Abort(
                     b'configured remotefile server does not support getfile'
                 )
-            f = wireprotov1peer.future()
-            yield {b'file': file, b'node': node}, f
-            code, data = f.value.split(b'\0', 1)
-            if int(code):
-                raise error.LookupError(file, node, data)
-            yield data
+
+            def decode(d):
+                code, data = d.split(b'\0', 1)
+                if int(code):
+                    raise error.LookupError(file, node, data)
+                return data
+
+            return {b'file': file, b'node': node}, decode
 
         @wireprotov1peer.batchable
         def x_rfl_getflogheads(self, path):
@@ -77,10 +79,11 @@
                     b'configured remotefile server does not '
                     b'support getflogheads'
                 )
-            f = wireprotov1peer.future()
-            yield {b'path': path}, f
-            heads = f.value.split(b'\n') if f.value else []
-            yield heads
+
+            def decode(d):
+                return d.split(b'\n') if d else []
+
+            return {b'path': path}, decode
 
         def _updatecallstreamopts(self, command, opts):
             if command != b'getbundle':
--- a/hgext/remotefilelog/remotefilelogserver.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/remotefilelog/remotefilelogserver.py	Thu Oct 21 17:25:41 2021 +0200
@@ -166,24 +166,24 @@
                                 n = util.pconvert(fp[striplen:])
                                 d = store.decodedir(n)
                                 t = store.FILETYPE_OTHER
-                                yield (t, d, n, st.st_size)
+                                yield (t, d, st.st_size)
                         if kind == stat.S_IFDIR:
                             visit.append(fp)
 
             if scmutil.istreemanifest(repo):
-                for (t, u, e, s) in repo.store.datafiles():
+                for (t, u, s) in repo.store.datafiles():
                     if u.startswith(b'meta/') and (
                         u.endswith(b'.i') or u.endswith(b'.d')
                     ):
-                        yield (t, u, e, s)
+                        yield (t, u, s)
 
             # Return .d and .i files that do not match the shallow pattern
             match = state.match
             if match and not match.always():
-                for (t, u, e, s) in repo.store.datafiles():
+                for (t, u, s) in repo.store.datafiles():
                     f = u[5:-2]  # trim data/...  and .i/.d
                     if not state.match(f):
-                        yield (t, u, e, s)
+                        yield (t, u, s)
 
             for x in repo.store.topfiles():
                 if state.noflatmf and x[1][:11] == b'00manifest.':
--- a/hgext/sparse.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/sparse.py	Thu Oct 21 17:25:41 2021 +0200
@@ -255,14 +255,9 @@
 
     # Prevent adding files that are outside the sparse checkout
     editfuncs = [
-        b'normal',
         b'set_tracked',
         b'set_untracked',
-        b'add',
-        b'normallookup',
         b'copy',
-        b'remove',
-        b'merge',
     ]
     hint = _(
         b'include file with `hg debugsparse --include <pattern>` or use '
--- a/hgext/split.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/split.py	Thu Oct 21 17:25:41 2021 +0200
@@ -22,6 +22,7 @@
     commands,
     error,
     hg,
+    logcmdutil,
     pycompat,
     registrar,
     revsetlang,
@@ -75,7 +76,7 @@
         # If the rebase somehow runs into conflicts, make sure
         # we close the transaction so the user can continue it.
         with util.acceptintervention(tr):
-            revs = scmutil.revrange(repo, revlist or [b'.'])
+            revs = logcmdutil.revrange(repo, revlist or [b'.'])
             if len(revs) > 1:
                 raise error.InputError(_(b'cannot split multiple revisions'))
 
--- a/hgext/transplant.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/hgext/transplant.py	Thu Oct 21 17:25:41 2021 +0200
@@ -37,7 +37,6 @@
     pycompat,
     registrar,
     revset,
-    scmutil,
     smartset,
     state as statemod,
     util,
@@ -845,7 +844,7 @@
         if opts.get(b'prune'):
             prune = {
                 source[r].node()
-                for r in scmutil.revrange(source, opts.get(b'prune'))
+                for r in logcmdutil.revrange(source, opts.get(b'prune'))
             }
             matchfn = lambda x: tf(x) and x not in prune
         else:
@@ -853,7 +852,7 @@
         merges = pycompat.maplist(source.lookup, opts.get(b'merge', ()))
         revmap = {}
         if revs:
-            for r in scmutil.revrange(source, revs):
+            for r in logcmdutil.revrange(source, revs):
                 revmap[int(r)] = source[r].node()
         elif opts.get(b'all') or not merges:
             if source != repo:
--- a/mercurial/archival.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/archival.py	Thu Oct 21 17:25:41 2021 +0200
@@ -29,6 +29,8 @@
     vfs as vfsmod,
 )
 
+from .utils import stringutil
+
 stringio = util.stringio
 
 # from unzip source code:
@@ -196,7 +198,7 @@
                         name, pycompat.sysstr(mode + kind), fileobj
                     )
                 except tarfile.CompressionError as e:
-                    raise error.Abort(pycompat.bytestr(e))
+                    raise error.Abort(stringutil.forcebytestr(e))
 
         if isinstance(dest, bytes):
             self.z = taropen(b'w:', name=dest)
--- a/mercurial/bdiff.h	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/bdiff.h	Thu Oct 21 17:25:41 2021 +0200
@@ -1,5 +1,5 @@
-#ifndef _HG_BDIFF_H_
-#define _HG_BDIFF_H_
+#ifndef HG_BDIFF_H
+#define HG_BDIFF_H
 
 #include "compat.h"
 
--- a/mercurial/bitmanipulation.h	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/bitmanipulation.h	Thu Oct 21 17:25:41 2021 +0200
@@ -1,5 +1,5 @@
-#ifndef _HG_BITMANIPULATION_H_
-#define _HG_BITMANIPULATION_H_
+#ifndef HG_BITMANIPULATION_H
+#define HG_BITMANIPULATION_H
 
 #include <string.h>
 
--- a/mercurial/bookmarks.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/bookmarks.py	Thu Oct 21 17:25:41 2021 +0200
@@ -680,8 +680,25 @@
     return books
 
 
-def updatefromremote(ui, repo, remotemarks, path, trfunc, explicit=()):
-    ui.debug(b"checking for updated bookmarks\n")
+def mirroring_remote(ui, repo, remotemarks):
+    """computes the bookmark changes that set the local bookmarks to
+    remotemarks"""
+    changed = []
+    localmarks = repo._bookmarks
+    for (b, id) in pycompat.iteritems(remotemarks):
+        if id != localmarks.get(b, None) and id in repo:
+            changed.append((b, id, ui.debug, _(b"updating bookmark %s\n") % b))
+    for b in localmarks:
+        if b not in remotemarks:
+            changed.append(
+                (b, None, ui.debug, _(b"removing bookmark %s\n") % b)
+            )
+    return changed
+
+
+def merging_from_remote(ui, repo, remotemarks, path, explicit=()):
+    """computes the bookmark changes that merge remote bookmarks into the
+    local bookmarks, based on comparebookmarks"""
     localmarks = repo._bookmarks
     (
         addsrc,
@@ -752,6 +769,20 @@
                 _(b"remote bookmark %s points to locally missing %s\n")
                 % (b, hex(scid)[:12])
             )
+    return changed
+
+
+def updatefromremote(
+    ui, repo, remotemarks, path, trfunc, explicit=(), mode=None
+):
+    if mode == b'ignore':
+        # This should move to an higher level to avoid fetching bookmark at all
+        return
+    ui.debug(b"checking for updated bookmarks\n")
+    if mode == b'mirror':
+        changed = mirroring_remote(ui, repo, remotemarks)
+    else:
+        changed = merging_from_remote(ui, repo, remotemarks, path, explicit)
 
     if changed:
         tr = trfunc()
@@ -760,11 +791,14 @@
         for b, node, writer, msg in sorted(changed, key=key):
             changes.append((b, node))
             writer(msg)
-        localmarks.applychanges(repo, tr, changes)
+        repo._bookmarks.applychanges(repo, tr, changes)
 
 
-def incoming(ui, repo, peer):
+def incoming(ui, repo, peer, mode=None):
     """Show bookmarks incoming from other to repo"""
+    if mode == b'ignore':
+        ui.status(_(b"bookmarks exchange disabled with this path\n"))
+        return 0
     ui.status(_(b"searching for changed bookmarks\n"))
 
     with peer.commandexecutor() as e:
@@ -777,9 +811,6 @@
             ).result()
         )
 
-    r = comparebookmarks(repo, remotemarks, repo._bookmarks)
-    addsrc, adddst, advsrc, advdst, diverge, differ, invalid, same = r
-
     incomings = []
     if ui.debugflag:
         getid = lambda id: id
@@ -795,18 +826,36 @@
         def add(b, id, st):
             incomings.append(b"   %-25s %s\n" % (b, getid(id)))
 
-    for b, scid, dcid in addsrc:
-        # i18n: "added" refers to a bookmark
-        add(b, hex(scid), _(b'added'))
-    for b, scid, dcid in advsrc:
-        # i18n: "advanced" refers to a bookmark
-        add(b, hex(scid), _(b'advanced'))
-    for b, scid, dcid in diverge:
-        # i18n: "diverged" refers to a bookmark
-        add(b, hex(scid), _(b'diverged'))
-    for b, scid, dcid in differ:
-        # i18n: "changed" refers to a bookmark
-        add(b, hex(scid), _(b'changed'))
+    if mode == b'mirror':
+        localmarks = repo._bookmarks
+        allmarks = set(remotemarks.keys()) | set(localmarks.keys())
+        for b in sorted(allmarks):
+            loc = localmarks.get(b)
+            rem = remotemarks.get(b)
+            if loc == rem:
+                continue
+            elif loc is None:
+                add(b, hex(rem), _(b'added'))
+            elif rem is None:
+                add(b, hex(repo.nullid), _(b'removed'))
+            else:
+                add(b, hex(rem), _(b'changed'))
+    else:
+        r = comparebookmarks(repo, remotemarks, repo._bookmarks)
+        addsrc, adddst, advsrc, advdst, diverge, differ, invalid, same = r
+
+        for b, scid, dcid in addsrc:
+            # i18n: "added" refers to a bookmark
+            add(b, hex(scid), _(b'added'))
+        for b, scid, dcid in advsrc:
+            # i18n: "advanced" refers to a bookmark
+            add(b, hex(scid), _(b'advanced'))
+        for b, scid, dcid in diverge:
+            # i18n: "diverged" refers to a bookmark
+            add(b, hex(scid), _(b'diverged'))
+        for b, scid, dcid in differ:
+            # i18n: "changed" refers to a bookmark
+            add(b, hex(scid), _(b'changed'))
 
     if not incomings:
         ui.status(_(b"no changed bookmarks found\n"))
--- a/mercurial/bundlerepo.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/bundlerepo.py	Thu Oct 21 17:25:41 2021 +0200
@@ -699,7 +699,9 @@
                 },
             ).result()
 
-        pullop = exchange.pulloperation(bundlerepo, peer, heads=reponodes)
+        pullop = exchange.pulloperation(
+            bundlerepo, peer, path=None, heads=reponodes
+        )
         pullop.trmanager = bundletransactionmanager()
         exchange._pullapplyphases(pullop, remotephases)
 
--- a/mercurial/cext/charencode.c	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/cext/charencode.c	Thu Oct 21 17:25:41 2021 +0200
@@ -264,7 +264,7 @@
 		}
 
 		tuple = (dirstateItemObject *)v;
-		if (tuple->state != 'r') {
+		if (tuple->flags | dirstate_flag_wc_tracked) {
 			PyObject *normed;
 			if (table != NULL) {
 				normed = _asciitransform(k, table,
--- a/mercurial/cext/dirs.c	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/cext/dirs.c	Thu Oct 21 17:25:41 2021 +0200
@@ -161,7 +161,7 @@
 	return ret;
 }
 
-static int dirs_fromdict(PyObject *dirs, PyObject *source, char skipchar)
+static int dirs_fromdict(PyObject *dirs, PyObject *source, bool only_tracked)
 {
 	PyObject *key, *value;
 	Py_ssize_t pos = 0;
@@ -171,13 +171,14 @@
 			PyErr_SetString(PyExc_TypeError, "expected string key");
 			return -1;
 		}
-		if (skipchar) {
+		if (only_tracked) {
 			if (!dirstate_tuple_check(value)) {
 				PyErr_SetString(PyExc_TypeError,
 				                "expected a dirstate tuple");
 				return -1;
 			}
-			if (((dirstateItemObject *)value)->state == skipchar)
+			if (!(((dirstateItemObject *)value)->flags &
+			      dirstate_flag_wc_tracked))
 				continue;
 		}
 
@@ -218,15 +219,17 @@
  * Calculate a refcounted set of directory names for the files in a
  * dirstate.
  */
-static int dirs_init(dirsObject *self, PyObject *args)
+static int dirs_init(dirsObject *self, PyObject *args, PyObject *kwargs)
 {
 	PyObject *dirs = NULL, *source = NULL;
-	char skipchar = 0;
+	int only_tracked = 0;
 	int ret = -1;
+	static char *keywords_name[] = {"map", "only_tracked", NULL};
 
 	self->dict = NULL;
 
-	if (!PyArg_ParseTuple(args, "|Oc:__init__", &source, &skipchar))
+	if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|Oi:__init__",
+	                                 keywords_name, &source, &only_tracked))
 		return -1;
 
 	dirs = PyDict_New();
@@ -237,10 +240,10 @@
 	if (source == NULL)
 		ret = 0;
 	else if (PyDict_Check(source))
-		ret = dirs_fromdict(dirs, source, skipchar);
-	else if (skipchar)
+		ret = dirs_fromdict(dirs, source, (bool)only_tracked);
+	else if (only_tracked)
 		PyErr_SetString(PyExc_ValueError,
-		                "skip character is only supported "
+		                "`only_tracked` is only supported "
 		                "with a dict source");
 	else
 		ret = dirs_fromiter(dirs, source);
--- a/mercurial/cext/parsers.c	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/cext/parsers.c	Thu Oct 21 17:25:41 2021 +0200
@@ -44,42 +44,98 @@
 	return _dict_new_presized(expected_size);
 }
 
-static inline dirstateItemObject *make_dirstate_item(char state, int mode,
-                                                     int size, int mtime)
-{
-	dirstateItemObject *t =
-	    PyObject_New(dirstateItemObject, &dirstateItemType);
-	if (!t) {
-		return NULL;
-	}
-	t->state = state;
-	t->mode = mode;
-	t->size = size;
-	t->mtime = mtime;
-	return t;
-}
-
 static PyObject *dirstate_item_new(PyTypeObject *subtype, PyObject *args,
                                    PyObject *kwds)
 {
 	/* We do all the initialization here and not a tp_init function because
 	 * dirstate_item is immutable. */
 	dirstateItemObject *t;
-	char state;
-	int size, mode, mtime;
-	if (!PyArg_ParseTuple(args, "ciii", &state, &mode, &size, &mtime)) {
+	int wc_tracked;
+	int p1_tracked;
+	int p2_info;
+	int has_meaningful_data;
+	int has_meaningful_mtime;
+	int mode;
+	int size;
+	int mtime_s;
+	int mtime_ns;
+	PyObject *parentfiledata;
+	PyObject *fallback_exec;
+	PyObject *fallback_symlink;
+	static char *keywords_name[] = {
+	    "wc_tracked",          "p1_tracked",           "p2_info",
+	    "has_meaningful_data", "has_meaningful_mtime", "parentfiledata",
+	    "fallback_exec",       "fallback_symlink",     NULL,
+	};
+	wc_tracked = 0;
+	p1_tracked = 0;
+	p2_info = 0;
+	has_meaningful_mtime = 1;
+	has_meaningful_data = 1;
+	parentfiledata = Py_None;
+	fallback_exec = Py_None;
+	fallback_symlink = Py_None;
+	if (!PyArg_ParseTupleAndKeywords(args, kwds, "|iiiiiOOO", keywords_name,
+	                                 &wc_tracked, &p1_tracked, &p2_info,
+	                                 &has_meaningful_data,
+	                                 &has_meaningful_mtime, &parentfiledata,
+	                                 &fallback_exec, &fallback_symlink)) {
 		return NULL;
 	}
-
 	t = (dirstateItemObject *)subtype->tp_alloc(subtype, 1);
 	if (!t) {
 		return NULL;
 	}
-	t->state = state;
-	t->mode = mode;
-	t->size = size;
-	t->mtime = mtime;
+
+	t->flags = 0;
+	if (wc_tracked) {
+		t->flags |= dirstate_flag_wc_tracked;
+	}
+	if (p1_tracked) {
+		t->flags |= dirstate_flag_p1_tracked;
+	}
+	if (p2_info) {
+		t->flags |= dirstate_flag_p2_info;
+	}
+
+	if (fallback_exec != Py_None) {
+		t->flags |= dirstate_flag_has_fallback_exec;
+		if (PyObject_IsTrue(fallback_exec)) {
+			t->flags |= dirstate_flag_fallback_exec;
+		}
+	}
+	if (fallback_symlink != Py_None) {
+		t->flags |= dirstate_flag_has_fallback_symlink;
+		if (PyObject_IsTrue(fallback_symlink)) {
+			t->flags |= dirstate_flag_fallback_symlink;
+		}
+	}
 
+	if (parentfiledata != Py_None) {
+		if (!PyArg_ParseTuple(parentfiledata, "ii(ii)", &mode, &size,
+		                      &mtime_s, &mtime_ns)) {
+			return NULL;
+		}
+	} else {
+		has_meaningful_data = 0;
+		has_meaningful_mtime = 0;
+	}
+	if (has_meaningful_data) {
+		t->flags |= dirstate_flag_has_meaningful_data;
+		t->mode = mode;
+		t->size = size;
+	} else {
+		t->mode = 0;
+		t->size = 0;
+	}
+	if (has_meaningful_mtime) {
+		t->flags |= dirstate_flag_has_mtime;
+		t->mtime_s = mtime_s;
+		t->mtime_ns = mtime_ns;
+	} else {
+		t->mtime_s = 0;
+		t->mtime_ns = 0;
+	}
 	return (PyObject *)t;
 }
 
@@ -88,92 +144,191 @@
 	PyObject_Del(o);
 }
 
-static Py_ssize_t dirstate_item_length(PyObject *o)
+static inline bool dirstate_item_c_tracked(dirstateItemObject *self)
+{
+	return (self->flags & dirstate_flag_wc_tracked);
+}
+
+static inline bool dirstate_item_c_any_tracked(dirstateItemObject *self)
 {
-	return 4;
+	const int mask = dirstate_flag_wc_tracked | dirstate_flag_p1_tracked |
+	                 dirstate_flag_p2_info;
+	return (self->flags & mask);
+}
+
+static inline bool dirstate_item_c_added(dirstateItemObject *self)
+{
+	const int mask = (dirstate_flag_wc_tracked | dirstate_flag_p1_tracked |
+	                  dirstate_flag_p2_info);
+	const int target = dirstate_flag_wc_tracked;
+	return (self->flags & mask) == target;
 }
 
-static PyObject *dirstate_item_item(PyObject *o, Py_ssize_t i)
+static inline bool dirstate_item_c_removed(dirstateItemObject *self)
+{
+	if (self->flags & dirstate_flag_wc_tracked) {
+		return false;
+	}
+	return (self->flags &
+	        (dirstate_flag_p1_tracked | dirstate_flag_p2_info));
+}
+
+static inline bool dirstate_item_c_merged(dirstateItemObject *self)
 {
-	dirstateItemObject *t = (dirstateItemObject *)o;
-	switch (i) {
-	case 0:
-		return PyBytes_FromStringAndSize(&t->state, 1);
-	case 1:
-		return PyInt_FromLong(t->mode);
-	case 2:
-		return PyInt_FromLong(t->size);
-	case 3:
-		return PyInt_FromLong(t->mtime);
-	default:
-		PyErr_SetString(PyExc_IndexError, "index out of range");
-		return NULL;
+	return ((self->flags & dirstate_flag_wc_tracked) &&
+	        (self->flags & dirstate_flag_p1_tracked) &&
+	        (self->flags & dirstate_flag_p2_info));
+}
+
+static inline bool dirstate_item_c_from_p2(dirstateItemObject *self)
+{
+	return ((self->flags & dirstate_flag_wc_tracked) &&
+	        !(self->flags & dirstate_flag_p1_tracked) &&
+	        (self->flags & dirstate_flag_p2_info));
+}
+
+static inline char dirstate_item_c_v1_state(dirstateItemObject *self)
+{
+	if (dirstate_item_c_removed(self)) {
+		return 'r';
+	} else if (dirstate_item_c_merged(self)) {
+		return 'm';
+	} else if (dirstate_item_c_added(self)) {
+		return 'a';
+	} else {
+		return 'n';
 	}
 }
 
-static PySequenceMethods dirstate_item_sq = {
-    dirstate_item_length, /* sq_length */
-    0,                    /* sq_concat */
-    0,                    /* sq_repeat */
-    dirstate_item_item,   /* sq_item */
-    0,                    /* sq_ass_item */
-    0,                    /* sq_contains */
-    0,                    /* sq_inplace_concat */
-    0                     /* sq_inplace_repeat */
+static inline bool dirstate_item_c_has_fallback_exec(dirstateItemObject *self)
+{
+	return (bool)self->flags & dirstate_flag_has_fallback_exec;
+}
+
+static inline bool
+dirstate_item_c_has_fallback_symlink(dirstateItemObject *self)
+{
+	return (bool)self->flags & dirstate_flag_has_fallback_symlink;
+}
+
+static inline int dirstate_item_c_v1_mode(dirstateItemObject *self)
+{
+	if (self->flags & dirstate_flag_has_meaningful_data) {
+		return self->mode;
+	} else {
+		return 0;
+	}
+}
+
+static inline int dirstate_item_c_v1_size(dirstateItemObject *self)
+{
+	if (!(self->flags & dirstate_flag_wc_tracked) &&
+	    (self->flags & dirstate_flag_p2_info)) {
+		if (self->flags & dirstate_flag_p1_tracked) {
+			return dirstate_v1_nonnormal;
+		} else {
+			return dirstate_v1_from_p2;
+		}
+	} else if (dirstate_item_c_removed(self)) {
+		return 0;
+	} else if (self->flags & dirstate_flag_p2_info) {
+		return dirstate_v1_from_p2;
+	} else if (dirstate_item_c_added(self)) {
+		return dirstate_v1_nonnormal;
+	} else if (self->flags & dirstate_flag_has_meaningful_data) {
+		return self->size;
+	} else {
+		return dirstate_v1_nonnormal;
+	}
+}
+
+static inline int dirstate_item_c_v1_mtime(dirstateItemObject *self)
+{
+	if (dirstate_item_c_removed(self)) {
+		return 0;
+	} else if (!(self->flags & dirstate_flag_has_mtime) ||
+	           !(self->flags & dirstate_flag_p1_tracked) ||
+	           !(self->flags & dirstate_flag_wc_tracked) ||
+	           (self->flags & dirstate_flag_p2_info)) {
+		return ambiguous_time;
+	} else {
+		return self->mtime_s;
+	}
+}
+
+static PyObject *dirstate_item_v2_data(dirstateItemObject *self)
+{
+	int flags = self->flags;
+	int mode = dirstate_item_c_v1_mode(self);
+	if ((mode & S_IXUSR) != 0) {
+		flags |= dirstate_flag_mode_exec_perm;
+	} else {
+		flags &= ~dirstate_flag_mode_exec_perm;
+	}
+	if (S_ISLNK(mode)) {
+		flags |= dirstate_flag_mode_is_symlink;
+	} else {
+		flags &= ~dirstate_flag_mode_is_symlink;
+	}
+	return Py_BuildValue("iiii", flags, self->size, self->mtime_s,
+	                     self->mtime_ns);
 };
 
 static PyObject *dirstate_item_v1_state(dirstateItemObject *self)
 {
-	return PyBytes_FromStringAndSize(&self->state, 1);
+	char state = dirstate_item_c_v1_state(self);
+	return PyBytes_FromStringAndSize(&state, 1);
 };
 
 static PyObject *dirstate_item_v1_mode(dirstateItemObject *self)
 {
-	return PyInt_FromLong(self->mode);
+	return PyInt_FromLong(dirstate_item_c_v1_mode(self));
 };
 
 static PyObject *dirstate_item_v1_size(dirstateItemObject *self)
 {
-	return PyInt_FromLong(self->size);
+	return PyInt_FromLong(dirstate_item_c_v1_size(self));
 };
 
 static PyObject *dirstate_item_v1_mtime(dirstateItemObject *self)
 {
-	return PyInt_FromLong(self->mtime);
+	return PyInt_FromLong(dirstate_item_c_v1_mtime(self));
 };
 
-static PyObject *dm_nonnormal(dirstateItemObject *self)
+static PyObject *dirstate_item_need_delay(dirstateItemObject *self,
+                                          PyObject *now)
 {
-	if (self->state != 'n' || self->mtime == ambiguous_time) {
-		Py_RETURN_TRUE;
-	} else {
-		Py_RETURN_FALSE;
+	int now_s;
+	int now_ns;
+	if (!PyArg_ParseTuple(now, "ii", &now_s, &now_ns)) {
+		return NULL;
 	}
-};
-static PyObject *dm_otherparent(dirstateItemObject *self)
-{
-	if (self->size == dirstate_v1_from_p2) {
+	if (dirstate_item_c_v1_state(self) == 'n' && self->mtime_s == now_s) {
 		Py_RETURN_TRUE;
 	} else {
 		Py_RETURN_FALSE;
 	}
 };
 
-static PyObject *dirstate_item_need_delay(dirstateItemObject *self,
-                                          PyObject *value)
+static PyObject *dirstate_item_mtime_likely_equal_to(dirstateItemObject *self,
+                                                     PyObject *other)
 {
-	long now;
-	if (!pylong_to_long(value, &now)) {
+	int other_s;
+	int other_ns;
+	if (!PyArg_ParseTuple(other, "ii", &other_s, &other_ns)) {
 		return NULL;
 	}
-	if (self->state == 'n' && self->mtime == now) {
+	if ((self->flags & dirstate_flag_has_mtime) &&
+	    self->mtime_s == other_s &&
+	    (self->mtime_ns == other_ns || self->mtime_ns == 0 ||
+	     other_ns == 0)) {
 		Py_RETURN_TRUE;
 	} else {
 		Py_RETURN_FALSE;
 	}
 };
 
-/* This will never change since it's bound to V1, unlike `make_dirstate_item`
+/* This will never change since it's bound to V1
  */
 static inline dirstateItemObject *
 dirstate_item_from_v1_data(char state, int mode, int size, int mtime)
@@ -183,10 +338,56 @@
 	if (!t) {
 		return NULL;
 	}
-	t->state = state;
-	t->mode = mode;
-	t->size = size;
-	t->mtime = mtime;
+	t->flags = 0;
+	t->mode = 0;
+	t->size = 0;
+	t->mtime_s = 0;
+	t->mtime_ns = 0;
+
+	if (state == 'm') {
+		t->flags = (dirstate_flag_wc_tracked |
+		            dirstate_flag_p1_tracked | dirstate_flag_p2_info);
+	} else if (state == 'a') {
+		t->flags = dirstate_flag_wc_tracked;
+	} else if (state == 'r') {
+		if (size == dirstate_v1_nonnormal) {
+			t->flags =
+			    dirstate_flag_p1_tracked | dirstate_flag_p2_info;
+		} else if (size == dirstate_v1_from_p2) {
+			t->flags = dirstate_flag_p2_info;
+		} else {
+			t->flags = dirstate_flag_p1_tracked;
+		}
+	} else if (state == 'n') {
+		if (size == dirstate_v1_from_p2) {
+			t->flags =
+			    dirstate_flag_wc_tracked | dirstate_flag_p2_info;
+		} else if (size == dirstate_v1_nonnormal) {
+			t->flags =
+			    dirstate_flag_wc_tracked | dirstate_flag_p1_tracked;
+		} else if (mtime == ambiguous_time) {
+			t->flags = (dirstate_flag_wc_tracked |
+			            dirstate_flag_p1_tracked |
+			            dirstate_flag_has_meaningful_data);
+			t->mode = mode;
+			t->size = size;
+		} else {
+			t->flags = (dirstate_flag_wc_tracked |
+			            dirstate_flag_p1_tracked |
+			            dirstate_flag_has_meaningful_data |
+			            dirstate_flag_has_mtime);
+			t->mode = mode;
+			t->size = size;
+			t->mtime_s = mtime;
+		}
+	} else {
+		PyErr_Format(PyExc_RuntimeError,
+		             "unknown state: `%c` (%d, %d, %d)", state, mode,
+		             size, mtime, NULL);
+		Py_DECREF(t);
+		return NULL;
+	}
+
 	return t;
 }
 
@@ -196,22 +397,52 @@
 {
 	/* We do all the initialization here and not a tp_init function because
 	 * dirstate_item is immutable. */
-	dirstateItemObject *t;
 	char state;
 	int size, mode, mtime;
 	if (!PyArg_ParseTuple(args, "ciii", &state, &mode, &size, &mtime)) {
 		return NULL;
 	}
+	return (PyObject *)dirstate_item_from_v1_data(state, mode, size, mtime);
+};
 
-	t = (dirstateItemObject *)subtype->tp_alloc(subtype, 1);
+static PyObject *dirstate_item_from_v2_meth(PyTypeObject *subtype,
+                                            PyObject *args)
+{
+	dirstateItemObject *t =
+	    PyObject_New(dirstateItemObject, &dirstateItemType);
 	if (!t) {
 		return NULL;
 	}
-	t->state = state;
-	t->mode = mode;
-	t->size = size;
-	t->mtime = mtime;
-
+	if (!PyArg_ParseTuple(args, "iiii", &t->flags, &t->size, &t->mtime_s,
+	                      &t->mtime_ns)) {
+		return NULL;
+	}
+	if (t->flags & dirstate_flag_expected_state_is_modified) {
+		t->flags &= ~(dirstate_flag_expected_state_is_modified |
+		              dirstate_flag_has_meaningful_data |
+		              dirstate_flag_has_mtime);
+	}
+	if (t->flags & dirstate_flag_mtime_second_ambiguous) {
+		/* The current code is not able to do the more subtle comparison
+		 * that the MTIME_SECOND_AMBIGUOUS requires. So we ignore the
+		 * mtime */
+		t->flags &= ~(dirstate_flag_mtime_second_ambiguous |
+		              dirstate_flag_has_meaningful_data |
+		              dirstate_flag_has_mtime);
+	}
+	t->mode = 0;
+	if (t->flags & dirstate_flag_has_meaningful_data) {
+		if (t->flags & dirstate_flag_mode_exec_perm) {
+			t->mode = 0755;
+		} else {
+			t->mode = 0644;
+		}
+		if (t->flags & dirstate_flag_mode_is_symlink) {
+			t->mode |= S_IFLNK;
+		} else {
+			t->mode |= S_IFREG;
+		}
+	}
 	return (PyObject *)t;
 };
 
@@ -219,11 +450,62 @@
    to make sure it is correct. */
 static PyObject *dirstate_item_set_possibly_dirty(dirstateItemObject *self)
 {
-	self->mtime = ambiguous_time;
+	self->flags &= ~dirstate_flag_has_mtime;
+	Py_RETURN_NONE;
+}
+
+/* See docstring of the python implementation for details */
+static PyObject *dirstate_item_set_clean(dirstateItemObject *self,
+                                         PyObject *args)
+{
+	int size, mode, mtime_s, mtime_ns;
+	if (!PyArg_ParseTuple(args, "ii(ii)", &mode, &size, &mtime_s,
+	                      &mtime_ns)) {
+		return NULL;
+	}
+	self->flags = dirstate_flag_wc_tracked | dirstate_flag_p1_tracked |
+	              dirstate_flag_has_meaningful_data |
+	              dirstate_flag_has_mtime;
+	self->mode = mode;
+	self->size = size;
+	self->mtime_s = mtime_s;
+	self->mtime_ns = mtime_ns;
 	Py_RETURN_NONE;
 }
 
+static PyObject *dirstate_item_set_tracked(dirstateItemObject *self)
+{
+	self->flags |= dirstate_flag_wc_tracked;
+	self->flags &= ~dirstate_flag_has_mtime;
+	Py_RETURN_NONE;
+}
+
+static PyObject *dirstate_item_set_untracked(dirstateItemObject *self)
+{
+	self->flags &= ~dirstate_flag_wc_tracked;
+	self->mode = 0;
+	self->size = 0;
+	self->mtime_s = 0;
+	self->mtime_ns = 0;
+	Py_RETURN_NONE;
+}
+
+static PyObject *dirstate_item_drop_merge_data(dirstateItemObject *self)
+{
+	if (self->flags & dirstate_flag_p2_info) {
+		self->flags &= ~(dirstate_flag_p2_info |
+		                 dirstate_flag_has_meaningful_data |
+		                 dirstate_flag_has_mtime);
+		self->mode = 0;
+		self->size = 0;
+		self->mtime_s = 0;
+		self->mtime_ns = 0;
+	}
+	Py_RETURN_NONE;
+}
 static PyMethodDef dirstate_item_methods[] = {
+    {"v2_data", (PyCFunction)dirstate_item_v2_data, METH_NOARGS,
+     "return data suitable for v2 serialization"},
     {"v1_state", (PyCFunction)dirstate_item_v1_state, METH_NOARGS,
      "return a \"state\" suitable for v1 serialization"},
     {"v1_mode", (PyCFunction)dirstate_item_v1_mode, METH_NOARGS,
@@ -234,40 +516,134 @@
      "return a \"mtime\" suitable for v1 serialization"},
     {"need_delay", (PyCFunction)dirstate_item_need_delay, METH_O,
      "True if the stored mtime would be ambiguous with the current time"},
-    {"from_v1_data", (PyCFunction)dirstate_item_from_v1_meth, METH_O,
-     "build a new DirstateItem object from V1 data"},
+    {"mtime_likely_equal_to", (PyCFunction)dirstate_item_mtime_likely_equal_to,
+     METH_O, "True if the stored mtime is likely equal to the given mtime"},
+    {"from_v1_data", (PyCFunction)dirstate_item_from_v1_meth,
+     METH_VARARGS | METH_CLASS, "build a new DirstateItem object from V1 data"},
+    {"from_v2_data", (PyCFunction)dirstate_item_from_v2_meth,
+     METH_VARARGS | METH_CLASS, "build a new DirstateItem object from V2 data"},
     {"set_possibly_dirty", (PyCFunction)dirstate_item_set_possibly_dirty,
      METH_NOARGS, "mark a file as \"possibly dirty\""},
-    {"dm_nonnormal", (PyCFunction)dm_nonnormal, METH_NOARGS,
-     "True is the entry is non-normal in the dirstatemap sense"},
-    {"dm_otherparent", (PyCFunction)dm_otherparent, METH_NOARGS,
-     "True is the entry is `otherparent` in the dirstatemap sense"},
+    {"set_clean", (PyCFunction)dirstate_item_set_clean, METH_VARARGS,
+     "mark a file as \"clean\""},
+    {"set_tracked", (PyCFunction)dirstate_item_set_tracked, METH_NOARGS,
+     "mark a file as \"tracked\""},
+    {"set_untracked", (PyCFunction)dirstate_item_set_untracked, METH_NOARGS,
+     "mark a file as \"untracked\""},
+    {"drop_merge_data", (PyCFunction)dirstate_item_drop_merge_data, METH_NOARGS,
+     "remove all \"merge-only\" from a DirstateItem"},
     {NULL} /* Sentinel */
 };
 
 static PyObject *dirstate_item_get_mode(dirstateItemObject *self)
 {
-	return PyInt_FromLong(self->mode);
+	return PyInt_FromLong(dirstate_item_c_v1_mode(self));
 };
 
 static PyObject *dirstate_item_get_size(dirstateItemObject *self)
 {
-	return PyInt_FromLong(self->size);
+	return PyInt_FromLong(dirstate_item_c_v1_size(self));
 };
 
 static PyObject *dirstate_item_get_mtime(dirstateItemObject *self)
 {
-	return PyInt_FromLong(self->mtime);
+	return PyInt_FromLong(dirstate_item_c_v1_mtime(self));
 };
 
 static PyObject *dirstate_item_get_state(dirstateItemObject *self)
 {
-	return PyBytes_FromStringAndSize(&self->state, 1);
+	char state = dirstate_item_c_v1_state(self);
+	return PyBytes_FromStringAndSize(&state, 1);
+};
+
+static PyObject *dirstate_item_get_has_fallback_exec(dirstateItemObject *self)
+{
+	if (dirstate_item_c_has_fallback_exec(self)) {
+		Py_RETURN_TRUE;
+	} else {
+		Py_RETURN_FALSE;
+	}
+};
+
+static PyObject *dirstate_item_get_fallback_exec(dirstateItemObject *self)
+{
+	if (dirstate_item_c_has_fallback_exec(self)) {
+		if (self->flags & dirstate_flag_fallback_exec) {
+			Py_RETURN_TRUE;
+		} else {
+			Py_RETURN_FALSE;
+		}
+	} else {
+		Py_RETURN_NONE;
+	}
+};
+
+static int dirstate_item_set_fallback_exec(dirstateItemObject *self,
+                                           PyObject *value)
+{
+	if ((value == Py_None) || (value == NULL)) {
+		self->flags &= ~dirstate_flag_has_fallback_exec;
+	} else {
+		self->flags |= dirstate_flag_has_fallback_exec;
+		if (PyObject_IsTrue(value)) {
+			self->flags |= dirstate_flag_fallback_exec;
+		} else {
+			self->flags &= ~dirstate_flag_fallback_exec;
+		}
+	}
+	return 0;
+};
+
+static PyObject *
+dirstate_item_get_has_fallback_symlink(dirstateItemObject *self)
+{
+	if (dirstate_item_c_has_fallback_symlink(self)) {
+		Py_RETURN_TRUE;
+	} else {
+		Py_RETURN_FALSE;
+	}
+};
+
+static PyObject *dirstate_item_get_fallback_symlink(dirstateItemObject *self)
+{
+	if (dirstate_item_c_has_fallback_symlink(self)) {
+		if (self->flags & dirstate_flag_fallback_symlink) {
+			Py_RETURN_TRUE;
+		} else {
+			Py_RETURN_FALSE;
+		}
+	} else {
+		Py_RETURN_NONE;
+	}
+};
+
+static int dirstate_item_set_fallback_symlink(dirstateItemObject *self,
+                                              PyObject *value)
+{
+	if ((value == Py_None) || (value == NULL)) {
+		self->flags &= ~dirstate_flag_has_fallback_symlink;
+	} else {
+		self->flags |= dirstate_flag_has_fallback_symlink;
+		if (PyObject_IsTrue(value)) {
+			self->flags |= dirstate_flag_fallback_symlink;
+		} else {
+			self->flags &= ~dirstate_flag_fallback_symlink;
+		}
+	}
+	return 0;
 };
 
 static PyObject *dirstate_item_get_tracked(dirstateItemObject *self)
 {
-	if (self->state == 'a' || self->state == 'm' || self->state == 'n') {
+	if (dirstate_item_c_tracked(self)) {
+		Py_RETURN_TRUE;
+	} else {
+		Py_RETURN_FALSE;
+	}
+};
+static PyObject *dirstate_item_get_p1_tracked(dirstateItemObject *self)
+{
+	if (self->flags & dirstate_flag_p1_tracked) {
 		Py_RETURN_TRUE;
 	} else {
 		Py_RETURN_FALSE;
@@ -276,7 +652,17 @@
 
 static PyObject *dirstate_item_get_added(dirstateItemObject *self)
 {
-	if (self->state == 'a') {
+	if (dirstate_item_c_added(self)) {
+		Py_RETURN_TRUE;
+	} else {
+		Py_RETURN_FALSE;
+	}
+};
+
+static PyObject *dirstate_item_get_p2_info(dirstateItemObject *self)
+{
+	if (self->flags & dirstate_flag_wc_tracked &&
+	    self->flags & dirstate_flag_p2_info) {
 		Py_RETURN_TRUE;
 	} else {
 		Py_RETURN_FALSE;
@@ -285,16 +671,7 @@
 
 static PyObject *dirstate_item_get_merged(dirstateItemObject *self)
 {
-	if (self->state == 'm') {
-		Py_RETURN_TRUE;
-	} else {
-		Py_RETURN_FALSE;
-	}
-};
-
-static PyObject *dirstate_item_get_merged_removed(dirstateItemObject *self)
-{
-	if (self->state == 'r' && self->size == dirstate_v1_nonnormal) {
+	if (dirstate_item_c_merged(self)) {
 		Py_RETURN_TRUE;
 	} else {
 		Py_RETURN_FALSE;
@@ -303,16 +680,29 @@
 
 static PyObject *dirstate_item_get_from_p2(dirstateItemObject *self)
 {
-	if (self->state == 'n' && self->size == dirstate_v1_from_p2) {
+	if (dirstate_item_c_from_p2(self)) {
 		Py_RETURN_TRUE;
 	} else {
 		Py_RETURN_FALSE;
 	}
 };
 
-static PyObject *dirstate_item_get_from_p2_removed(dirstateItemObject *self)
+static PyObject *dirstate_item_get_maybe_clean(dirstateItemObject *self)
 {
-	if (self->state == 'r' && self->size == dirstate_v1_from_p2) {
+	if (!(self->flags & dirstate_flag_wc_tracked)) {
+		Py_RETURN_FALSE;
+	} else if (!(self->flags & dirstate_flag_p1_tracked)) {
+		Py_RETURN_FALSE;
+	} else if (self->flags & dirstate_flag_p2_info) {
+		Py_RETURN_FALSE;
+	} else {
+		Py_RETURN_TRUE;
+	}
+};
+
+static PyObject *dirstate_item_get_any_tracked(dirstateItemObject *self)
+{
+	if (dirstate_item_c_any_tracked(self)) {
 		Py_RETURN_TRUE;
 	} else {
 		Py_RETURN_FALSE;
@@ -321,7 +711,7 @@
 
 static PyObject *dirstate_item_get_removed(dirstateItemObject *self)
 {
-	if (self->state == 'r') {
+	if (dirstate_item_c_removed(self)) {
 		Py_RETURN_TRUE;
 	} else {
 		Py_RETURN_FALSE;
@@ -333,14 +723,25 @@
     {"size", (getter)dirstate_item_get_size, NULL, "size", NULL},
     {"mtime", (getter)dirstate_item_get_mtime, NULL, "mtime", NULL},
     {"state", (getter)dirstate_item_get_state, NULL, "state", NULL},
+    {"has_fallback_exec", (getter)dirstate_item_get_has_fallback_exec, NULL,
+     "has_fallback_exec", NULL},
+    {"fallback_exec", (getter)dirstate_item_get_fallback_exec,
+     (setter)dirstate_item_set_fallback_exec, "fallback_exec", NULL},
+    {"has_fallback_symlink", (getter)dirstate_item_get_has_fallback_symlink,
+     NULL, "has_fallback_symlink", NULL},
+    {"fallback_symlink", (getter)dirstate_item_get_fallback_symlink,
+     (setter)dirstate_item_set_fallback_symlink, "fallback_symlink", NULL},
     {"tracked", (getter)dirstate_item_get_tracked, NULL, "tracked", NULL},
+    {"p1_tracked", (getter)dirstate_item_get_p1_tracked, NULL, "p1_tracked",
+     NULL},
     {"added", (getter)dirstate_item_get_added, NULL, "added", NULL},
-    {"merged_removed", (getter)dirstate_item_get_merged_removed, NULL,
-     "merged_removed", NULL},
+    {"p2_info", (getter)dirstate_item_get_p2_info, NULL, "p2_info", NULL},
     {"merged", (getter)dirstate_item_get_merged, NULL, "merged", NULL},
-    {"from_p2_removed", (getter)dirstate_item_get_from_p2_removed, NULL,
-     "from_p2_removed", NULL},
     {"from_p2", (getter)dirstate_item_get_from_p2, NULL, "from_p2", NULL},
+    {"maybe_clean", (getter)dirstate_item_get_maybe_clean, NULL, "maybe_clean",
+     NULL},
+    {"any_tracked", (getter)dirstate_item_get_any_tracked, NULL, "any_tracked",
+     NULL},
     {"removed", (getter)dirstate_item_get_removed, NULL, "removed", NULL},
     {NULL} /* Sentinel */
 };
@@ -357,7 +758,7 @@
     0,                                 /* tp_compare */
     0,                                 /* tp_repr */
     0,                                 /* tp_as_number */
-    &dirstate_item_sq,                 /* tp_as_sequence */
+    0,                                 /* tp_as_sequence */
     0,                                 /* tp_as_mapping */
     0,                                 /* tp_hash  */
     0,                                 /* tp_call */
@@ -441,6 +842,8 @@
 
 		entry = (PyObject *)dirstate_item_from_v1_data(state, mode,
 		                                               size, mtime);
+		if (!entry)
+			goto quit;
 		cpos = memchr(cur, 0, flen);
 		if (cpos) {
 			fname = PyBytes_FromStringAndSize(cur, cpos - cur);
@@ -476,68 +879,6 @@
 }
 
 /*
- * Build a set of non-normal and other parent entries from the dirstate dmap
- */
-static PyObject *nonnormalotherparententries(PyObject *self, PyObject *args)
-{
-	PyObject *dmap, *fname, *v;
-	PyObject *nonnset = NULL, *otherpset = NULL, *result = NULL;
-	Py_ssize_t pos;
-
-	if (!PyArg_ParseTuple(args, "O!:nonnormalentries", &PyDict_Type,
-	                      &dmap)) {
-		goto bail;
-	}
-
-	nonnset = PySet_New(NULL);
-	if (nonnset == NULL) {
-		goto bail;
-	}
-
-	otherpset = PySet_New(NULL);
-	if (otherpset == NULL) {
-		goto bail;
-	}
-
-	pos = 0;
-	while (PyDict_Next(dmap, &pos, &fname, &v)) {
-		dirstateItemObject *t;
-		if (!dirstate_tuple_check(v)) {
-			PyErr_SetString(PyExc_TypeError,
-			                "expected a dirstate tuple");
-			goto bail;
-		}
-		t = (dirstateItemObject *)v;
-
-		if (t->state == 'n' && t->size == -2) {
-			if (PySet_Add(otherpset, fname) == -1) {
-				goto bail;
-			}
-		}
-
-		if (t->state == 'n' && t->mtime != -1) {
-			continue;
-		}
-		if (PySet_Add(nonnset, fname) == -1) {
-			goto bail;
-		}
-	}
-
-	result = Py_BuildValue("(OO)", nonnset, otherpset);
-	if (result == NULL) {
-		goto bail;
-	}
-	Py_DECREF(nonnset);
-	Py_DECREF(otherpset);
-	return result;
-bail:
-	Py_XDECREF(nonnset);
-	Py_XDECREF(otherpset);
-	Py_XDECREF(result);
-	return NULL;
-}
-
-/*
  * Efficiently pack a dirstate object into its on-disk format.
  */
 static PyObject *pack_dirstate(PyObject *self, PyObject *args)
@@ -547,11 +888,12 @@
 	Py_ssize_t nbytes, pos, l;
 	PyObject *k, *v = NULL, *pn;
 	char *p, *s;
-	int now;
+	int now_s;
+	int now_ns;
 
-	if (!PyArg_ParseTuple(args, "O!O!O!i:pack_dirstate", &PyDict_Type, &map,
-	                      &PyDict_Type, &copymap, &PyTuple_Type, &pl,
-	                      &now)) {
+	if (!PyArg_ParseTuple(args, "O!O!O!(ii):pack_dirstate", &PyDict_Type,
+	                      &map, &PyDict_Type, &copymap, &PyTuple_Type, &pl,
+	                      &now_s, &now_ns)) {
 		return NULL;
 	}
 
@@ -616,15 +958,15 @@
 		}
 		tuple = (dirstateItemObject *)v;
 
-		state = tuple->state;
-		mode = tuple->mode;
-		size = tuple->size;
-		mtime = tuple->mtime;
-		if (state == 'n' && mtime == now) {
+		state = dirstate_item_c_v1_state(tuple);
+		mode = dirstate_item_c_v1_mode(tuple);
+		size = dirstate_item_c_v1_size(tuple);
+		mtime = dirstate_item_c_v1_mtime(tuple);
+		if (state == 'n' && tuple->mtime_s == now_s) {
 			/* See pure/parsers.py:pack_dirstate for why we do
 			 * this. */
 			mtime = -1;
-			mtime_unset = (PyObject *)make_dirstate_item(
+			mtime_unset = (PyObject *)dirstate_item_from_v1_data(
 			    state, mode, size, mtime);
 			if (!mtime_unset) {
 				goto bail;
@@ -869,9 +1211,6 @@
 
 static PyMethodDef methods[] = {
     {"pack_dirstate", pack_dirstate, METH_VARARGS, "pack a dirstate\n"},
-    {"nonnormalotherparententries", nonnormalotherparententries, METH_VARARGS,
-     "create a set containing non-normal and other parent entries of given "
-     "dirstate\n"},
     {"parse_dirstate", parse_dirstate, METH_VARARGS, "parse a dirstate\n"},
     {"parse_index2", (PyCFunction)parse_index2, METH_VARARGS | METH_KEYWORDS,
      "parse a revlog index\n"},
@@ -899,7 +1238,6 @@
 
 static void module_init(PyObject *mod)
 {
-	PyObject *capsule = NULL;
 	PyModule_AddIntConstant(mod, "version", version);
 
 	/* This module constant has two purposes.  First, it lets us unit test
@@ -916,12 +1254,6 @@
 	manifest_module_init(mod);
 	revlog_module_init(mod);
 
-	capsule = PyCapsule_New(
-	    make_dirstate_item,
-	    "mercurial.cext.parsers.make_dirstate_item_CAPI", NULL);
-	if (capsule != NULL)
-		PyModule_AddObject(mod, "make_dirstate_item_CAPI", capsule);
-
 	if (PyType_Ready(&dirstateItemType) < 0) {
 		return;
 	}
--- a/mercurial/cext/util.h	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/cext/util.h	Thu Oct 21 17:25:41 2021 +0200
@@ -24,13 +24,31 @@
 /* clang-format off */
 typedef struct {
 	PyObject_HEAD
-	char state;
+	int flags;
 	int mode;
 	int size;
-	int mtime;
+	int mtime_s;
+	int mtime_ns;
 } dirstateItemObject;
 /* clang-format on */
 
+static const int dirstate_flag_wc_tracked = 1 << 0;
+static const int dirstate_flag_p1_tracked = 1 << 1;
+static const int dirstate_flag_p2_info = 1 << 2;
+static const int dirstate_flag_mode_exec_perm = 1 << 3;
+static const int dirstate_flag_mode_is_symlink = 1 << 4;
+static const int dirstate_flag_has_fallback_exec = 1 << 5;
+static const int dirstate_flag_fallback_exec = 1 << 6;
+static const int dirstate_flag_has_fallback_symlink = 1 << 7;
+static const int dirstate_flag_fallback_symlink = 1 << 8;
+static const int dirstate_flag_expected_state_is_modified = 1 << 9;
+static const int dirstate_flag_has_meaningful_data = 1 << 10;
+static const int dirstate_flag_has_mtime = 1 << 11;
+static const int dirstate_flag_mtime_second_ambiguous = 1 << 12;
+static const int dirstate_flag_directory = 1 << 13;
+static const int dirstate_flag_all_unknown_recorded = 1 << 14;
+static const int dirstate_flag_all_ignored_recorded = 1 << 15;
+
 extern PyTypeObject dirstateItemType;
 #define dirstate_tuple_check(op) (Py_TYPE(op) == &dirstateItemType)
 
--- a/mercurial/cmdutil.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/cmdutil.py	Thu Oct 21 17:25:41 2021 +0200
@@ -626,7 +626,7 @@
                 for realname, tmpname in pycompat.iteritems(backups):
                     ui.debug(b'restoring %r to %r\n' % (tmpname, realname))
 
-                    if dirstate[realname] == b'n':
+                    if dirstate.get_entry(realname).maybe_clean:
                         # without normallookup, restoring timestamp
                         # may cause partially committed files
                         # to be treated as unmodified
@@ -987,7 +987,7 @@
     with repo.wlock(), repo.lock(), repo.transaction(b'branches'):
         # abort in case of uncommitted merge or dirty wdir
         bailifchanged(repo)
-        revs = scmutil.revrange(repo, revs)
+        revs = logcmdutil.revrange(repo, revs)
         if not revs:
             raise error.InputError(b"empty revision set")
         roots = repo.revs(b'roots(%ld)', revs)
@@ -1480,7 +1480,7 @@
             # TODO: Remove this restriction and make it also create the copy
             #       targets (and remove the rename source if rename==True).
             raise error.InputError(_(b'--at-rev requires --after'))
-        ctx = scmutil.revsingle(repo, rev)
+        ctx = logcmdutil.revsingle(repo, rev)
         if len(ctx.parents()) > 1:
             raise error.InputError(
                 _(b'cannot mark/unmark copy in merge commit')
@@ -1642,7 +1642,9 @@
         reltarget = repo.pathto(abstarget, cwd)
         target = repo.wjoin(abstarget)
         src = repo.wjoin(abssrc)
-        state = repo.dirstate[abstarget]
+        entry = repo.dirstate.get_entry(abstarget)
+
+        already_commited = entry.tracked and not entry.added
 
         scmutil.checkportable(ui, abstarget)
 
@@ -1672,30 +1674,48 @@
                 exists = False
                 samefile = True
 
-        if not after and exists or after and state in b'mn':
+        if not after and exists or after and already_commited:
             if not opts[b'force']:
-                if state in b'mn':
+                if already_commited:
                     msg = _(b'%s: not overwriting - file already committed\n')
-                    if after:
-                        flags = b'--after --force'
+                    # Check if if the target was added in the parent and the
+                    # source already existed in the grandparent.
+                    looks_like_copy_in_pctx = abstarget in pctx and any(
+                        abssrc in gpctx and abstarget not in gpctx
+                        for gpctx in pctx.parents()
+                    )
+                    if looks_like_copy_in_pctx:
+                        if rename:
+                            hint = _(
+                                b"('hg rename --at-rev .' to record the rename "
+                                b"in the parent of the working copy)\n"
+                            )
+                        else:
+                            hint = _(
+                                b"('hg copy --at-rev .' to record the copy in "
+                                b"the parent of the working copy)\n"
+                            )
                     else:
-                        flags = b'--force'
-                    if rename:
-                        hint = (
-                            _(
-                                b"('hg rename %s' to replace the file by "
-                                b'recording a rename)\n'
+                        if after:
+                            flags = b'--after --force'
+                        else:
+                            flags = b'--force'
+                        if rename:
+                            hint = (
+                                _(
+                                    b"('hg rename %s' to replace the file by "
+                                    b'recording a rename)\n'
+                                )
+                                % flags
                             )
-                            % flags
-                        )
-                    else:
-                        hint = (
-                            _(
-                                b"('hg copy %s' to replace the file by "
-                                b'recording a copy)\n'
+                        else:
+                            hint = (
+                                _(
+                                    b"('hg copy %s' to replace the file by "
+                                    b'recording a copy)\n'
+                                )
+                                % flags
                             )
-                            % flags
-                        )
                 else:
                     msg = _(b'%s: not overwriting - file exists\n')
                     if rename:
@@ -3350,7 +3370,11 @@
         for f in localchanges:
             src = repo.dirstate.copied(f)
             # XXX should we check for rename down to target node?
-            if src and src not in names and repo.dirstate[src] == b'r':
+            if (
+                src
+                and src not in names
+                and repo.dirstate.get_entry(src).removed
+            ):
                 dsremoved.add(src)
                 names[src] = True
 
@@ -3364,12 +3388,12 @@
         # distinguish between file to forget and the other
         added = set()
         for abs in dsadded:
-            if repo.dirstate[abs] != b'a':
+            if not repo.dirstate.get_entry(abs).added:
                 added.add(abs)
         dsadded -= added
 
         for abs in deladded:
-            if repo.dirstate[abs] == b'a':
+            if repo.dirstate.get_entry(abs).added:
                 dsadded.add(abs)
         deladded -= dsadded
 
--- a/mercurial/commands.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/commands.py	Thu Oct 21 17:25:41 2021 +0200
@@ -445,7 +445,7 @@
     rev = opts.get(b'rev')
     if rev:
         repo = scmutil.unhidehashlikerevs(repo, [rev], b'nowarn')
-    ctx = scmutil.revsingle(repo, rev)
+    ctx = logcmdutil.revsingle(repo, rev)
 
     ui.pager(b'annotate')
     rootfm = ui.formatter(b'annotate', opts)
@@ -526,7 +526,7 @@
     )
 
     def bad(x, y):
-        raise error.Abort(b"%s: %s" % (x, y))
+        raise error.InputError(b"%s: %s" % (x, y))
 
     m = scmutil.match(ctx, pats, opts, badfn=bad)
 
@@ -536,7 +536,7 @@
     )
     skiprevs = opts.get(b'skip')
     if skiprevs:
-        skiprevs = scmutil.revrange(repo, skiprevs)
+        skiprevs = logcmdutil.revrange(repo, skiprevs)
 
     uipathfn = scmutil.getuipathfn(repo, legacyrelativevalue=True)
     for abs in ctx.walk(m):
@@ -649,7 +649,7 @@
     rev = opts.get(b'rev')
     if rev:
         repo = scmutil.unhidehashlikerevs(repo, [rev], b'nowarn')
-    ctx = scmutil.revsingle(repo, rev)
+    ctx = logcmdutil.revsingle(repo, rev)
     if not ctx:
         raise error.InputError(
             _(b'no working directory: please specify a revision')
@@ -791,7 +791,7 @@
 
     cmdutil.checkunfinished(repo)
     cmdutil.bailifchanged(repo)
-    ctx = scmutil.revsingle(repo, rev)
+    ctx = logcmdutil.revsingle(repo, rev)
     node = ctx.node()
 
     op1, op2 = repo.dirstate.parents()
@@ -1037,7 +1037,7 @@
     state = hbisect.load_state(repo)
 
     if rev:
-        nodes = [repo[i].node() for i in scmutil.revrange(repo, rev)]
+        nodes = [repo[i].node() for i in logcmdutil.revrange(repo, rev)]
     else:
         nodes = [repo.lookup(b'.')]
 
@@ -1081,7 +1081,7 @@
                 raise error.StateError(_(b'current bisect revision is a merge'))
         if rev:
             if not nodes:
-                raise error.Abort(_(b'empty revision set'))
+                raise error.InputError(_(b'empty revision set'))
             node = repo[nodes[-1]].node()
         with hbisect.restore_state(repo, state, node):
             while changesets:
@@ -1424,7 +1424,7 @@
     revs = opts.get(b'rev')
     selectedbranches = None
     if revs:
-        revs = scmutil.revrange(repo, revs)
+        revs = logcmdutil.revrange(repo, revs)
         getbi = repo.revbranchcache().branchinfo
         selectedbranches = {getbi(r)[0] for r in revs}
 
@@ -1558,7 +1558,7 @@
     revs = None
     if b'rev' in opts:
         revstrings = opts[b'rev']
-        revs = scmutil.revrange(repo, revstrings)
+        revs = logcmdutil.revrange(repo, revstrings)
         if revstrings and not revs:
             raise error.InputError(_(b'no commits to bundle'))
 
@@ -1590,7 +1590,7 @@
             ui.warn(_(b"ignoring --base because --all was specified\n"))
         base = [nullrev]
     else:
-        base = scmutil.revrange(repo, opts.get(b'base'))
+        base = logcmdutil.revrange(repo, opts.get(b'base'))
     if cgversion not in changegroup.supportedoutgoingversions(repo):
         raise error.Abort(
             _(b"repository does not support bundle version %s") % cgversion
@@ -1761,7 +1761,7 @@
     rev = opts.get(b'rev')
     if rev:
         repo = scmutil.unhidehashlikerevs(repo, [rev], b'nowarn')
-    ctx = scmutil.revsingle(repo, rev)
+    ctx = logcmdutil.revsingle(repo, rev)
     m = scmutil.match(ctx, (file1,) + pats, opts)
     fntemplate = opts.pop(b'output', b'')
     if cmdutil.isstdiofilename(fntemplate):
@@ -2600,17 +2600,17 @@
     cmdutil.check_incompatible_arguments(opts, b'to', [b'rev', b'change'])
     if change:
         repo = scmutil.unhidehashlikerevs(repo, [change], b'nowarn')
-        ctx2 = scmutil.revsingle(repo, change, None)
+        ctx2 = logcmdutil.revsingle(repo, change, None)
         ctx1 = logcmdutil.diff_parent(ctx2)
     elif from_rev or to_rev:
         repo = scmutil.unhidehashlikerevs(
             repo, [from_rev] + [to_rev], b'nowarn'
         )
-        ctx1 = scmutil.revsingle(repo, from_rev, None)
-        ctx2 = scmutil.revsingle(repo, to_rev, None)
+        ctx1 = logcmdutil.revsingle(repo, from_rev, None)
+        ctx2 = logcmdutil.revsingle(repo, to_rev, None)
     else:
         repo = scmutil.unhidehashlikerevs(repo, revs, b'nowarn')
-        ctx1, ctx2 = scmutil.revpair(repo, revs)
+        ctx1, ctx2 = logcmdutil.revpair(repo, revs)
 
     if reverse:
         ctxleft = ctx2
@@ -2753,7 +2753,7 @@
             changesets = [b'.']
 
         repo = scmutil.unhidehashlikerevs(repo, changesets, b'nowarn')
-        revs = scmutil.revrange(repo, changesets)
+        revs = logcmdutil.revrange(repo, changesets)
 
     if not revs:
         raise error.InputError(_(b"export requires at least one changeset"))
@@ -2864,7 +2864,7 @@
     rev = opts.get(b'rev')
     if rev:
         repo = scmutil.unhidehashlikerevs(repo, [rev], b'nowarn')
-    ctx = scmutil.revsingle(repo, rev, None)
+    ctx = logcmdutil.revsingle(repo, rev, None)
 
     end = b'\n'
     if opts.get(b'print0'):
@@ -3170,12 +3170,12 @@
             raise error.InputError(_(b'no revisions specified'))
         cmdutil.checkunfinished(repo)
         cmdutil.bailifchanged(repo)
-        revs = scmutil.revrange(repo, revs)
+        revs = logcmdutil.revrange(repo, revs)
 
     skipped = set()
     basectx = None
     if opts.get('base'):
-        basectx = scmutil.revsingle(repo, opts['base'], None)
+        basectx = logcmdutil.revsingle(repo, opts['base'], None)
     if basectx is None:
         # check for merges
         for rev in repo.revs(b'%ld and merge()', revs):
@@ -3696,7 +3696,7 @@
     rev = opts.get(b'rev')
     if rev:
         repo = scmutil.unhidehashlikerevs(repo, [rev], b'nowarn')
-        start = scmutil.revsingle(repo, rev, None).node()
+        start = logcmdutil.revsingle(repo, rev, None).node()
 
     if opts.get(b'topo'):
         heads = [repo[h] for h in repo.heads(start)]
@@ -3708,7 +3708,7 @@
 
     if branchrevs:
         branches = {
-            repo[r].branch() for r in scmutil.revrange(repo, branchrevs)
+            repo[r].branch() for r in logcmdutil.revrange(repo, branchrevs)
         }
         heads = [h for h in heads if h.branch() in branches]
 
@@ -3932,7 +3932,7 @@
         else:
             if rev:
                 repo = scmutil.unhidehashlikerevs(repo, [rev], b'nowarn')
-            ctx = scmutil.revsingle(repo, rev, None)
+            ctx = logcmdutil.revsingle(repo, rev, None)
 
             if ctx.rev() is None:
                 ctx = repo[None]
@@ -4346,8 +4346,11 @@
     cmdutil.check_incompatible_arguments(opts, b'subrepos', [b'bundle'])
 
     if opts.get(b'bookmarks'):
-        srcs = urlutil.get_pull_paths(repo, ui, [source], opts.get(b'branch'))
-        for source, branches in srcs:
+        srcs = urlutil.get_pull_paths(repo, ui, [source])
+        for path in srcs:
+            source, branches = urlutil.parseurl(
+                path.rawloc, opts.get(b'branch')
+            )
             other = hg.peer(repo, opts, source)
             try:
                 if b'bookmarks' not in other.listkeys(b'namespaces'):
@@ -4357,7 +4360,9 @@
                 ui.status(
                     _(b'comparing with %s\n') % urlutil.hidepassword(source)
                 )
-                return bookmarks.incoming(ui, repo, other)
+                return bookmarks.incoming(
+                    ui, repo, other, mode=path.bookmarks_mode
+                )
             finally:
                 other.close()
 
@@ -4445,7 +4450,7 @@
         end = b'\0'
     else:
         end = b'\n'
-    ctx = scmutil.revsingle(repo, opts.get(b'rev'), None)
+    ctx = logcmdutil.revsingle(repo, opts.get(b'rev'), None)
 
     ret = 1
     m = scmutil.match(
@@ -4790,7 +4795,7 @@
     mode = {b'l': b'644', b'x': b'755', b'': b'644', b't': b'755'}
     if node:
         repo = scmutil.unhidehashlikerevs(repo, [node], b'nowarn')
-    ctx = scmutil.revsingle(repo, node)
+    ctx = logcmdutil.revsingle(repo, node)
     mf = ctx.manifest()
     ui.pager(b'manifest')
     for f in ctx:
@@ -4877,7 +4882,7 @@
         node = opts.get(b'rev')
 
     if node:
-        ctx = scmutil.revsingle(repo, node)
+        ctx = logcmdutil.revsingle(repo, node)
     else:
         if ui.configbool(b'commands', b'merge.require-rev'):
             raise error.InputError(
@@ -5056,7 +5061,7 @@
     rev = opts.get(b'rev')
     if rev:
         repo = scmutil.unhidehashlikerevs(repo, [rev], b'nowarn')
-    ctx = scmutil.revsingle(repo, rev, None)
+    ctx = logcmdutil.revsingle(repo, rev, None)
 
     if file_:
         m = scmutil.match(ctx, (file_,), opts)
@@ -5219,13 +5224,13 @@
     # look for specified revision
     revs = list(revs)
     revs.extend(opts[b'rev'])
-    if not revs:
+    if revs:
+        revs = logcmdutil.revrange(repo, revs)
+    else:
         # display both parents as the second parent phase can influence
         # the phase of a merge commit
         revs = [c.rev() for c in repo[None].parents()]
 
-    revs = scmutil.revrange(repo, revs)
-
     ret = 0
     if targetphase is None:
         # display
@@ -5393,8 +5398,8 @@
         hint = _(b'use hg pull followed by hg update DEST')
         raise error.InputError(msg, hint=hint)
 
-    sources = urlutil.get_pull_paths(repo, ui, sources, opts.get(b'branch'))
-    for source, branches in sources:
+    for path in urlutil.get_pull_paths(repo, ui, sources):
+        source, branches = urlutil.parseurl(path.rawloc, opts.get(b'branch'))
         ui.status(_(b'pulling from %s\n') % urlutil.hidepassword(source))
         ui.flush()
         other = hg.peer(repo, opts, source)
@@ -5451,6 +5456,7 @@
                 modheads = exchange.pull(
                     repo,
                     other,
+                    path=path,
                     heads=nodes,
                     force=opts.get(b'force'),
                     bookmarks=opts.get(b'bookmark', ()),
@@ -5735,7 +5741,7 @@
 
         try:
             if revs:
-                revs = [repo[r].node() for r in scmutil.revrange(repo, revs)]
+                revs = [repo[r].node() for r in logcmdutil.revrange(repo, revs)]
                 if not revs:
                     raise error.InputError(
                         _(b"specified revisions evaluate to an empty set"),
@@ -6347,7 +6353,7 @@
     rev = opts.get(b'rev')
     if rev:
         repo = scmutil.unhidehashlikerevs(repo, [rev], b'nowarn')
-    ctx = scmutil.revsingle(repo, rev)
+    ctx = logcmdutil.revsingle(repo, rev)
 
     if not (
         pats
@@ -6905,11 +6911,11 @@
         raise error.InputError(msg)
     elif change:
         repo = scmutil.unhidehashlikerevs(repo, [change], b'nowarn')
-        ctx2 = scmutil.revsingle(repo, change, None)
+        ctx2 = logcmdutil.revsingle(repo, change, None)
         ctx1 = ctx2.p1()
     else:
         repo = scmutil.unhidehashlikerevs(repo, revs, b'nowarn')
-        ctx1, ctx2 = scmutil.revpair(repo, revs)
+        ctx1, ctx2 = logcmdutil.revpair(repo, revs)
 
     forcerelativevalue = None
     if ui.hasconfig(b'commands', b'status.relative'):
@@ -7453,7 +7459,7 @@
                         b'(use -f to force)'
                     )
                 )
-        node = scmutil.revsingle(repo, rev_).node()
+        node = logcmdutil.revsingle(repo, rev_).node()
 
         if not message:
             # we don't translate commit messages
@@ -7477,7 +7483,7 @@
         # don't allow tagging the null rev
         if (
             not opts.get(b'remove')
-            and scmutil.revsingle(repo, rev_).rev() == nullrev
+            and logcmdutil.revsingle(repo, rev_).rev() == nullrev
         ):
             raise error.InputError(_(b"cannot tag null revision"))
 
@@ -7840,7 +7846,7 @@
         brev = rev
         if rev:
             repo = scmutil.unhidehashlikerevs(repo, [rev], b'nowarn')
-        ctx = scmutil.revsingle(repo, rev, default=None)
+        ctx = logcmdutil.revsingle(repo, rev, default=None)
         rev = ctx.rev()
         hidden = ctx.hidden()
         overrides = {(b'ui', b'forcemerge'): opts.get('tool', b'')}
--- a/mercurial/compat.h	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/compat.h	Thu Oct 21 17:25:41 2021 +0200
@@ -1,5 +1,5 @@
-#ifndef _HG_COMPAT_H_
-#define _HG_COMPAT_H_
+#ifndef HG_COMPAT_H
+#define HG_COMPAT_H
 
 #ifdef _WIN32
 #ifdef _MSC_VER
--- a/mercurial/configitems.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/configitems.py	Thu Oct 21 17:25:41 2021 +0200
@@ -959,11 +959,6 @@
 )
 coreconfigitem(
     b'experimental',
-    b'dirstate-tree.in-memory',
-    default=False,
-)
-coreconfigitem(
-    b'experimental',
     b'editortmpinhg',
     default=False,
 )
@@ -1266,6 +1261,11 @@
 )
 coreconfigitem(
     b'experimental',
+    b'web.full-garbage-collection-rate',
+    default=1,  # still forcing a full collection on each request
+)
+coreconfigitem(
+    b'experimental',
     b'worker.wdir-get-thread-safe',
     default=False,
 )
@@ -1880,6 +1880,13 @@
     default=b'skip',
     experimental=True,
 )
+# experimental as long as format.exp-dirstate-v2 is.
+coreconfigitem(
+    b'storage',
+    b'dirstate-v2.slow-path',
+    default=b"abort",
+    experimental=True,
+)
 coreconfigitem(
     b'storage',
     b'new-repo-backend',
--- a/mercurial/context.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/context.py	Thu Oct 21 17:25:41 2021 +0200
@@ -1551,11 +1551,11 @@
     def __iter__(self):
         d = self._repo.dirstate
         for f in d:
-            if d[f] != b'r':
+            if d.get_entry(f).tracked:
                 yield f
 
     def __contains__(self, key):
-        return self._repo.dirstate[key] not in b"?r"
+        return self._repo.dirstate.get_entry(key).tracked
 
     def hex(self):
         return self._repo.nodeconstants.wdirhex
@@ -2017,7 +2017,7 @@
     def matches(self, match):
         match = self._repo.narrowmatch(match)
         ds = self._repo.dirstate
-        return sorted(f for f in ds.matches(match) if ds[f] != b'r')
+        return sorted(f for f in ds.matches(match) if ds.get_entry(f).tracked)
 
     def markcommitted(self, node):
         with self._repo.dirstate.parentchange():
--- a/mercurial/copies.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/copies.py	Thu Oct 21 17:25:41 2021 +0200
@@ -94,7 +94,7 @@
     ds = repo.dirstate
     c = ds.copies().copy()
     for k in list(c):
-        if ds[k] not in b'anm' or (match and not match(k)):
+        if not ds.get_entry(k).tracked or (match and not match(k)):
             del c[k]
     return c
 
--- a/mercurial/debugcommands.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/debugcommands.py	Thu Oct 21 17:25:41 2021 +0200
@@ -506,7 +506,7 @@
 )
 def debugchangedfiles(ui, repo, rev, **opts):
     """list the stored files changes for a revision"""
-    ctx = scmutil.revsingle(repo, rev, None)
+    ctx = logcmdutil.revsingle(repo, rev, None)
     files = None
 
     if opts['compute']:
@@ -550,24 +550,9 @@
     m1 = repo[parent1].manifest()
     m2 = repo[parent2].manifest()
     errors = 0
-    for f in repo.dirstate:
-        state = repo.dirstate[f]
-        if state in b"nr" and f not in m1:
-            ui.warn(_(b"%s in state %s, but not in manifest1\n") % (f, state))
-            errors += 1
-        if state in b"a" and f in m1:
-            ui.warn(_(b"%s in state %s, but also in manifest1\n") % (f, state))
-            errors += 1
-        if state in b"m" and f not in m1 and f not in m2:
-            ui.warn(
-                _(b"%s in state %s, but not in either manifest\n") % (f, state)
-            )
-            errors += 1
-    for f in m1:
-        state = repo.dirstate[f]
-        if state not in b"nrm":
-            ui.warn(_(b"%s in manifest1, but listed as state %s") % (f, state))
-            errors += 1
+    for err in repo.dirstate.verify(m1, m2):
+        ui.warn(err[0] % err[1:])
+        errors += 1
     if errors:
         errstr = _(b".hg/dirstate inconsistent with current parent's manifest")
         raise error.Abort(errstr)
@@ -962,35 +947,29 @@
     datesort = opts.get('datesort')
 
     if datesort:
-        keyfunc = lambda x: (
-            x[1].v1_mtime(),
-            x[0],
-        )  # sort by mtime, then by filename
+
+        def keyfunc(entry):
+            filename, _state, _mode, _size, mtime = entry
+            return (mtime, filename)
+
     else:
         keyfunc = None  # sort by filename
-    if opts['all']:
-        entries = list(repo.dirstate._map.debug_iter())
-    else:
-        entries = list(pycompat.iteritems(repo.dirstate))
+    entries = list(repo.dirstate._map.debug_iter(all=opts['all']))
     entries.sort(key=keyfunc)
-    for file_, ent in entries:
-        if ent.v1_mtime() == -1:
+    for entry in entries:
+        filename, state, mode, size, mtime = entry
+        if mtime == -1:
             timestr = b'unset               '
         elif nodates:
             timestr = b'set                 '
         else:
-            timestr = time.strftime(
-                "%Y-%m-%d %H:%M:%S ", time.localtime(ent.v1_mtime())
-            )
+            timestr = time.strftime("%Y-%m-%d %H:%M:%S ", time.localtime(mtime))
             timestr = encoding.strtolocal(timestr)
-        if ent.mode & 0o20000:
+        if mode & 0o20000:
             mode = b'lnk'
         else:
-            mode = b'%3o' % (ent.v1_mode() & 0o777 & ~util.umask)
-        ui.write(
-            b"%c %s %10d %s%s\n"
-            % (ent.v1_state(), mode, ent.v1_size(), timestr, file_)
-        )
+            mode = b'%3o' % (mode & 0o777 & ~util.umask)
+        ui.write(b"%c %s %10d %s%s\n" % (state, mode, size, timestr, filename))
     for f in repo.dirstate.copies():
         ui.write(_(b"copy: %s -> %s\n") % (repo.dirstate.copied(f), f))
 
@@ -1103,7 +1082,7 @@
         ui.status(_(b'comparing with %s\n') % urlutil.hidepassword(remoteurl))
     else:
         branches = (None, [])
-        remote_filtered_revs = scmutil.revrange(
+        remote_filtered_revs = logcmdutil.revrange(
             unfi, [b"not (::(%s))" % remote_revs]
         )
         remote_filtered_revs = frozenset(remote_filtered_revs)
@@ -1117,7 +1096,7 @@
         remote._repo = remote._repo.filtered(b'debug-discovery-remote-filter')
 
     if local_revs:
-        local_filtered_revs = scmutil.revrange(
+        local_filtered_revs = logcmdutil.revrange(
             unfi, [b"not (::(%s))" % local_revs]
         )
         local_filtered_revs = frozenset(local_filtered_revs)
@@ -1155,7 +1134,7 @@
         def doit(pushedrevs, remoteheads, remote=remote):
             nodes = None
             if pushedrevs:
-                revs = scmutil.revrange(repo, pushedrevs)
+                revs = logcmdutil.revrange(repo, pushedrevs)
                 nodes = [repo[r].node() for r in revs]
             common, any, hds = setdiscovery.findcommonheads(
                 ui, repo, remote, ancestorsof=nodes, audit=data
@@ -1394,7 +1373,7 @@
 
     fileset.symbols  # force import of fileset so we have predicates to optimize
     opts = pycompat.byteskwargs(opts)
-    ctx = scmutil.revsingle(repo, opts.get(b'rev'), None)
+    ctx = logcmdutil.revsingle(repo, opts.get(b'rev'), None)
 
     stages = [
         (b'parsed', pycompat.identity),
@@ -1495,8 +1474,8 @@
     filename.
 
     Note that this does *not* mean that this repairs future affected revisions,
-    that needs a separate fix at the exchange level that hasn't been written yet
-    (as of 5.9rc0).
+    that needs a separate fix at the exchange level that was introduced in
+    Mercurial 5.9.1.
 
     There is a `--paranoid` flag to test that the fast implementation is correct
     by checking it against the slow implementation. Since this matter is quite
@@ -2614,7 +2593,7 @@
             l.release()
     else:
         if opts[b'rev']:
-            revs = scmutil.revrange(repo, opts[b'rev'])
+            revs = logcmdutil.revrange(repo, opts[b'rev'])
             nodes = [repo[r].node() for r in revs]
             markers = list(
                 obsutil.getmarkers(
@@ -2981,16 +2960,28 @@
             dirstatefiles = set(dirstate)
             manifestonly = manifestfiles - dirstatefiles
             dsonly = dirstatefiles - manifestfiles
-            dsnotadded = {f for f in dsonly if dirstate[f] != b'a'}
+            dsnotadded = {f for f in dsonly if not dirstate.get_entry(f).added}
             changedfiles = manifestonly | dsnotadded
 
         dirstate.rebuild(ctx.node(), ctx.manifest(), changedfiles)
 
 
-@command(b'debugrebuildfncache', [], b'')
-def debugrebuildfncache(ui, repo):
+@command(
+    b'debugrebuildfncache',
+    [
+        (
+            b'',
+            b'only-data',
+            False,
+            _(b'only look for wrong .d files (much faster)'),
+        )
+    ],
+    b'',
+)
+def debugrebuildfncache(ui, repo, **opts):
     """rebuild the fncache file"""
-    repair.rebuildfncache(ui, repo)
+    opts = pycompat.byteskwargs(opts)
+    repair.rebuildfncache(ui, repo, opts.get(b"only_data"))
 
 
 @command(
@@ -4018,7 +4009,7 @@
     cache = {}
     ctx2str = bytes
     node2str = short
-    for rev in scmutil.revrange(repo, revs):
+    for rev in logcmdutil.revrange(repo, revs):
         ctx = repo[rev]
         ui.write(b'%s\n' % ctx2str(ctx))
         for succsset in obsutil.successorssets(
@@ -4077,7 +4068,7 @@
             raise error.RepoError(
                 _(b'there is no Mercurial repository here (.hg not found)')
             )
-        revs = scmutil.revrange(repo, opts['rev'])
+        revs = logcmdutil.revrange(repo, opts['rev'])
 
     props = {}
     for d in opts['define']:
--- a/mercurial/dirstate.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/dirstate.py	Thu Oct 21 17:25:41 2021 +0200
@@ -31,6 +31,10 @@
     util,
 )
 
+from .dirstateutils import (
+    timestamp,
+)
+
 from .interfaces import (
     dirstate as intdirstate,
     util as interfaceutil,
@@ -39,13 +43,13 @@
 parsers = policy.importmod('parsers')
 rustmod = policy.importrust('dirstate')
 
-SUPPORTS_DIRSTATE_V2 = rustmod is not None
+HAS_FAST_DIRSTATE_V2 = rustmod is not None
 
 propertycache = util.propertycache
 filecache = scmutil.filecache
 _rangemask = dirstatemap.rangemask
 
-DirstateItem = parsers.DirstateItem
+DirstateItem = dirstatemap.DirstateItem
 
 
 class repocache(filecache):
@@ -66,7 +70,7 @@
     '''Get "now" timestamp on filesystem'''
     tmpfd, tmpname = vfs.mkstemp()
     try:
-        return os.fstat(tmpfd)[stat.ST_MTIME]
+        return timestamp.mtime_of(os.fstat(tmpfd))
     finally:
         os.close(tmpfd)
         vfs.unlink(tmpname)
@@ -122,7 +126,7 @@
         # UNC path pointing to root share (issue4557)
         self._rootdir = pathutil.normasprefix(root)
         self._dirty = False
-        self._lastnormaltime = 0
+        self._lastnormaltime = timestamp.zero()
         self._ui = ui
         self._filecache = {}
         self._parentwriters = 0
@@ -130,7 +134,6 @@
         self._pendingfilename = b'%s.pending' % self._filename
         self._plchangecallbacks = {}
         self._origpl = None
-        self._updatedfiles = set()
         self._mapcls = dirstatemap.dirstatemap
         # Access and cache cwd early, so we don't access it for the first time
         # after a working-copy update caused it to not exist (accessing it then
@@ -260,7 +263,11 @@
             def f(x):
                 if os.path.islink(self._join(x)):
                     return b'l'
-                if b'x' in fallback(x):
+                entry = self.get_entry(x)
+                if entry.has_fallback_exec:
+                    if entry.fallback_exec:
+                        return b'x'
+                elif b'x' in fallback(x):
                     return b'x'
                 return b''
 
@@ -270,13 +277,28 @@
             def f(x):
                 if b'l' in fallback(x):
                     return b'l'
+                entry = self.get_entry(x)
+                if entry.has_fallback_symlink:
+                    if entry.fallback_symlink:
+                        return b'l'
                 if util.isexec(self._join(x)):
                     return b'x'
                 return b''
 
             return f
         else:
-            return fallback
+
+            def f(x):
+                entry = self.get_entry(x)
+                if entry.has_fallback_symlink:
+                    if entry.fallback_symlink:
+                        return b'l'
+                if entry.has_fallback_exec:
+                    if entry.fallback_exec:
+                        return b'x'
+                    elif entry.has_fallback_symlink:
+                        return b''
+                return fallback(x)
 
     @propertycache
     def _cwd(self):
@@ -328,11 +350,20 @@
         consider migrating all user of this to going through the dirstate entry
         instead.
         """
+        msg = b"don't use dirstate[file], use dirstate.get_entry(file)"
+        util.nouideprecwarn(msg, b'6.1', stacklevel=2)
         entry = self._map.get(key)
         if entry is not None:
             return entry.state
         return b'?'
 
+    def get_entry(self, path):
+        """return a DirstateItem for the associated path"""
+        entry = self._map.get(path)
+        if entry is None:
+            return DirstateItem()
+        return entry
+
     def __contains__(self, key):
         return key in self._map
 
@@ -344,9 +375,6 @@
 
     iteritems = items
 
-    def directories(self):
-        return self._map.directories()
-
     def parents(self):
         return [self._validate(p) for p in self._pl]
 
@@ -385,32 +413,10 @@
         oldp2 = self._pl[1]
         if self._origpl is None:
             self._origpl = self._pl
-        self._map.setparents(p1, p2)
-        copies = {}
-        if (
-            oldp2 != self._nodeconstants.nullid
-            and p2 == self._nodeconstants.nullid
-        ):
-            candidatefiles = self._map.non_normal_or_other_parent_paths()
-
-            for f in candidatefiles:
-                s = self._map.get(f)
-                if s is None:
-                    continue
-
-                # Discard "merged" markers when moving away from a merge state
-                if s.merged:
-                    source = self._map.copymap.get(f)
-                    if source:
-                        copies[f] = source
-                    self._normallookup(f)
-                # Also fix up otherparent markers
-                elif s.from_p2:
-                    source = self._map.copymap.get(f)
-                    if source:
-                        copies[f] = source
-                    self._add(f)
-        return copies
+        nullid = self._nodeconstants.nullid
+        # True if we need to fold p2 related state back to a linear case
+        fold_p2 = oldp2 != nullid and p2 == nullid
+        return self._map.setparents(p1, p2, fold_p2=fold_p2)
 
     def setbranch(self, branch):
         self.__class__._branch.set(self, encoding.fromlocal(branch))
@@ -438,9 +444,8 @@
         for a in ("_map", "_branch", "_ignore"):
             if a in self.__dict__:
                 delattr(self, a)
-        self._lastnormaltime = 0
+        self._lastnormaltime = timestamp.zero()
         self._dirty = False
-        self._updatedfiles.clear()
         self._parentwriters = 0
         self._origpl = None
 
@@ -451,10 +456,8 @@
         self._dirty = True
         if source is not None:
             self._map.copymap[dest] = source
-            self._updatedfiles.add(source)
-            self._updatedfiles.add(dest)
-        elif self._map.copymap.pop(dest, None):
-            self._updatedfiles.add(dest)
+        else:
+            self._map.copymap.pop(dest, None)
 
     def copied(self, file):
         return self._map.copymap.get(file, None)
@@ -471,18 +474,11 @@
 
         return True the file was previously untracked, False otherwise.
         """
+        self._dirty = True
         entry = self._map.get(filename)
-        if entry is None:
-            self._add(filename)
-            return True
-        elif not entry.tracked:
-            self._normallookup(filename)
-            return True
-        # XXX This is probably overkill for more case, but we need this to
-        # fully replace the `normallookup` call with `set_tracked` one.
-        # Consider smoothing this in the future.
-        self.set_possibly_dirty(filename)
-        return False
+        if entry is None or not entry.tracked:
+            self._check_new_tracked_filename(filename)
+        return self._map.set_tracked(filename)
 
     @requires_no_parents_change
     def set_untracked(self, filename):
@@ -493,28 +489,32 @@
 
         return True the file was previously tracked, False otherwise.
         """
-        entry = self._map.get(filename)
-        if entry is None:
-            return False
-        elif entry.added:
-            self._drop(filename)
-            return True
-        else:
-            self._remove(filename)
-            return True
+        ret = self._map.set_untracked(filename)
+        if ret:
+            self._dirty = True
+        return ret
 
     @requires_no_parents_change
     def set_clean(self, filename, parentfiledata=None):
         """record that the current state of the file on disk is known to be clean"""
         self._dirty = True
-        self._updatedfiles.add(filename)
-        self._normal(filename, parentfiledata=parentfiledata)
+        if parentfiledata:
+            (mode, size, mtime) = parentfiledata
+        else:
+            (mode, size, mtime) = self._get_filedata(filename)
+        if not self._map[filename].tracked:
+            self._check_new_tracked_filename(filename)
+        self._map.set_clean(filename, mode, size, mtime)
+        if mtime > self._lastnormaltime:
+            # Remember the most recent modification timeslot for status(),
+            # to make sure we won't miss future size-preserving file content
+            # modifications that happen within the same timeslot.
+            self._lastnormaltime = mtime
 
     @requires_no_parents_change
     def set_possibly_dirty(self, filename):
         """record that the current state of the file on disk is unknown"""
         self._dirty = True
-        self._updatedfiles.add(filename)
         self._map.set_possibly_dirty(filename)
 
     @requires_parents_change
@@ -539,35 +539,26 @@
             wc_tracked = False
         else:
             wc_tracked = entry.tracked
-        possibly_dirty = False
-        if p1_tracked and wc_tracked:
-            # the underlying reference might have changed, we will have to
-            # check it.
-            possibly_dirty = True
-        elif not (p1_tracked or wc_tracked):
+        if not (p1_tracked or wc_tracked):
             # the file is no longer relevant to anyone
-            self._drop(filename)
+            if self._map.get(filename) is not None:
+                self._map.reset_state(filename)
+                self._dirty = True
         elif (not p1_tracked) and wc_tracked:
             if entry is not None and entry.added:
                 return  # avoid dropping copy information (maybe?)
-        elif p1_tracked and not wc_tracked:
-            pass
-        else:
-            assert False, 'unreachable'
 
-        # this mean we are doing call for file we do not really care about the
-        # data (eg: added or removed), however this should be a minor overhead
-        # compared to the overall update process calling this.
         parentfiledata = None
-        if wc_tracked:
+        if wc_tracked and p1_tracked:
             parentfiledata = self._get_filedata(filename)
 
-        self._updatedfiles.add(filename)
         self._map.reset_state(
             filename,
             wc_tracked,
             p1_tracked,
-            possibly_dirty=possibly_dirty,
+            # the underlying reference might have changed, we will have to
+            # check it.
+            has_meaningful_mtime=False,
             parentfiledata=parentfiledata,
         )
         if (
@@ -585,10 +576,7 @@
         filename,
         wc_tracked,
         p1_tracked,
-        p2_tracked=False,
-        merged=False,
-        clean_p1=False,
-        clean_p2=False,
+        p2_info=False,
         possibly_dirty=False,
         parentfiledata=None,
     ):
@@ -603,47 +591,26 @@
         depending of what information ends up being relevant and useful to
         other processing.
         """
-        if merged and (clean_p1 or clean_p2):
-            msg = b'`merged` argument incompatible with `clean_p1`/`clean_p2`'
-            raise error.ProgrammingError(msg)
 
         # note: I do not think we need to double check name clash here since we
         # are in a update/merge case that should already have taken care of
         # this. The test agrees
 
         self._dirty = True
-        self._updatedfiles.add(filename)
 
         need_parent_file_data = (
-            not (possibly_dirty or clean_p2 or merged)
-            and wc_tracked
-            and p1_tracked
+            not possibly_dirty and not p2_info and wc_tracked and p1_tracked
         )
 
-        # this mean we are doing call for file we do not really care about the
-        # data (eg: added or removed), however this should be a minor overhead
-        # compared to the overall update process calling this.
-        if need_parent_file_data:
-            if parentfiledata is None:
-                parentfiledata = self._get_filedata(filename)
-            mtime = parentfiledata[2]
-
-            if mtime > self._lastnormaltime:
-                # Remember the most recent modification timeslot for
-                # status(), to make sure we won't miss future
-                # size-preserving file content modifications that happen
-                # within the same timeslot.
-                self._lastnormaltime = mtime
+        if need_parent_file_data and parentfiledata is None:
+            parentfiledata = self._get_filedata(filename)
 
         self._map.reset_state(
             filename,
             wc_tracked,
             p1_tracked,
-            p2_tracked=p2_tracked,
-            merged=merged,
-            clean_p1=clean_p1,
-            clean_p2=clean_p2,
-            possibly_dirty=possibly_dirty,
+            p2_info=p2_info,
+            has_meaningful_mtime=not possibly_dirty,
             parentfiledata=parentfiledata,
         )
         if (
@@ -655,263 +622,30 @@
             # modifications that happen within the same timeslot.
             self._lastnormaltime = parentfiledata[2]
 
-    def _addpath(
-        self,
-        f,
-        mode=0,
-        size=None,
-        mtime=None,
-        added=False,
-        merged=False,
-        from_p2=False,
-        possibly_dirty=False,
-    ):
-        entry = self._map.get(f)
-        if added or entry is not None and entry.removed:
-            scmutil.checkfilename(f)
-            if self._map.hastrackeddir(f):
-                msg = _(b'directory %r already in dirstate')
-                msg %= pycompat.bytestr(f)
+    def _check_new_tracked_filename(self, filename):
+        scmutil.checkfilename(filename)
+        if self._map.hastrackeddir(filename):
+            msg = _(b'directory %r already in dirstate')
+            msg %= pycompat.bytestr(filename)
+            raise error.Abort(msg)
+        # shadows
+        for d in pathutil.finddirs(filename):
+            if self._map.hastrackeddir(d):
+                break
+            entry = self._map.get(d)
+            if entry is not None and not entry.removed:
+                msg = _(b'file %r in dirstate clashes with %r')
+                msg %= (pycompat.bytestr(d), pycompat.bytestr(filename))
                 raise error.Abort(msg)
-            # shadows
-            for d in pathutil.finddirs(f):
-                if self._map.hastrackeddir(d):
-                    break
-                entry = self._map.get(d)
-                if entry is not None and not entry.removed:
-                    msg = _(b'file %r in dirstate clashes with %r')
-                    msg %= (pycompat.bytestr(d), pycompat.bytestr(f))
-                    raise error.Abort(msg)
-        self._dirty = True
-        self._updatedfiles.add(f)
-        self._map.addfile(
-            f,
-            mode=mode,
-            size=size,
-            mtime=mtime,
-            added=added,
-            merged=merged,
-            from_p2=from_p2,
-            possibly_dirty=possibly_dirty,
-        )
 
     def _get_filedata(self, filename):
         """returns"""
         s = os.lstat(self._join(filename))
         mode = s.st_mode
         size = s.st_size
-        mtime = s[stat.ST_MTIME]
+        mtime = timestamp.mtime_of(s)
         return (mode, size, mtime)
 
-    def normal(self, f, parentfiledata=None):
-        """Mark a file normal and clean.
-
-        parentfiledata: (mode, size, mtime) of the clean file
-
-        parentfiledata should be computed from memory (for mode,
-        size), as or close as possible from the point where we
-        determined the file was clean, to limit the risk of the
-        file having been changed by an external process between the
-        moment where the file was determined to be clean and now."""
-        if self.pendingparentchange():
-            util.nouideprecwarn(
-                b"do not use `normal` inside of update/merge context."
-                b" Use `update_file` or `update_file_p1`",
-                b'6.0',
-                stacklevel=2,
-            )
-        else:
-            util.nouideprecwarn(
-                b"do not use `normal` outside of update/merge context."
-                b" Use `set_tracked`",
-                b'6.0',
-                stacklevel=2,
-            )
-        self._normal(f, parentfiledata=parentfiledata)
-
-    def _normal(self, f, parentfiledata=None):
-        if parentfiledata:
-            (mode, size, mtime) = parentfiledata
-        else:
-            (mode, size, mtime) = self._get_filedata(f)
-        self._addpath(f, mode=mode, size=size, mtime=mtime)
-        self._map.copymap.pop(f, None)
-        if f in self._map.nonnormalset:
-            self._map.nonnormalset.remove(f)
-        if mtime > self._lastnormaltime:
-            # Remember the most recent modification timeslot for status(),
-            # to make sure we won't miss future size-preserving file content
-            # modifications that happen within the same timeslot.
-            self._lastnormaltime = mtime
-
-    def normallookup(self, f):
-        '''Mark a file normal, but possibly dirty.'''
-        if self.pendingparentchange():
-            util.nouideprecwarn(
-                b"do not use `normallookup` inside of update/merge context."
-                b" Use `update_file` or `update_file_p1`",
-                b'6.0',
-                stacklevel=2,
-            )
-        else:
-            util.nouideprecwarn(
-                b"do not use `normallookup` outside of update/merge context."
-                b" Use `set_possibly_dirty` or `set_tracked`",
-                b'6.0',
-                stacklevel=2,
-            )
-        self._normallookup(f)
-
-    def _normallookup(self, f):
-        '''Mark a file normal, but possibly dirty.'''
-        if self.in_merge:
-            # if there is a merge going on and the file was either
-            # "merged" or coming from other parent (-2) before
-            # being removed, restore that state.
-            entry = self._map.get(f)
-            if entry is not None:
-                # XXX this should probably be dealt with a a lower level
-                # (see `merged_removed` and `from_p2_removed`)
-                if entry.merged_removed or entry.from_p2_removed:
-                    source = self._map.copymap.get(f)
-                    if entry.merged_removed:
-                        self._merge(f)
-                    elif entry.from_p2_removed:
-                        self._otherparent(f)
-                    if source is not None:
-                        self.copy(source, f)
-                    return
-                elif entry.merged or entry.from_p2:
-                    return
-        self._addpath(f, possibly_dirty=True)
-        self._map.copymap.pop(f, None)
-
-    def otherparent(self, f):
-        '''Mark as coming from the other parent, always dirty.'''
-        if self.pendingparentchange():
-            util.nouideprecwarn(
-                b"do not use `otherparent` inside of update/merge context."
-                b" Use `update_file` or `update_file_p1`",
-                b'6.0',
-                stacklevel=2,
-            )
-        else:
-            util.nouideprecwarn(
-                b"do not use `otherparent` outside of update/merge context."
-                b"It should have been set by the update/merge code",
-                b'6.0',
-                stacklevel=2,
-            )
-        self._otherparent(f)
-
-    def _otherparent(self, f):
-        if not self.in_merge:
-            msg = _(b"setting %r to other parent only allowed in merges") % f
-            raise error.Abort(msg)
-        entry = self._map.get(f)
-        if entry is not None and entry.tracked:
-            # merge-like
-            self._addpath(f, merged=True)
-        else:
-            # add-like
-            self._addpath(f, from_p2=True)
-        self._map.copymap.pop(f, None)
-
-    def add(self, f):
-        '''Mark a file added.'''
-        if self.pendingparentchange():
-            util.nouideprecwarn(
-                b"do not use `add` inside of update/merge context."
-                b" Use `update_file`",
-                b'6.0',
-                stacklevel=2,
-            )
-        else:
-            util.nouideprecwarn(
-                b"do not use `add` outside of update/merge context."
-                b" Use `set_tracked`",
-                b'6.0',
-                stacklevel=2,
-            )
-        self._add(f)
-
-    def _add(self, filename):
-        """internal function to mark a file as added"""
-        self._addpath(filename, added=True)
-        self._map.copymap.pop(filename, None)
-
-    def remove(self, f):
-        '''Mark a file removed'''
-        if self.pendingparentchange():
-            util.nouideprecwarn(
-                b"do not use `remove` insde of update/merge context."
-                b" Use `update_file` or `update_file_p1`",
-                b'6.0',
-                stacklevel=2,
-            )
-        else:
-            util.nouideprecwarn(
-                b"do not use `remove` outside of update/merge context."
-                b" Use `set_untracked`",
-                b'6.0',
-                stacklevel=2,
-            )
-        self._remove(f)
-
-    def _remove(self, filename):
-        """internal function to mark a file removed"""
-        self._dirty = True
-        self._updatedfiles.add(filename)
-        self._map.removefile(filename, in_merge=self.in_merge)
-
-    def merge(self, f):
-        '''Mark a file merged.'''
-        if self.pendingparentchange():
-            util.nouideprecwarn(
-                b"do not use `merge` inside of update/merge context."
-                b" Use `update_file`",
-                b'6.0',
-                stacklevel=2,
-            )
-        else:
-            util.nouideprecwarn(
-                b"do not use `merge` outside of update/merge context."
-                b"It should have been set by the update/merge code",
-                b'6.0',
-                stacklevel=2,
-            )
-        self._merge(f)
-
-    def _merge(self, f):
-        if not self.in_merge:
-            return self._normallookup(f)
-        return self._otherparent(f)
-
-    def drop(self, f):
-        '''Drop a file from the dirstate'''
-        if self.pendingparentchange():
-            util.nouideprecwarn(
-                b"do not use `drop` inside of update/merge context."
-                b" Use `update_file`",
-                b'6.0',
-                stacklevel=2,
-            )
-        else:
-            util.nouideprecwarn(
-                b"do not use `drop` outside of update/merge context."
-                b" Use `set_untracked`",
-                b'6.0',
-                stacklevel=2,
-            )
-        self._drop(f)
-
-    def _drop(self, filename):
-        """internal function to drop a file from the dirstate"""
-        if self._map.dropfile(filename):
-            self._dirty = True
-            self._updatedfiles.add(filename)
-            self._map.copymap.pop(filename, None)
-
     def _discoverpath(self, path, normed, ignoremissing, exists, storemap):
         if exists is None:
             exists = os.path.lexists(os.path.join(self._root, path))
@@ -990,8 +724,7 @@
 
     def clear(self):
         self._map.clear()
-        self._lastnormaltime = 0
-        self._updatedfiles.clear()
+        self._lastnormaltime = timestamp.zero()
         self._dirty = True
 
     def rebuild(self, parent, allfiles, changedfiles=None):
@@ -1022,9 +755,17 @@
         self._map.setparents(parent, self._nodeconstants.nullid)
 
         for f in to_lookup:
-            self._normallookup(f)
+
+            if self.in_merge:
+                self.set_tracked(f)
+            else:
+                self._map.reset_state(
+                    f,
+                    wc_tracked=True,
+                    p1_tracked=True,
+                )
         for f in to_drop:
-            self._drop(f)
+            self._map.reset_state(f)
 
         self._dirty = True
 
@@ -1048,19 +789,14 @@
             # See also the wiki page below for detail:
             # https://www.mercurial-scm.org/wiki/DirstateTransactionPlan
 
-            # emulate dropping timestamp in 'parsers.pack_dirstate'
+            # record when mtime start to be ambiguous
             now = _getfsnow(self._opener)
-            self._map.clearambiguoustimes(self._updatedfiles, now)
-
-            # emulate that all 'dirstate.normal' results are written out
-            self._lastnormaltime = 0
-            self._updatedfiles.clear()
 
             # delay writing in-memory changes out
             tr.addfilegenerator(
                 b'dirstate',
                 (self._filename,),
-                lambda f: self._writedirstate(tr, f),
+                lambda f: self._writedirstate(tr, f, now=now),
                 location=b'plain',
             )
             return
@@ -1079,7 +815,7 @@
         """
         self._plchangecallbacks[category] = callback
 
-    def _writedirstate(self, tr, st):
+    def _writedirstate(self, tr, st, now=None):
         # notify callbacks about parents change
         if self._origpl is not None and self._origpl != self._pl:
             for c, callback in sorted(
@@ -1087,9 +823,11 @@
             ):
                 callback(self, self._origpl, self._pl)
             self._origpl = None
-        # use the modification time of the newly created temporary file as the
-        # filesystem's notion of 'now'
-        now = util.fstat(st)[stat.ST_MTIME] & _rangemask
+
+        if now is None:
+            # use the modification time of the newly created temporary file as the
+            # filesystem's notion of 'now'
+            now = timestamp.mtime_of(util.fstat(st))
 
         # enough 'delaywrite' prevents 'pack_dirstate' from dropping
         # timestamp of each entries in dirstate, because of 'now > mtime'
@@ -1106,11 +844,12 @@
                     start = int(clock) - (int(clock) % delaywrite)
                     end = start + delaywrite
                     time.sleep(end - clock)
-                    now = end  # trust our estimate that the end is near now
+                    # trust our estimate that the end is near now
+                    now = timestamp.timestamp((end, 0))
                     break
 
         self._map.write(tr, st, now)
-        self._lastnormaltime = 0
+        self._lastnormaltime = timestamp.zero()
         self._dirty = False
 
     def _dirignore(self, f):
@@ -1503,7 +1242,7 @@
             traversed,
             dirty,
         ) = rustmod.status(
-            self._map._rustmap,
+            self._map._map,
             matcher,
             self._rootdir,
             self._ignorefiles(),
@@ -1643,21 +1382,13 @@
                     uadd(fn)
                 continue
 
-            # This is equivalent to 'state, mode, size, time = dmap[fn]' but not
-            # written like that for performance reasons. dmap[fn] is not a
-            # Python tuple in compiled builds. The CPython UNPACK_SEQUENCE
-            # opcode has fast paths when the value to be unpacked is a tuple or
-            # a list, but falls back to creating a full-fledged iterator in
-            # general. That is much slower than simply accessing and storing the
-            # tuple members one by one.
             t = dget(fn)
             mode = t.mode
             size = t.size
-            time = t.mtime
 
             if not st and t.tracked:
                 dadd(fn)
-            elif t.merged:
+            elif t.p2_info:
                 madd(fn)
             elif t.added:
                 aadd(fn)
@@ -1670,7 +1401,6 @@
                         (size != st.st_size and size != st.st_size & _rangemask)
                         or ((mode ^ st.st_mode) & 0o100 and checkexec)
                     )
-                    or t.from_p2
                     or fn in copymap
                 ):
                     if stat.S_ISLNK(st.st_mode) and size != st.st_size:
@@ -1679,12 +1409,9 @@
                         ladd(fn)
                     else:
                         madd(fn)
-                elif (
-                    time != st[stat.ST_MTIME]
-                    and time != st[stat.ST_MTIME] & _rangemask
-                ):
+                elif not t.mtime_likely_equal_to(timestamp.mtime_of(st)):
                     ladd(fn)
-                elif st[stat.ST_MTIME] == lastnormaltime:
+                elif timestamp.mtime_of(st) == lastnormaltime:
                     # fn may have just been marked as normal and it may have
                     # changed in the same second without changing its size.
                     # This can happen if we quickly do multiple commits.
@@ -1703,7 +1430,7 @@
         """
         dmap = self._map
         if rustmod is not None:
-            dmap = self._map._rustmap
+            dmap = self._map._map
 
         if match.always():
             return dmap.keys()
@@ -1778,3 +1505,22 @@
     def clearbackup(self, tr, backupname):
         '''Clear backup file'''
         self._opener.unlink(backupname)
+
+    def verify(self, m1, m2):
+        """check the dirstate content again the parent manifest and yield errors"""
+        missing_from_p1 = b"%s in state %s, but not in manifest1\n"
+        unexpected_in_p1 = b"%s in state %s, but also in manifest1\n"
+        missing_from_ps = b"%s in state %s, but not in either manifest\n"
+        missing_from_ds = b"%s in manifest1, but listed as state %s\n"
+        for f, entry in self.items():
+            state = entry.state
+            if state in b"nr" and f not in m1:
+                yield (missing_from_p1, f, state)
+            if state in b"a" and f in m1:
+                yield (unexpected_in_p1, f, state)
+            if state in b"m" and f not in m1 and f not in m2:
+                yield (missing_from_ps, f, state)
+        for f in m1:
+            state = self.get_entry(f).state
+            if state not in b"nrm":
+                yield (missing_from_ds, f, state)
--- a/mercurial/dirstatemap.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/dirstatemap.py	Thu Oct 21 17:25:41 2021 +0200
@@ -20,6 +20,7 @@
 
 from .dirstateutils import (
     docket as docketmod,
+    v2,
 )
 
 parsers = policy.importmod('parsers')
@@ -27,22 +28,276 @@
 
 propertycache = util.propertycache
 
-DirstateItem = parsers.DirstateItem
-
-
-# a special value used internally for `size` if the file come from the other parent
-FROM_P2 = -2
-
-# a special value used internally for `size` if the file is modified/merged/added
-NONNORMAL = -1
-
-# a special value used internally for `time` if the time is ambigeous
-AMBIGUOUS_TIME = -1
+if rustmod is None:
+    DirstateItem = parsers.DirstateItem
+else:
+    DirstateItem = rustmod.DirstateItem
 
 rangemask = 0x7FFFFFFF
 
 
-class dirstatemap(object):
+class _dirstatemapcommon(object):
+    """
+    Methods that are identical for both implementations of the dirstatemap
+    class, with and without Rust extensions enabled.
+    """
+
+    # please pytype
+
+    _map = None
+    copymap = None
+
+    def __init__(self, ui, opener, root, nodeconstants, use_dirstate_v2):
+        self._use_dirstate_v2 = use_dirstate_v2
+        self._nodeconstants = nodeconstants
+        self._ui = ui
+        self._opener = opener
+        self._root = root
+        self._filename = b'dirstate'
+        self._nodelen = 20  # Also update Rust code when changing this!
+        self._parents = None
+        self._dirtyparents = False
+        self._docket = None
+
+        # for consistent view between _pl() and _read() invocations
+        self._pendingmode = None
+
+    def preload(self):
+        """Loads the underlying data, if it's not already loaded"""
+        self._map
+
+    def get(self, key, default=None):
+        return self._map.get(key, default)
+
+    def __len__(self):
+        return len(self._map)
+
+    def __iter__(self):
+        return iter(self._map)
+
+    def __contains__(self, key):
+        return key in self._map
+
+    def __getitem__(self, item):
+        return self._map[item]
+
+    ### sub-class utility method
+    #
+    # Use to allow for generic implementation of some method while still coping
+    # with minor difference between implementation.
+
+    def _dirs_incr(self, filename, old_entry=None):
+        """incremente the dirstate counter if applicable
+
+        This might be a no-op for some subclass who deal with directory
+        tracking in a different way.
+        """
+
+    def _dirs_decr(self, filename, old_entry=None, remove_variant=False):
+        """decremente the dirstate counter if applicable
+
+        This might be a no-op for some subclass who deal with directory
+        tracking in a different way.
+        """
+
+    def _refresh_entry(self, f, entry):
+        """record updated state of an entry"""
+
+    def _insert_entry(self, f, entry):
+        """add a new dirstate entry (or replace an unrelated one)
+
+        The fact it is actually new is the responsability of the caller
+        """
+
+    def _drop_entry(self, f):
+        """remove any entry for file f
+
+        This should also drop associated copy information
+
+        The fact we actually need to drop it is the responsability of the caller"""
+
+    ### method to manipulate the entries
+
+    def set_possibly_dirty(self, filename):
+        """record that the current state of the file on disk is unknown"""
+        entry = self[filename]
+        entry.set_possibly_dirty()
+        self._refresh_entry(filename, entry)
+
+    def set_clean(self, filename, mode, size, mtime):
+        """mark a file as back to a clean state"""
+        entry = self[filename]
+        size = size & rangemask
+        entry.set_clean(mode, size, mtime)
+        self._refresh_entry(filename, entry)
+        self.copymap.pop(filename, None)
+
+    def set_tracked(self, filename):
+        new = False
+        entry = self.get(filename)
+        if entry is None:
+            self._dirs_incr(filename)
+            entry = DirstateItem(
+                wc_tracked=True,
+            )
+
+            self._insert_entry(filename, entry)
+            new = True
+        elif not entry.tracked:
+            self._dirs_incr(filename, entry)
+            entry.set_tracked()
+            self._refresh_entry(filename, entry)
+            new = True
+        else:
+            # XXX This is probably overkill for more case, but we need this to
+            # fully replace the `normallookup` call with `set_tracked` one.
+            # Consider smoothing this in the future.
+            entry.set_possibly_dirty()
+            self._refresh_entry(filename, entry)
+        return new
+
+    def set_untracked(self, f):
+        """Mark a file as no longer tracked in the dirstate map"""
+        entry = self.get(f)
+        if entry is None:
+            return False
+        else:
+            self._dirs_decr(f, old_entry=entry, remove_variant=not entry.added)
+            if not entry.p2_info:
+                self.copymap.pop(f, None)
+            entry.set_untracked()
+            self._refresh_entry(f, entry)
+            return True
+
+    def reset_state(
+        self,
+        filename,
+        wc_tracked=False,
+        p1_tracked=False,
+        p2_info=False,
+        has_meaningful_mtime=True,
+        has_meaningful_data=True,
+        parentfiledata=None,
+    ):
+        """Set a entry to a given state, diregarding all previous state
+
+        This is to be used by the part of the dirstate API dedicated to
+        adjusting the dirstate after a update/merge.
+
+        note: calling this might result to no entry existing at all if the
+        dirstate map does not see any point at having one for this file
+        anymore.
+        """
+        # copy information are now outdated
+        # (maybe new information should be in directly passed to this function)
+        self.copymap.pop(filename, None)
+
+        if not (p1_tracked or p2_info or wc_tracked):
+            old_entry = self._map.get(filename)
+            self._drop_entry(filename)
+            self._dirs_decr(filename, old_entry=old_entry)
+            return
+
+        old_entry = self._map.get(filename)
+        self._dirs_incr(filename, old_entry)
+        entry = DirstateItem(
+            wc_tracked=wc_tracked,
+            p1_tracked=p1_tracked,
+            p2_info=p2_info,
+            has_meaningful_mtime=has_meaningful_mtime,
+            parentfiledata=parentfiledata,
+        )
+        self._insert_entry(filename, entry)
+
+    ### disk interaction
+
+    def _opendirstatefile(self):
+        fp, mode = txnutil.trypending(self._root, self._opener, self._filename)
+        if self._pendingmode is not None and self._pendingmode != mode:
+            fp.close()
+            raise error.Abort(
+                _(b'working directory state may be changed parallelly')
+            )
+        self._pendingmode = mode
+        return fp
+
+    def _readdirstatefile(self, size=-1):
+        try:
+            with self._opendirstatefile() as fp:
+                return fp.read(size)
+        except IOError as err:
+            if err.errno != errno.ENOENT:
+                raise
+            # File doesn't exist, so the current state is empty
+            return b''
+
+    @property
+    def docket(self):
+        if not self._docket:
+            if not self._use_dirstate_v2:
+                raise error.ProgrammingError(
+                    b'dirstate only has a docket in v2 format'
+                )
+            self._docket = docketmod.DirstateDocket.parse(
+                self._readdirstatefile(), self._nodeconstants
+            )
+        return self._docket
+
+    def write_v2_no_append(self, tr, st, meta, packed):
+        old_docket = self.docket
+        new_docket = docketmod.DirstateDocket.with_new_uuid(
+            self.parents(), len(packed), meta
+        )
+        data_filename = new_docket.data_filename()
+        if tr:
+            tr.add(data_filename, 0)
+        self._opener.write(data_filename, packed)
+        # Write the new docket after the new data file has been
+        # written. Because `st` was opened with `atomictemp=True`,
+        # the actual `.hg/dirstate` file is only affected on close.
+        st.write(new_docket.serialize())
+        st.close()
+        # Remove the old data file after the new docket pointing to
+        # the new data file was written.
+        if old_docket.uuid:
+            data_filename = old_docket.data_filename()
+            unlink = lambda _tr=None: self._opener.unlink(data_filename)
+            if tr:
+                category = b"dirstate-v2-clean-" + old_docket.uuid
+                tr.addpostclose(category, unlink)
+            else:
+                unlink()
+        self._docket = new_docket
+
+    ### reading/setting parents
+
+    def parents(self):
+        if not self._parents:
+            if self._use_dirstate_v2:
+                self._parents = self.docket.parents
+            else:
+                read_len = self._nodelen * 2
+                st = self._readdirstatefile(read_len)
+                l = len(st)
+                if l == read_len:
+                    self._parents = (
+                        st[: self._nodelen],
+                        st[self._nodelen : 2 * self._nodelen],
+                    )
+                elif l == 0:
+                    self._parents = (
+                        self._nodeconstants.nullid,
+                        self._nodeconstants.nullid,
+                    )
+                else:
+                    raise error.Abort(
+                        _(b'working directory state appears damaged!')
+                    )
+
+        return self._parents
+
+
+class dirstatemap(_dirstatemapcommon):
     """Map encapsulating the dirstate's contents.
 
     The dirstate contains the following state:
@@ -56,19 +311,19 @@
     - the state map maps filenames to tuples of (state, mode, size, mtime),
       where state is a single character representing 'normal', 'added',
       'removed', or 'merged'. It is read by treating the dirstate as a
-      dict.  File state is updated by calling the `addfile`, `removefile` and
-      `dropfile` methods.
+      dict.  File state is updated by calling various methods (see each
+      documentation for details):
+
+      - `reset_state`,
+      - `set_tracked`
+      - `set_untracked`
+      - `set_clean`
+      - `set_possibly_dirty`
 
     - `copymap` maps destination filenames to their source filename.
 
     The dirstate also provides the following views onto the state:
 
-    - `nonnormalset` is a set of the filenames that have state other
-      than 'normal', or are normal but have an mtime of -1 ('normallookup').
-
-    - `otherparentset` is a set of the filenames that are marked as coming
-      from the second parent when the dirstate is currently being merged.
-
     - `filefoldmap` is a dict mapping normalized filenames to the denormalized
       form that they appear as in the dirstate.
 
@@ -76,22 +331,7 @@
       denormalized form that they appear as in the dirstate.
     """
 
-    def __init__(self, ui, opener, root, nodeconstants, use_dirstate_v2):
-        self._ui = ui
-        self._opener = opener
-        self._root = root
-        self._filename = b'dirstate'
-        self._nodelen = 20
-        self._nodeconstants = nodeconstants
-        assert (
-            not use_dirstate_v2
-        ), "should have detected unsupported requirement"
-
-        self._parents = None
-        self._dirtyparents = False
-
-        # for consistent view between _pl() and _read() invocations
-        self._pendingmode = None
+    ### Core data storage and access
 
     @propertycache
     def _map(self):
@@ -113,8 +353,6 @@
         util.clearcachedproperty(self, b"_alldirs")
         util.clearcachedproperty(self, b"filefoldmap")
         util.clearcachedproperty(self, b"dirfoldmap")
-        util.clearcachedproperty(self, b"nonnormalset")
-        util.clearcachedproperty(self, b"otherparentset")
 
     def items(self):
         return pycompat.iteritems(self._map)
@@ -122,29 +360,109 @@
     # forward for python2,3 compat
     iteritems = items
 
-    debug_iter = items
-
-    def __len__(self):
-        return len(self._map)
-
-    def __iter__(self):
-        return iter(self._map)
+    def debug_iter(self, all):
+        """
+        Return an iterator of (filename, state, mode, size, mtime) tuples
 
-    def get(self, key, default=None):
-        return self._map.get(key, default)
-
-    def __contains__(self, key):
-        return key in self._map
-
-    def __getitem__(self, key):
-        return self._map[key]
+        `all` is unused when Rust is not enabled
+        """
+        for (filename, item) in self.items():
+            yield (filename, item.state, item.mode, item.size, item.mtime)
 
     def keys(self):
         return self._map.keys()
 
-    def preload(self):
-        """Loads the underlying data, if it's not already loaded"""
+    ### reading/setting parents
+
+    def setparents(self, p1, p2, fold_p2=False):
+        self._parents = (p1, p2)
+        self._dirtyparents = True
+        copies = {}
+        if fold_p2:
+            for f, s in pycompat.iteritems(self._map):
+                # Discard "merged" markers when moving away from a merge state
+                if s.p2_info:
+                    source = self.copymap.pop(f, None)
+                    if source:
+                        copies[f] = source
+                    s.drop_merge_data()
+        return copies
+
+    ### disk interaction
+
+    def read(self):
+        # ignore HG_PENDING because identity is used only for writing
+        self.identity = util.filestat.frompath(
+            self._opener.join(self._filename)
+        )
+
+        if self._use_dirstate_v2:
+            if not self.docket.uuid:
+                return
+            st = self._opener.read(self.docket.data_filename())
+        else:
+            st = self._readdirstatefile()
+
+        if not st:
+            return
+
+        # TODO: adjust this estimate for dirstate-v2
+        if util.safehasattr(parsers, b'dict_new_presized'):
+            # Make an estimate of the number of files in the dirstate based on
+            # its size. This trades wasting some memory for avoiding costly
+            # resizes. Each entry have a prefix of 17 bytes followed by one or
+            # two path names. Studies on various large-scale real-world repositories
+            # found 54 bytes a reasonable upper limit for the average path names.
+            # Copy entries are ignored for the sake of this estimate.
+            self._map = parsers.dict_new_presized(len(st) // 71)
+
+        # Python's garbage collector triggers a GC each time a certain number
+        # of container objects (the number being defined by
+        # gc.get_threshold()) are allocated. parse_dirstate creates a tuple
+        # for each file in the dirstate. The C version then immediately marks
+        # them as not to be tracked by the collector. However, this has no
+        # effect on when GCs are triggered, only on what objects the GC looks
+        # into. This means that O(number of files) GCs are unavoidable.
+        # Depending on when in the process's lifetime the dirstate is parsed,
+        # this can get very expensive. As a workaround, disable GC while
+        # parsing the dirstate.
+        #
+        # (we cannot decorate the function directly since it is in a C module)
+        if self._use_dirstate_v2:
+            p = self.docket.parents
+            meta = self.docket.tree_metadata
+            parse_dirstate = util.nogc(v2.parse_dirstate)
+            parse_dirstate(self._map, self.copymap, st, meta)
+        else:
+            parse_dirstate = util.nogc(parsers.parse_dirstate)
+            p = parse_dirstate(self._map, self.copymap, st)
+        if not self._dirtyparents:
+            self.setparents(*p)
+
+        # Avoid excess attribute lookups by fast pathing certain checks
+        self.__contains__ = self._map.__contains__
+        self.__getitem__ = self._map.__getitem__
+        self.get = self._map.get
+
+    def write(self, tr, st, now):
+        if self._use_dirstate_v2:
+            packed, meta = v2.pack_dirstate(self._map, self.copymap, now)
+            self.write_v2_no_append(tr, st, meta, packed)
+        else:
+            packed = parsers.pack_dirstate(
+                self._map, self.copymap, self.parents(), now
+            )
+            st.write(packed)
+            st.close()
+        self._dirtyparents = False
+
+    @propertycache
+    def identity(self):
         self._map
+        return self.identity
+
+    ### code related to maintaining and accessing "extra" property
+    # (e.g. "has_dir")
 
     def _dirs_incr(self, filename, old_entry=None):
         """incremente the dirstate counter if applicable"""
@@ -168,200 +486,6 @@
             normed = util.normcase(filename)
             self.filefoldmap.pop(normed, None)
 
-    def set_possibly_dirty(self, filename):
-        """record that the current state of the file on disk is unknown"""
-        self[filename].set_possibly_dirty()
-
-    def addfile(
-        self,
-        f,
-        mode=0,
-        size=None,
-        mtime=None,
-        added=False,
-        merged=False,
-        from_p2=False,
-        possibly_dirty=False,
-    ):
-        """Add a tracked file to the dirstate."""
-        if added:
-            assert not merged
-            assert not possibly_dirty
-            assert not from_p2
-            state = b'a'
-            size = NONNORMAL
-            mtime = AMBIGUOUS_TIME
-        elif merged:
-            assert not possibly_dirty
-            assert not from_p2
-            state = b'm'
-            size = FROM_P2
-            mtime = AMBIGUOUS_TIME
-        elif from_p2:
-            assert not possibly_dirty
-            state = b'n'
-            size = FROM_P2
-            mtime = AMBIGUOUS_TIME
-        elif possibly_dirty:
-            state = b'n'
-            size = NONNORMAL
-            mtime = AMBIGUOUS_TIME
-        else:
-            assert size != FROM_P2
-            assert size != NONNORMAL
-            assert size is not None
-            assert mtime is not None
-
-            state = b'n'
-            size = size & rangemask
-            mtime = mtime & rangemask
-        assert state is not None
-        assert size is not None
-        assert mtime is not None
-        old_entry = self.get(f)
-        self._dirs_incr(f, old_entry)
-        e = self._map[f] = DirstateItem(state, mode, size, mtime)
-        if e.dm_nonnormal:
-            self.nonnormalset.add(f)
-        if e.dm_otherparent:
-            self.otherparentset.add(f)
-
-    def reset_state(
-        self,
-        filename,
-        wc_tracked,
-        p1_tracked,
-        p2_tracked=False,
-        merged=False,
-        clean_p1=False,
-        clean_p2=False,
-        possibly_dirty=False,
-        parentfiledata=None,
-    ):
-        """Set a entry to a given state, diregarding all previous state
-
-        This is to be used by the part of the dirstate API dedicated to
-        adjusting the dirstate after a update/merge.
-
-        note: calling this might result to no entry existing at all if the
-        dirstate map does not see any point at having one for this file
-        anymore.
-        """
-        if merged and (clean_p1 or clean_p2):
-            msg = b'`merged` argument incompatible with `clean_p1`/`clean_p2`'
-            raise error.ProgrammingError(msg)
-        # copy information are now outdated
-        # (maybe new information should be in directly passed to this function)
-        self.copymap.pop(filename, None)
-
-        if not (p1_tracked or p2_tracked or wc_tracked):
-            self.dropfile(filename)
-        elif merged:
-            # XXX might be merged and removed ?
-            entry = self.get(filename)
-            if entry is not None and entry.tracked:
-                # XXX mostly replicate dirstate.other parent.  We should get
-                # the higher layer to pass us more reliable data where `merged`
-                # actually mean merged. Dropping the else clause will show
-                # failure in `test-graft.t`
-                self.addfile(filename, merged=True)
-            else:
-                self.addfile(filename, from_p2=True)
-        elif not (p1_tracked or p2_tracked) and wc_tracked:
-            self.addfile(filename, added=True, possibly_dirty=possibly_dirty)
-        elif (p1_tracked or p2_tracked) and not wc_tracked:
-            # XXX might be merged and removed ?
-            old_entry = self._map.get(filename)
-            self._dirs_decr(filename, old_entry=old_entry, remove_variant=True)
-            self._map[filename] = DirstateItem(b'r', 0, 0, 0)
-            self.nonnormalset.add(filename)
-        elif clean_p2 and wc_tracked:
-            if p1_tracked or self.get(filename) is not None:
-                # XXX the `self.get` call is catching some case in
-                # `test-merge-remove.t` where the file is tracked in p1, the
-                # p1_tracked argument is False.
-                #
-                # In addition, this seems to be a case where the file is marked
-                # as merged without actually being the result of a merge
-                # action. So thing are not ideal here.
-                self.addfile(filename, merged=True)
-            else:
-                self.addfile(filename, from_p2=True)
-        elif not p1_tracked and p2_tracked and wc_tracked:
-            self.addfile(filename, from_p2=True, possibly_dirty=possibly_dirty)
-        elif possibly_dirty:
-            self.addfile(filename, possibly_dirty=possibly_dirty)
-        elif wc_tracked:
-            # this is a "normal" file
-            if parentfiledata is None:
-                msg = b'failed to pass parentfiledata for a normal file: %s'
-                msg %= filename
-                raise error.ProgrammingError(msg)
-            mode, size, mtime = parentfiledata
-            self.addfile(filename, mode=mode, size=size, mtime=mtime)
-            self.nonnormalset.discard(filename)
-        else:
-            assert False, 'unreachable'
-
-    def removefile(self, f, in_merge=False):
-        """
-        Mark a file as removed in the dirstate.
-
-        The `size` parameter is used to store sentinel values that indicate
-        the file's previous state.  In the future, we should refactor this
-        to be more explicit about what that state is.
-        """
-        entry = self.get(f)
-        size = 0
-        if in_merge:
-            # XXX we should not be able to have 'm' state and 'FROM_P2' if not
-            # during a merge. So I (marmoute) am not sure we need the
-            # conditionnal at all. Adding double checking this with assert
-            # would be nice.
-            if entry is not None:
-                # backup the previous state
-                if entry.merged:  # merge
-                    size = NONNORMAL
-                elif entry.from_p2:
-                    size = FROM_P2
-                    self.otherparentset.add(f)
-        if entry is not None and not (entry.merged or entry.from_p2):
-            self.copymap.pop(f, None)
-        self._dirs_decr(f, old_entry=entry, remove_variant=True)
-        self._map[f] = DirstateItem(b'r', 0, size, 0)
-        self.nonnormalset.add(f)
-
-    def dropfile(self, f):
-        """
-        Remove a file from the dirstate.  Returns True if the file was
-        previously recorded.
-        """
-        old_entry = self._map.pop(f, None)
-        self._dirs_decr(f, old_entry=old_entry)
-        self.nonnormalset.discard(f)
-        return old_entry is not None
-
-    def clearambiguoustimes(self, files, now):
-        for f in files:
-            e = self.get(f)
-            if e is not None and e.need_delay(now):
-                e.set_possibly_dirty()
-                self.nonnormalset.add(f)
-
-    def nonnormalentries(self):
-        '''Compute the nonnormal dirstate entries from the dmap'''
-        try:
-            return parsers.nonnormalotherparententries(self._map)
-        except AttributeError:
-            nonnorm = set()
-            otherparent = set()
-            for fname, e in pycompat.iteritems(self._map):
-                if e.dm_nonnormal:
-                    nonnorm.add(fname)
-                if e.from_p2:
-                    otherparent.add(fname)
-            return nonnorm, otherparent
-
     @propertycache
     def filefoldmap(self):
         """Returns a dictionary mapping normalized case paths to their
@@ -384,6 +508,14 @@
         f[b'.'] = b'.'  # prevents useless util.fspath() invocation
         return f
 
+    @propertycache
+    def dirfoldmap(self):
+        f = {}
+        normcase = util.normcase
+        for name in self._dirs:
+            f[normcase(name)] = name
+        return f
+
     def hastrackeddir(self, d):
         """
         Returns True if the dirstate contains a tracked (not removed) file
@@ -400,393 +532,34 @@
 
     @propertycache
     def _dirs(self):
-        return pathutil.dirs(self._map, b'r')
+        return pathutil.dirs(self._map, only_tracked=True)
 
     @propertycache
     def _alldirs(self):
         return pathutil.dirs(self._map)
 
-    def _opendirstatefile(self):
-        fp, mode = txnutil.trypending(self._root, self._opener, self._filename)
-        if self._pendingmode is not None and self._pendingmode != mode:
-            fp.close()
-            raise error.Abort(
-                _(b'working directory state may be changed parallelly')
-            )
-        self._pendingmode = mode
-        return fp
-
-    def parents(self):
-        if not self._parents:
-            try:
-                fp = self._opendirstatefile()
-                st = fp.read(2 * self._nodelen)
-                fp.close()
-            except IOError as err:
-                if err.errno != errno.ENOENT:
-                    raise
-                # File doesn't exist, so the current state is empty
-                st = b''
+    ### code related to manipulation of entries and copy-sources
 
-            l = len(st)
-            if l == self._nodelen * 2:
-                self._parents = (
-                    st[: self._nodelen],
-                    st[self._nodelen : 2 * self._nodelen],
-                )
-            elif l == 0:
-                self._parents = (
-                    self._nodeconstants.nullid,
-                    self._nodeconstants.nullid,
-                )
-            else:
-                raise error.Abort(
-                    _(b'working directory state appears damaged!')
-                )
-
-        return self._parents
-
-    def setparents(self, p1, p2):
-        self._parents = (p1, p2)
-        self._dirtyparents = True
-
-    def read(self):
-        # ignore HG_PENDING because identity is used only for writing
-        self.identity = util.filestat.frompath(
-            self._opener.join(self._filename)
-        )
-
-        try:
-            fp = self._opendirstatefile()
-            try:
-                st = fp.read()
-            finally:
-                fp.close()
-        except IOError as err:
-            if err.errno != errno.ENOENT:
-                raise
-            return
-        if not st:
-            return
+    def _refresh_entry(self, f, entry):
+        if not entry.any_tracked:
+            self._map.pop(f, None)
 
-        if util.safehasattr(parsers, b'dict_new_presized'):
-            # Make an estimate of the number of files in the dirstate based on
-            # its size. This trades wasting some memory for avoiding costly
-            # resizes. Each entry have a prefix of 17 bytes followed by one or
-            # two path names. Studies on various large-scale real-world repositories
-            # found 54 bytes a reasonable upper limit for the average path names.
-            # Copy entries are ignored for the sake of this estimate.
-            self._map = parsers.dict_new_presized(len(st) // 71)
-
-        # Python's garbage collector triggers a GC each time a certain number
-        # of container objects (the number being defined by
-        # gc.get_threshold()) are allocated. parse_dirstate creates a tuple
-        # for each file in the dirstate. The C version then immediately marks
-        # them as not to be tracked by the collector. However, this has no
-        # effect on when GCs are triggered, only on what objects the GC looks
-        # into. This means that O(number of files) GCs are unavoidable.
-        # Depending on when in the process's lifetime the dirstate is parsed,
-        # this can get very expensive. As a workaround, disable GC while
-        # parsing the dirstate.
-        #
-        # (we cannot decorate the function directly since it is in a C module)
-        parse_dirstate = util.nogc(parsers.parse_dirstate)
-        p = parse_dirstate(self._map, self.copymap, st)
-        if not self._dirtyparents:
-            self.setparents(*p)
-
-        # Avoid excess attribute lookups by fast pathing certain checks
-        self.__contains__ = self._map.__contains__
-        self.__getitem__ = self._map.__getitem__
-        self.get = self._map.get
+    def _insert_entry(self, f, entry):
+        self._map[f] = entry
 
-    def write(self, _tr, st, now):
-        st.write(
-            parsers.pack_dirstate(self._map, self.copymap, self.parents(), now)
-        )
-        st.close()
-        self._dirtyparents = False
-        self.nonnormalset, self.otherparentset = self.nonnormalentries()
-
-    @propertycache
-    def nonnormalset(self):
-        nonnorm, otherparents = self.nonnormalentries()
-        self.otherparentset = otherparents
-        return nonnorm
-
-    @propertycache
-    def otherparentset(self):
-        nonnorm, otherparents = self.nonnormalentries()
-        self.nonnormalset = nonnorm
-        return otherparents
-
-    def non_normal_or_other_parent_paths(self):
-        return self.nonnormalset.union(self.otherparentset)
-
-    @propertycache
-    def identity(self):
-        self._map
-        return self.identity
-
-    @propertycache
-    def dirfoldmap(self):
-        f = {}
-        normcase = util.normcase
-        for name in self._dirs:
-            f[normcase(name)] = name
-        return f
+    def _drop_entry(self, f):
+        self._map.pop(f, None)
+        self.copymap.pop(f, None)
 
 
 if rustmod is not None:
 
-    class dirstatemap(object):
-        def __init__(self, ui, opener, root, nodeconstants, use_dirstate_v2):
-            self._use_dirstate_v2 = use_dirstate_v2
-            self._nodeconstants = nodeconstants
-            self._ui = ui
-            self._opener = opener
-            self._root = root
-            self._filename = b'dirstate'
-            self._nodelen = 20  # Also update Rust code when changing this!
-            self._parents = None
-            self._dirtyparents = False
-            self._docket = None
-
-            # for consistent view between _pl() and _read() invocations
-            self._pendingmode = None
-
-            self._use_dirstate_tree = self._ui.configbool(
-                b"experimental",
-                b"dirstate-tree.in-memory",
-                False,
-            )
-
-        def addfile(
-            self,
-            f,
-            mode=0,
-            size=None,
-            mtime=None,
-            added=False,
-            merged=False,
-            from_p2=False,
-            possibly_dirty=False,
-        ):
-            return self._rustmap.addfile(
-                f,
-                mode,
-                size,
-                mtime,
-                added,
-                merged,
-                from_p2,
-                possibly_dirty,
-            )
-
-        def reset_state(
-            self,
-            filename,
-            wc_tracked,
-            p1_tracked,
-            p2_tracked=False,
-            merged=False,
-            clean_p1=False,
-            clean_p2=False,
-            possibly_dirty=False,
-            parentfiledata=None,
-        ):
-            """Set a entry to a given state, disregarding all previous state
-
-            This is to be used by the part of the dirstate API dedicated to
-            adjusting the dirstate after a update/merge.
-
-            note: calling this might result to no entry existing at all if the
-            dirstate map does not see any point at having one for this file
-            anymore.
-            """
-            if merged and (clean_p1 or clean_p2):
-                msg = (
-                    b'`merged` argument incompatible with `clean_p1`/`clean_p2`'
-                )
-                raise error.ProgrammingError(msg)
-            # copy information are now outdated
-            # (maybe new information should be in directly passed to this function)
-            self.copymap.pop(filename, None)
+    class dirstatemap(_dirstatemapcommon):
 
-            if not (p1_tracked or p2_tracked or wc_tracked):
-                self.dropfile(filename)
-            elif merged:
-                # XXX might be merged and removed ?
-                entry = self.get(filename)
-                if entry is not None and entry.tracked:
-                    # XXX mostly replicate dirstate.other parent.  We should get
-                    # the higher layer to pass us more reliable data where `merged`
-                    # actually mean merged. Dropping the else clause will show
-                    # failure in `test-graft.t`
-                    self.addfile(filename, merged=True)
-                else:
-                    self.addfile(filename, from_p2=True)
-            elif not (p1_tracked or p2_tracked) and wc_tracked:
-                self.addfile(
-                    filename, added=True, possibly_dirty=possibly_dirty
-                )
-            elif (p1_tracked or p2_tracked) and not wc_tracked:
-                # XXX might be merged and removed ?
-                self[filename] = DirstateItem(b'r', 0, 0, 0)
-                self.nonnormalset.add(filename)
-            elif clean_p2 and wc_tracked:
-                if p1_tracked or self.get(filename) is not None:
-                    # XXX the `self.get` call is catching some case in
-                    # `test-merge-remove.t` where the file is tracked in p1, the
-                    # p1_tracked argument is False.
-                    #
-                    # In addition, this seems to be a case where the file is marked
-                    # as merged without actually being the result of a merge
-                    # action. So thing are not ideal here.
-                    self.addfile(filename, merged=True)
-                else:
-                    self.addfile(filename, from_p2=True)
-            elif not p1_tracked and p2_tracked and wc_tracked:
-                self.addfile(
-                    filename, from_p2=True, possibly_dirty=possibly_dirty
-                )
-            elif possibly_dirty:
-                self.addfile(filename, possibly_dirty=possibly_dirty)
-            elif wc_tracked:
-                # this is a "normal" file
-                if parentfiledata is None:
-                    msg = b'failed to pass parentfiledata for a normal file: %s'
-                    msg %= filename
-                    raise error.ProgrammingError(msg)
-                mode, size, mtime = parentfiledata
-                self.addfile(filename, mode=mode, size=size, mtime=mtime)
-                self.nonnormalset.discard(filename)
-            else:
-                assert False, 'unreachable'
-
-        def removefile(self, *args, **kwargs):
-            return self._rustmap.removefile(*args, **kwargs)
-
-        def dropfile(self, *args, **kwargs):
-            return self._rustmap.dropfile(*args, **kwargs)
-
-        def clearambiguoustimes(self, *args, **kwargs):
-            return self._rustmap.clearambiguoustimes(*args, **kwargs)
-
-        def nonnormalentries(self):
-            return self._rustmap.nonnormalentries()
-
-        def get(self, *args, **kwargs):
-            return self._rustmap.get(*args, **kwargs)
-
-        @property
-        def copymap(self):
-            return self._rustmap.copymap()
-
-        def directories(self):
-            return self._rustmap.directories()
-
-        def debug_iter(self):
-            return self._rustmap.debug_iter()
-
-        def preload(self):
-            self._rustmap
-
-        def clear(self):
-            self._rustmap.clear()
-            self.setparents(
-                self._nodeconstants.nullid, self._nodeconstants.nullid
-            )
-            util.clearcachedproperty(self, b"_dirs")
-            util.clearcachedproperty(self, b"_alldirs")
-            util.clearcachedproperty(self, b"dirfoldmap")
-
-        def items(self):
-            return self._rustmap.items()
-
-        def keys(self):
-            return iter(self._rustmap)
-
-        def __contains__(self, key):
-            return key in self._rustmap
-
-        def __getitem__(self, item):
-            return self._rustmap[item]
-
-        def __len__(self):
-            return len(self._rustmap)
-
-        def __iter__(self):
-            return iter(self._rustmap)
-
-        # forward for python2,3 compat
-        iteritems = items
-
-        def _opendirstatefile(self):
-            fp, mode = txnutil.trypending(
-                self._root, self._opener, self._filename
-            )
-            if self._pendingmode is not None and self._pendingmode != mode:
-                fp.close()
-                raise error.Abort(
-                    _(b'working directory state may be changed parallelly')
-                )
-            self._pendingmode = mode
-            return fp
-
-        def _readdirstatefile(self, size=-1):
-            try:
-                with self._opendirstatefile() as fp:
-                    return fp.read(size)
-            except IOError as err:
-                if err.errno != errno.ENOENT:
-                    raise
-                # File doesn't exist, so the current state is empty
-                return b''
-
-        def setparents(self, p1, p2):
-            self._parents = (p1, p2)
-            self._dirtyparents = True
-
-        def parents(self):
-            if not self._parents:
-                if self._use_dirstate_v2:
-                    self._parents = self.docket.parents
-                else:
-                    read_len = self._nodelen * 2
-                    st = self._readdirstatefile(read_len)
-                    l = len(st)
-                    if l == read_len:
-                        self._parents = (
-                            st[: self._nodelen],
-                            st[self._nodelen : 2 * self._nodelen],
-                        )
-                    elif l == 0:
-                        self._parents = (
-                            self._nodeconstants.nullid,
-                            self._nodeconstants.nullid,
-                        )
-                    else:
-                        raise error.Abort(
-                            _(b'working directory state appears damaged!')
-                        )
-
-            return self._parents
-
-        @property
-        def docket(self):
-            if not self._docket:
-                if not self._use_dirstate_v2:
-                    raise error.ProgrammingError(
-                        b'dirstate only has a docket in v2 format'
-                    )
-                self._docket = docketmod.DirstateDocket.parse(
-                    self._readdirstatefile(), self._nodeconstants
-                )
-            return self._docket
+        ### Core data storage and access
 
         @propertycache
-        def _rustmap(self):
+        def _map(self):
             """
             Fills the Dirstatemap when called.
             """
@@ -801,27 +574,91 @@
                     data = self._opener.read(self.docket.data_filename())
                 else:
                     data = b''
-                self._rustmap = rustmod.DirstateMap.new_v2(
+                self._map = rustmod.DirstateMap.new_v2(
                     data, self.docket.data_size, self.docket.tree_metadata
                 )
                 parents = self.docket.parents
             else:
-                self._rustmap, parents = rustmod.DirstateMap.new_v1(
-                    self._use_dirstate_tree, self._readdirstatefile()
+                self._map, parents = rustmod.DirstateMap.new_v1(
+                    self._readdirstatefile()
                 )
 
             if parents and not self._dirtyparents:
                 self.setparents(*parents)
 
-            self.__contains__ = self._rustmap.__contains__
-            self.__getitem__ = self._rustmap.__getitem__
-            self.get = self._rustmap.get
-            return self._rustmap
+            self.__contains__ = self._map.__contains__
+            self.__getitem__ = self._map.__getitem__
+            self.get = self._map.get
+            return self._map
+
+        @property
+        def copymap(self):
+            return self._map.copymap()
+
+        def debug_iter(self, all):
+            """
+            Return an iterator of (filename, state, mode, size, mtime) tuples
+
+            `all`: also include with `state == b' '` dirstate tree nodes that
+            don't have an associated `DirstateItem`.
+
+            """
+            return self._map.debug_iter(all)
+
+        def clear(self):
+            self._map.clear()
+            self.setparents(
+                self._nodeconstants.nullid, self._nodeconstants.nullid
+            )
+            util.clearcachedproperty(self, b"_dirs")
+            util.clearcachedproperty(self, b"_alldirs")
+            util.clearcachedproperty(self, b"dirfoldmap")
+
+        def items(self):
+            return self._map.items()
+
+        # forward for python2,3 compat
+        iteritems = items
+
+        def keys(self):
+            return iter(self._map)
+
+        ### reading/setting parents
+
+        def setparents(self, p1, p2, fold_p2=False):
+            self._parents = (p1, p2)
+            self._dirtyparents = True
+            copies = {}
+            if fold_p2:
+                # Collect into an intermediate list to avoid a `RuntimeError`
+                # exception due to mutation during iteration.
+                # TODO: move this the whole loop to Rust where `iter_mut`
+                # enables in-place mutation of elements of a collection while
+                # iterating it, without mutating the collection itself.
+                files_with_p2_info = [
+                    f for f, s in self._map.items() if s.p2_info
+                ]
+                rust_map = self._map
+                for f in files_with_p2_info:
+                    e = rust_map.get(f)
+                    source = self.copymap.pop(f, None)
+                    if source:
+                        copies[f] = source
+                    e.drop_merge_data()
+                    rust_map.set_dirstate_item(f, e)
+            return copies
+
+        ### disk interaction
+
+        @propertycache
+        def identity(self):
+            self._map
+            return self.identity
 
         def write(self, tr, st, now):
             if not self._use_dirstate_v2:
                 p1, p2 = self.parents()
-                packed = self._rustmap.write_v1(p1, p2, now)
+                packed = self._map.write_v1(p1, p2, now)
                 st.write(packed)
                 st.close()
                 self._dirtyparents = False
@@ -829,7 +666,7 @@
 
             # We can only append to an existing data file if there is one
             can_append = self.docket.uuid is not None
-            packed, meta, append = self._rustmap.write_v2(now, can_append)
+            packed, meta, append = self._map.write_v2(now, can_append)
             if append:
                 docket = self.docket
                 data_filename = docket.data_filename()
@@ -847,79 +684,49 @@
                 st.write(docket.serialize())
                 st.close()
             else:
-                old_docket = self.docket
-                new_docket = docketmod.DirstateDocket.with_new_uuid(
-                    self.parents(), len(packed), meta
-                )
-                data_filename = new_docket.data_filename()
-                if tr:
-                    tr.add(data_filename, 0)
-                self._opener.write(data_filename, packed)
-                # Write the new docket after the new data file has been
-                # written. Because `st` was opened with `atomictemp=True`,
-                # the actual `.hg/dirstate` file is only affected on close.
-                st.write(new_docket.serialize())
-                st.close()
-                # Remove the old data file after the new docket pointing to
-                # the new data file was written.
-                if old_docket.uuid:
-                    data_filename = old_docket.data_filename()
-                    unlink = lambda _tr=None: self._opener.unlink(data_filename)
-                    if tr:
-                        category = b"dirstate-v2-clean-" + old_docket.uuid
-                        tr.addpostclose(category, unlink)
-                    else:
-                        unlink()
-                self._docket = new_docket
+                self.write_v2_no_append(tr, st, meta, packed)
             # Reload from the newly-written file
-            util.clearcachedproperty(self, b"_rustmap")
+            util.clearcachedproperty(self, b"_map")
             self._dirtyparents = False
 
+        ### code related to maintaining and accessing "extra" property
+        # (e.g. "has_dir")
+
         @propertycache
         def filefoldmap(self):
             """Returns a dictionary mapping normalized case paths to their
             non-normalized versions.
             """
-            return self._rustmap.filefoldmapasdict()
+            return self._map.filefoldmapasdict()
 
         def hastrackeddir(self, d):
-            return self._rustmap.hastrackeddir(d)
+            return self._map.hastrackeddir(d)
 
         def hasdir(self, d):
-            return self._rustmap.hasdir(d)
-
-        @propertycache
-        def identity(self):
-            self._rustmap
-            return self.identity
-
-        @property
-        def nonnormalset(self):
-            nonnorm = self._rustmap.non_normal_entries()
-            return nonnorm
-
-        @propertycache
-        def otherparentset(self):
-            otherparents = self._rustmap.other_parent_entries()
-            return otherparents
-
-        def non_normal_or_other_parent_paths(self):
-            return self._rustmap.non_normal_or_other_parent_paths()
+            return self._map.hasdir(d)
 
         @propertycache
         def dirfoldmap(self):
             f = {}
             normcase = util.normcase
-            for name in self._rustmap.tracked_dirs():
+            for name in self._map.tracked_dirs():
                 f[normcase(name)] = name
             return f
 
-        def set_possibly_dirty(self, filename):
-            """record that the current state of the file on disk is unknown"""
-            entry = self[filename]
-            entry.set_possibly_dirty()
-            self._rustmap.set_v1(filename, entry)
+        ### code related to manipulation of entries and copy-sources
+
+        def _refresh_entry(self, f, entry):
+            if not entry.any_tracked:
+                self._map.drop_item_and_copy_source(f)
+            else:
+                self._map.addfile(f, entry)
+
+        def _insert_entry(self, f, entry):
+            self._map.addfile(f, entry)
+
+        def _drop_entry(self, f):
+            self._map.drop_item_and_copy_source(f)
 
         def __setitem__(self, key, value):
             assert isinstance(value, DirstateItem)
-            self._rustmap.set_v1(key, value)
+            self._map.set_dirstate_item(key, value)
--- a/mercurial/dirstateutils/docket.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/dirstateutils/docket.py	Thu Oct 21 17:25:41 2021 +0200
@@ -10,31 +10,27 @@
 import struct
 
 from ..revlogutils import docket as docket_mod
-
+from . import v2
 
 V2_FORMAT_MARKER = b"dirstate-v2\n"
 
-# Must match the constant of the same name in
-# `rust/hg-core/src/dirstate_tree/on_disk.rs`
-TREE_METADATA_SIZE = 44
-
 # * 12 bytes: format marker
 # * 32 bytes: node ID of the working directory's first parent
 # * 32 bytes: node ID of the working directory's second parent
+# * {TREE_METADATA_SIZE} bytes: tree metadata, parsed separately
 # * 4 bytes: big-endian used size of the data file
-# * {TREE_METADATA_SIZE} bytes: tree metadata, parsed separately
 # * 1 byte: length of the data file's UUID
 # * variable: data file's UUID
 #
 # Node IDs are null-padded if shorter than 32 bytes.
 # A data file shorter than the specified used size is corrupted (truncated)
 HEADER = struct.Struct(
-    ">{}s32s32sL{}sB".format(len(V2_FORMAT_MARKER), TREE_METADATA_SIZE)
+    ">{}s32s32s{}sLB".format(len(V2_FORMAT_MARKER), v2.TREE_METADATA_SIZE)
 )
 
 
 class DirstateDocket(object):
-    data_filename_pattern = b'dirstate.%s.d'
+    data_filename_pattern = b'dirstate.%s'
 
     def __init__(self, parents, data_size, tree_metadata, uuid):
         self.parents = parents
@@ -51,7 +47,7 @@
         if not data:
             parents = (nodeconstants.nullid, nodeconstants.nullid)
             return cls(parents, 0, b'', None)
-        marker, p1, p2, data_size, meta, uuid_size = HEADER.unpack_from(data)
+        marker, p1, p2, meta, data_size, uuid_size = HEADER.unpack_from(data)
         if marker != V2_FORMAT_MARKER:
             raise ValueError("expected dirstate-v2 marker")
         uuid = data[HEADER.size : HEADER.size + uuid_size]
@@ -65,8 +61,8 @@
             V2_FORMAT_MARKER,
             p1,
             p2,
+            self.tree_metadata,
             self.data_size,
-            self.tree_metadata,
             len(self.uuid),
         )
         return header + self.uuid
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mercurial/dirstateutils/timestamp.py	Thu Oct 21 17:25:41 2021 +0200
@@ -0,0 +1,87 @@
+# Copyright Mercurial Contributors
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2 or any later version.
+
+from __future__ import absolute_import
+
+import functools
+import stat
+
+
+rangemask = 0x7FFFFFFF
+
+
+@functools.total_ordering
+class timestamp(tuple):
+    """
+    A Unix timestamp with optional nanoseconds precision,
+    modulo 2**31 seconds.
+
+    A 2-tuple containing:
+
+    `truncated_seconds`: seconds since the Unix epoch,
+    truncated to its lower 31 bits
+
+    `subsecond_nanoseconds`: number of nanoseconds since `truncated_seconds`.
+    When this is zero, the sub-second precision is considered unknown.
+    """
+
+    def __new__(cls, value):
+        truncated_seconds, subsec_nanos = value
+        value = (truncated_seconds & rangemask, subsec_nanos)
+        return super(timestamp, cls).__new__(cls, value)
+
+    def __eq__(self, other):
+        self_secs, self_subsec_nanos = self
+        other_secs, other_subsec_nanos = other
+        return self_secs == other_secs and (
+            self_subsec_nanos == other_subsec_nanos
+            or self_subsec_nanos == 0
+            or other_subsec_nanos == 0
+        )
+
+    def __gt__(self, other):
+        self_secs, self_subsec_nanos = self
+        other_secs, other_subsec_nanos = other
+        if self_secs > other_secs:
+            return True
+        if self_secs < other_secs:
+            return False
+        if self_subsec_nanos == 0 or other_subsec_nanos == 0:
+            # they are considered equal, so not "greater than"
+            return False
+        return self_subsec_nanos > other_subsec_nanos
+
+
+def zero():
+    """
+    Returns the `timestamp` at the Unix epoch.
+    """
+    return tuple.__new__(timestamp, (0, 0))
+
+
+def mtime_of(stat_result):
+    """
+    Takes an `os.stat_result`-like object and returns a `timestamp` object
+    for its modification time.
+    """
+    try:
+        # TODO: add this attribute to `osutil.stat` objects,
+        # see `mercurial/cext/osutil.c`.
+        #
+        # This attribute is also not available on Python 2.
+        nanos = stat_result.st_mtime_ns
+    except AttributeError:
+        # https://docs.python.org/2/library/os.html#os.stat_float_times
+        # "For compatibility with older Python versions,
+        #  accessing stat_result as a tuple always returns integers."
+        secs = stat_result[stat.ST_MTIME]
+
+        subsec_nanos = 0
+    else:
+        billion = int(1e9)
+        secs = nanos // billion
+        subsec_nanos = nanos % billion
+
+    return timestamp((secs, subsec_nanos))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mercurial/dirstateutils/v2.py	Thu Oct 21 17:25:41 2021 +0200
@@ -0,0 +1,414 @@
+# v2.py - Pure-Python implementation of the dirstate-v2 file format
+#
+# Copyright Mercurial Contributors
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2 or any later version.
+
+from __future__ import absolute_import
+
+import struct
+
+from ..thirdparty import attr
+from .. import error, policy
+
+parsers = policy.importmod('parsers')
+
+
+# Must match the constant of the same name in
+# `rust/hg-core/src/dirstate_tree/on_disk.rs`
+TREE_METADATA_SIZE = 44
+NODE_SIZE = 44
+
+
+# Must match the `TreeMetadata` Rust struct in
+# `rust/hg-core/src/dirstate_tree/on_disk.rs`. See doc-comments there.
+#
+# * 4 bytes: start offset of root nodes
+# * 4 bytes: number of root nodes
+# * 4 bytes: total number of nodes in the tree that have an entry
+# * 4 bytes: total number of nodes in the tree that have a copy source
+# * 4 bytes: number of bytes in the data file that are not used anymore
+# * 4 bytes: unused
+# * 20 bytes: SHA-1 hash of ignore patterns
+TREE_METADATA = struct.Struct('>LLLLL4s20s')
+
+
+# Must match the `Node` Rust struct in
+# `rust/hg-core/src/dirstate_tree/on_disk.rs`. See doc-comments there.
+#
+# * 4 bytes: start offset of full path
+# * 2 bytes: length of the full path
+# * 2 bytes: length within the full path before its "base name"
+# * 4 bytes: start offset of the copy source if any, or zero for no copy source
+# * 2 bytes: length of the copy source if any, or unused
+# * 4 bytes: start offset of child nodes
+# * 4 bytes: number of child nodes
+# * 4 bytes: number of descendant nodes that have an entry
+# * 4 bytes: number of descendant nodes that have a "tracked" state
+# * 1 byte: flags
+# * 4 bytes: expected size
+# * 4 bytes: mtime seconds
+# * 4 bytes: mtime nanoseconds
+NODE = struct.Struct('>LHHLHLLLLHlll')
+
+
+assert TREE_METADATA_SIZE == TREE_METADATA.size
+assert NODE_SIZE == NODE.size
+
+# match constant in mercurial/pure/parsers.py
+DIRSTATE_V2_DIRECTORY = 1 << 5
+
+
+def parse_dirstate(map, copy_map, data, tree_metadata):
+    """parse a full v2-dirstate from a binary data into dictionnaries:
+
+    - map: a {path: entry} mapping that will be filled
+    - copy_map: a {path: copy-source} mapping that will be filled
+    - data: a binary blob contains v2 nodes data
+    - tree_metadata:: a binary blob of the top level node (from the docket)
+    """
+    (
+        root_nodes_start,
+        root_nodes_len,
+        _nodes_with_entry_count,
+        _nodes_with_copy_source_count,
+        _unreachable_bytes,
+        _unused,
+        _ignore_patterns_hash,
+    ) = TREE_METADATA.unpack(tree_metadata)
+    parse_nodes(map, copy_map, data, root_nodes_start, root_nodes_len)
+
+
+def parse_nodes(map, copy_map, data, start, len):
+    """parse <len> nodes from <data> starting at offset <start>
+
+    This is used by parse_dirstate to recursively fill `map` and `copy_map`.
+
+    All directory specific information is ignored and do not need any
+    processing (DIRECTORY, ALL_UNKNOWN_RECORDED, ALL_IGNORED_RECORDED)
+    """
+    for i in range(len):
+        node_start = start + NODE_SIZE * i
+        node_bytes = slice_with_len(data, node_start, NODE_SIZE)
+        (
+            path_start,
+            path_len,
+            _basename_start,
+            copy_source_start,
+            copy_source_len,
+            children_start,
+            children_count,
+            _descendants_with_entry_count,
+            _tracked_descendants_count,
+            flags,
+            size,
+            mtime_s,
+            mtime_ns,
+        ) = NODE.unpack(node_bytes)
+
+        # Parse child nodes of this node recursively
+        parse_nodes(map, copy_map, data, children_start, children_count)
+
+        item = parsers.DirstateItem.from_v2_data(flags, size, mtime_s, mtime_ns)
+        if not item.any_tracked:
+            continue
+        path = slice_with_len(data, path_start, path_len)
+        map[path] = item
+        if copy_source_start:
+            copy_map[path] = slice_with_len(
+                data, copy_source_start, copy_source_len
+            )
+
+
+def slice_with_len(data, start, len):
+    return data[start : start + len]
+
+
+@attr.s
+class Node(object):
+    path = attr.ib()
+    entry = attr.ib()
+    parent = attr.ib(default=None)
+    children_count = attr.ib(default=0)
+    children_offset = attr.ib(default=0)
+    descendants_with_entry = attr.ib(default=0)
+    tracked_descendants = attr.ib(default=0)
+
+    def pack(self, copy_map, paths_offset):
+        path = self.path
+        copy = copy_map.get(path)
+        entry = self.entry
+
+        path_start = paths_offset
+        path_len = len(path)
+        basename_start = path.rfind(b'/') + 1  # 0 if rfind returns -1
+        if copy is not None:
+            copy_source_start = paths_offset + len(path)
+            copy_source_len = len(copy)
+        else:
+            copy_source_start = 0
+            copy_source_len = 0
+        if entry is not None:
+            flags, size, mtime_s, mtime_ns = entry.v2_data()
+        else:
+            # There are no mtime-cached directories in the Python implementation
+            flags = DIRSTATE_V2_DIRECTORY
+            size = 0
+            mtime_s = 0
+            mtime_ns = 0
+        return NODE.pack(
+            path_start,
+            path_len,
+            basename_start,
+            copy_source_start,
+            copy_source_len,
+            self.children_offset,
+            self.children_count,
+            self.descendants_with_entry,
+            self.tracked_descendants,
+            flags,
+            size,
+            mtime_s,
+            mtime_ns,
+        )
+
+
+def pack_dirstate(map, copy_map, now):
+    """
+    Pack `map` and `copy_map` into the dirstate v2 binary format and return
+    the bytearray.
+    `now` is a timestamp of the current filesystem time used to detect race
+    conditions in writing the dirstate to disk, see inline comment.
+
+    The on-disk format expects a tree-like structure where the leaves are
+    written first (and sorted per-directory), going up levels until the root
+    node and writing that one to the docket. See more details on the on-disk
+    format in `mercurial/helptext/internals/dirstate-v2`.
+
+    Since both `map` and `copy_map` are flat dicts we need to figure out the
+    hierarchy. This algorithm does so without having to build the entire tree
+    in-memory: it only keeps the minimum number of nodes around to satisfy the
+    format.
+
+    # Algorithm explanation
+
+    This explanation does not talk about the different counters for tracked
+    descendents and storing the copies, but that work is pretty simple once this
+    algorithm is in place.
+
+    ## Building a subtree
+
+    First, sort `map`: this makes it so the leaves of the tree are contiguous
+    per directory (i.e. a/b/c and a/b/d will be next to each other in the list),
+    and enables us to use the ordering of folders to have a "cursor" of the
+    current folder we're in without ever going twice in the same branch of the
+    tree. The cursor is a node that remembers its parent and any information
+    relevant to the format (see the `Node` class), building the relevant part
+    of the tree lazily.
+    Then, for each file in `map`, move the cursor into the tree to the
+    corresponding folder of the file: for example, if the very first file
+    is "a/b/c", we start from `Node[""]`, create `Node["a"]` which points to
+    its parent `Node[""]`, then create `Node["a/b"]`, which points to its parent
+    `Node["a"]`. These nodes are kept around in a stack.
+    If the next file in `map` is in the same subtree ("a/b/d" or "a/b/e/f"), we
+    add it to the stack and keep looping with the same logic of creating the
+    tree nodes as needed. If however the next file in `map` is *not* in the same
+    subtree ("a/other", if we're still in the "a/b" folder), then we know that
+    the subtree we're in is complete.
+
+    ## Writing the subtree
+
+    We have the entire subtree in the stack, so we start writing it to disk
+    folder by folder. The way we write a folder is to pop the stack into a list
+    until the folder changes, revert this list of direct children (to satisfy
+    the format requirement that children be sorted). This process repeats until
+    we hit the "other" subtree.
+
+    An example:
+        a
+        dir1/b
+        dir1/c
+        dir2/dir3/d
+        dir2/dir3/e
+        dir2/f
+
+    Would have us:
+        - add to the stack until "dir2/dir3/e"
+        - realize that "dir2/f" is in a different subtree
+        - pop "dir2/dir3/e", "dir2/dir3/d", reverse them so they're sorted and
+          pack them since the next entry is "dir2/dir3"
+        - go back up to "dir2"
+        - add "dir2/f" to the stack
+        - realize we're done with the map
+        - pop "dir2/f", "dir2/dir3" from the stack, reverse and pack them
+        - go up to the root node, do the same to write "a", "dir1" and "dir2" in
+          that order
+
+    ## Special case for the root node
+
+    The root node is not serialized in the format, but its information is
+    written to the docket. Again, see more details on the on-disk format in
+    `mercurial/helptext/internals/dirstate-v2`.
+    """
+    data = bytearray()
+    root_nodes_start = 0
+    root_nodes_len = 0
+    nodes_with_entry_count = 0
+    nodes_with_copy_source_count = 0
+    # Will always be 0 since this implementation always re-writes everything
+    # to disk
+    unreachable_bytes = 0
+    unused = b'\x00' * 4
+    # This is an optimization that's only useful for the Rust implementation
+    ignore_patterns_hash = b'\x00' * 20
+
+    if len(map) == 0:
+        tree_metadata = TREE_METADATA.pack(
+            root_nodes_start,
+            root_nodes_len,
+            nodes_with_entry_count,
+            nodes_with_copy_source_count,
+            unreachable_bytes,
+            unused,
+            ignore_patterns_hash,
+        )
+        return data, tree_metadata
+
+    sorted_map = sorted(map.items(), key=lambda x: x[0])
+
+    # Use a stack to not have to only remember the nodes we currently need
+    # instead of building the entire tree in memory
+    stack = []
+    current_node = Node(b"", None)
+    stack.append(current_node)
+
+    for index, (path, entry) in enumerate(sorted_map, 1):
+        if entry.need_delay(now):
+            # The file was last modified "simultaneously" with the current
+            # write to dirstate (i.e. within the same second for file-
+            # systems with a granularity of 1 sec). This commonly happens
+            # for at least a couple of files on 'update'.
+            # The user could change the file without changing its size
+            # within the same second. Invalidate the file's mtime in
+            # dirstate, forcing future 'status' calls to compare the
+            # contents of the file if the size is the same. This prevents
+            # mistakenly treating such files as clean.
+            entry.set_possibly_dirty()
+        nodes_with_entry_count += 1
+        if path in copy_map:
+            nodes_with_copy_source_count += 1
+        current_folder = get_folder(path)
+        current_node = move_to_correct_node_in_tree(
+            current_folder, current_node, stack
+        )
+
+        current_node.children_count += 1
+        # Entries from `map` are never `None`
+        if entry.tracked:
+            current_node.tracked_descendants += 1
+        current_node.descendants_with_entry += 1
+        stack.append(Node(path, entry, current_node))
+
+        should_pack = True
+        next_path = None
+        if index < len(sorted_map):
+            # Determine if the next entry is in the same sub-tree, if so don't
+            # pack yet
+            next_path = sorted_map[index][0]
+            should_pack = not get_folder(next_path).startswith(current_folder)
+        if should_pack:
+            pack_directory_children(current_node, copy_map, data, stack)
+            while stack and current_node.path != b"":
+                # Go up the tree and write until we reach the folder of the next
+                # entry (if any, otherwise the root)
+                parent = current_node.parent
+                in_parent_folder_of_next_entry = next_path is not None and (
+                    get_folder(next_path).startswith(get_folder(stack[-1].path))
+                )
+                if parent is None or in_parent_folder_of_next_entry:
+                    break
+                pack_directory_children(parent, copy_map, data, stack)
+                current_node = parent
+
+    # Special case for the root node since we don't write it to disk, only its
+    # children to the docket
+    current_node = stack.pop()
+    assert current_node.path == b"", current_node.path
+    assert len(stack) == 0, len(stack)
+
+    tree_metadata = TREE_METADATA.pack(
+        current_node.children_offset,
+        current_node.children_count,
+        nodes_with_entry_count,
+        nodes_with_copy_source_count,
+        unreachable_bytes,
+        unused,
+        ignore_patterns_hash,
+    )
+
+    return data, tree_metadata
+
+
+def get_folder(path):
+    """
+    Return the folder of the path that's given, an empty string for root paths.
+    """
+    return path.rsplit(b'/', 1)[0] if b'/' in path else b''
+
+
+def move_to_correct_node_in_tree(target_folder, current_node, stack):
+    """
+    Move inside the dirstate node tree to the node corresponding to
+    `target_folder`, creating the missing nodes along the way if needed.
+    """
+    while target_folder != current_node.path:
+        if target_folder.startswith(current_node.path):
+            # We need to go down a folder
+            prefix = target_folder[len(current_node.path) :].lstrip(b'/')
+            subfolder_name = prefix.split(b'/', 1)[0]
+            if current_node.path:
+                subfolder_path = current_node.path + b'/' + subfolder_name
+            else:
+                subfolder_path = subfolder_name
+            next_node = stack[-1]
+            if next_node.path == target_folder:
+                # This folder is now a file and only contains removed entries
+                # merge with the last node
+                current_node = next_node
+            else:
+                current_node.children_count += 1
+                current_node = Node(subfolder_path, None, current_node)
+                stack.append(current_node)
+        else:
+            # We need to go up a folder
+            current_node = current_node.parent
+    return current_node
+
+
+def pack_directory_children(node, copy_map, data, stack):
+    """
+    Write the binary representation of the direct sorted children of `node` to
+    `data`
+    """
+    direct_children = []
+
+    while stack[-1].path != b"" and get_folder(stack[-1].path) == node.path:
+        direct_children.append(stack.pop())
+    if not direct_children:
+        raise error.ProgrammingError(b"no direct children for %r" % node.path)
+
+    # Reverse the stack to get the correct sorted order
+    direct_children.reverse()
+    packed_children = bytearray()
+    # Write the paths to `data`. Pack child nodes but don't write them yet
+    for child in direct_children:
+        packed = child.pack(copy_map=copy_map, paths_offset=len(data))
+        packed_children.extend(packed)
+        data.extend(child.path)
+        data.extend(copy_map.get(child.path, b""))
+        node.tracked_descendants += child.tracked_descendants
+        node.descendants_with_entry += child.descendants_with_entry
+    # Write the fixed-size child nodes all together
+    node.children_offset = len(data)
+    data.extend(packed_children)
--- a/mercurial/dispatch.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/dispatch.py	Thu Oct 21 17:25:41 2021 +0200
@@ -253,7 +253,7 @@
         status = -1
 
     ret = _flushstdio(req.ui, err)
-    if ret:
+    if ret and not status:
         status = ret
     return status
 
--- a/mercurial/encoding.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/encoding.py	Thu Oct 21 17:25:41 2021 +0200
@@ -240,7 +240,9 @@
             b"decoding near '%s': %s!" % (sub, pycompat.bytestr(inst))
         )
     except LookupError as k:
-        raise error.Abort(k, hint=b"please check your locale settings")
+        raise error.Abort(
+            pycompat.bytestr(k), hint=b"please check your locale settings"
+        )
 
 
 def unitolocal(u):
@@ -306,7 +308,9 @@
     except UnicodeError:
         return s.lower()  # we don't know how to fold this except in ASCII
     except LookupError as k:
-        raise error.Abort(k, hint=b"please check your locale settings")
+        raise error.Abort(
+            pycompat.bytestr(k), hint=b"please check your locale settings"
+        )
 
 
 def upper(s):
@@ -333,7 +337,9 @@
     except UnicodeError:
         return s.upper()  # we don't know how to fold this except in ASCII
     except LookupError as k:
-        raise error.Abort(k, hint=b"please check your locale settings")
+        raise error.Abort(
+            pycompat.bytestr(k), hint=b"please check your locale settings"
+        )
 
 
 if not _nativeenviron:
--- a/mercurial/exchange.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/exchange.py	Thu Oct 21 17:25:41 2021 +0200
@@ -1378,6 +1378,7 @@
         self,
         repo,
         remote,
+        path,
         heads=None,
         force=False,
         bookmarks=(),
@@ -1391,6 +1392,10 @@
         self.repo = repo
         # repo we pull from
         self.remote = remote
+        # path object used to build this remote
+        #
+        # Ideally, the remote peer would carry that directly.
+        self.remote_path = path
         # revision we try to pull (None is "all")
         self.heads = heads
         # bookmark pulled explicitly
@@ -1556,6 +1561,7 @@
 def pull(
     repo,
     remote,
+    path=None,
     heads=None,
     force=False,
     bookmarks=(),
@@ -1611,8 +1617,9 @@
     pullop = pulloperation(
         repo,
         remote,
-        heads,
-        force,
+        path=path,
+        heads=heads,
+        force=force,
         bookmarks=bookmarks,
         streamclonerequested=streamclonerequested,
         includepats=includepats,
@@ -2021,6 +2028,9 @@
     pullop.stepsdone.add(b'bookmarks')
     repo = pullop.repo
     remotebookmarks = pullop.remotebookmarks
+    bookmarks_mode = None
+    if pullop.remote_path is not None:
+        bookmarks_mode = pullop.remote_path.bookmarks_mode
     bookmod.updatefromremote(
         repo.ui,
         repo,
@@ -2028,6 +2038,7 @@
         pullop.remote.url(),
         pullop.gettransaction,
         explicit=pullop.explicitbookmarks,
+        mode=bookmarks_mode,
     )
 
 
--- a/mercurial/exewrapper.c	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/exewrapper.c	Thu Oct 21 17:25:41 2021 +0200
@@ -29,6 +29,134 @@
 #define _countof(array) (sizeof(array) / sizeof(array[0]))
 #endif
 
+#if PY_MAJOR_VERSION >= 3
+
+#pragma comment(lib, "Advapi32.lib")
+
+/* python.org installations */
+#define CORE_PATH L"SOFTWARE\\Python\\PythonCore"
+
+/* Microsoft Store installations */
+#define LOOKASIDE_PATH                                                         \
+	L"SOFTWARE\\Microsoft\\AppModel\\Lookaside\\user\\Software\\Python\\"  \
+	L"PythonCore"
+
+static wchar_t *_locate_python_for_key(HKEY root, LPCWSTR subkey, size_t *size)
+{
+	wchar_t installPathKey[512];
+	wchar_t *executable = NULL;
+	DWORD type;
+	DWORD sz = 0;
+	HKEY ip_key;
+	LSTATUS status;
+
+	_snwprintf_s(installPathKey, sizeof(installPathKey), _TRUNCATE,
+	             L"%ls\\%d.%d\\InstallPath", subkey, PY_MAJOR_VERSION,
+	             PY_MINOR_VERSION);
+
+	status =
+	    RegOpenKeyExW(root, installPathKey, 0, KEY_QUERY_VALUE, &ip_key);
+
+	if (status != ERROR_SUCCESS)
+		return NULL;
+
+	status = RegQueryValueExW(ip_key, L"ExecutablePath", NULL, &type,
+	                          (LPBYTE)executable, &sz);
+	if (status == ERROR_SUCCESS) {
+		/* Allocate extra space so path\to\python.exe can be converted
+		 * to path\to\python39.dll + NUL.
+		 */
+		*size = sz + sizeof(_T(HGPYTHONLIB ".dll")) + sizeof(wchar_t);
+		executable = malloc(*size);
+
+		if (executable) {
+			status =
+			    RegQueryValueExW(ip_key, L"ExecutablePath", NULL,
+			                     &type, (LPBYTE)executable, &sz);
+
+			if (status != ERROR_SUCCESS) {
+				free(executable);
+				executable = NULL;
+			} else {
+				/* Not all values are stored NUL terminated */
+				executable[sz] = L'\0';
+			}
+		}
+	}
+
+	RegCloseKey(ip_key);
+	return executable;
+}
+
+static HMODULE load_system_py3(void)
+{
+	wchar_t *subkeys[] = {CORE_PATH, LOOKASIDE_PATH};
+	HKEY roots[] = {HKEY_LOCAL_MACHINE, HKEY_CURRENT_USER};
+
+	/* Give priority to python.org installs, because MS Store installs can
+	 * break with user profile corruption, and also use some NTFS feature
+	 * that MSYS doesn't understand.
+	 */
+	for (int s = 0; s < _countof(subkeys); s++) {
+		for (int r = 0; r < _countof(roots); r++) {
+			size_t size = 0;
+			wchar_t *py =
+			    _locate_python_for_key(roots[r], subkeys[s], &size);
+			wchar_t *cut = NULL;
+			HMODULE pydll;
+
+			if (!py) {
+				continue;
+			}
+
+			/* Cut off the python executable component */
+			cut = wcsrchr(py, L'\\');
+			if (cut == NULL) {
+				free(py);
+				continue;
+			}
+			*cut = 0;
+
+			wcscat_s(py, size, _T("\\" HGPYTHONLIB ".dll"));
+
+			pydll = LoadLibrary(py);
+
+			/* Also load python3.dll, so we don't pick up a random
+			 * one on PATH. We don't search {sys.prefix}\DLLs like
+			 * python.exe because this is commented as "not been a
+			 * normal install layout for a while", and don't search
+			 * LOAD_LIBRARY_SEARCH_APPLICATION_DIR because it's not
+			 * clear what the use case is.
+			 */
+			if (pydll != NULL) {
+				HANDLE py3dll = NULL;
+
+				*cut = 0;
+				wcscat_s(py, size, L"\\python3.dll");
+
+				py3dll = LoadLibrary(py);
+				if (py3dll == NULL) {
+					const char *err;
+					err = "failed to load python3.dll "
+					      "for " HGPYTHONLIB ".dll";
+					fprintf(stderr, "abort: %s (0x%X)\n",
+					        err, GetLastError());
+					exit(255);
+				}
+			}
+
+			free(py);
+
+			if (pydll != NULL) {
+				return pydll;
+			}
+		}
+	}
+
+	return NULL;
+}
+#endif
+
 static TCHAR pyscript[MAX_PATH + 10];
 static TCHAR pyhome[MAX_PATH + 10];
 static TCHAR pydllfile[MAX_PATH + 10];
@@ -108,6 +236,12 @@
 		Py_SetPythonHome(pyhome);
 	}
 
+#if PY_MAJOR_VERSION >= 3
+	if (pydll == NULL) {
+		pydll = load_system_py3();
+	}
+#endif
+
 	if (pydll == NULL) {
 		pydll = LoadLibrary(_T(HGPYTHONLIB) _T(".dll"));
 		if (pydll == NULL) {
--- a/mercurial/extensions.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/extensions.py	Thu Oct 21 17:25:41 2021 +0200
@@ -224,8 +224,12 @@
     minver = getattr(mod, 'minimumhgversion', None)
     if minver:
         curver = util.versiontuple(n=2)
+        extmin = util.versiontuple(stringutil.forcebytestr(minver), 2)
 
-        if None in curver or util.versiontuple(minver, 2) > curver:
+        if None in extmin:
+            extmin = (extmin[0] or 0, extmin[1] or 0)
+
+        if None in curver or extmin > curver:
             msg = _(
                 b'(third party extension %s requires version %s or newer '
                 b'of Mercurial (current: %s); disabling)\n'
--- a/mercurial/help.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/help.py	Thu Oct 21 17:25:41 2021 +0200
@@ -365,6 +365,11 @@
             loaddoc(b'config', subdir=b'internals'),
         ),
         (
+            [b'dirstate-v2'],
+            _(b'dirstate-v2 file format'),
+            loaddoc(b'dirstate-v2', subdir=b'internals'),
+        ),
+        (
             [b'extensions', b'extension'],
             _(b'Extension API'),
             loaddoc(b'extensions', subdir=b'internals'),
--- a/mercurial/helptext/config.txt	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/helptext/config.txt	Thu Oct 21 17:25:41 2021 +0200
@@ -1748,6 +1748,18 @@
    Revsets specifying bookmarks will not result in the bookmark being
    pushed.
 
+``bookmarks.mode``
+  How bookmark will be dealt during the exchange. It support the following value
+
+  - ``default``: the default behavior, local and remote bookmarks are "merged"
+    on push/pull.
+
+  - ``mirror``: when pulling, replace local bookmarks by remote bookmarks. This
+    is useful to replicate a repository, or as an optimization.
+
+  - ``ignore``: ignore bookmarks during exchange.
+    (This currently only affect pulling)
+
 The following special named paths exist:
 
 ``default``
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mercurial/helptext/internals/dirstate-v2.txt	Thu Oct 21 17:25:41 2021 +0200
@@ -0,0 +1,610 @@
+The *dirstate* is what Mercurial uses internally to track
+the state of files in the working directory,
+such as set by commands like `hg add` and `hg rm`.
+It also contains some cached data that help make `hg status` faster.
+The name refers both to `.hg/dirstate` on the filesystem
+and the corresponding data structure in memory while a Mercurial process
+is running.
+
+The original file format, retroactively dubbed `dirstate-v1`,
+is described at https://www.mercurial-scm.org/wiki/DirState.
+It is made of a flat sequence of unordered variable-size entries,
+so accessing any information in it requires parsing all of it.
+Similarly, saving changes requires rewriting the entire file.
+
+The newer `dirsate-v2` file format is designed to fix these limitations
+and make `hg status` faster.
+
+User guide
+==========
+
+Compatibility
+-------------
+
+The file format is experimental and may still change.
+Different versions of Mercurial may not be compatible with each other
+when working on a local repository that uses this format.
+When using an incompatible version with the experimental format,
+anything can happen including data corruption.
+
+Since the dirstate is entirely local and not relevant to the wire protocol,
+`dirstate-v2` does not affect compatibility with remote Mercurial versions.
+
+When `share-safe` is enabled, different repositories sharing the same store
+can use different dirstate formats.
+
+Enabling `dirsate-v2` for new local repositories
+------------------------------------------------
+
+When creating a new local repository such as with `hg init` or `hg clone`,
+the `exp-dirstate-v2` boolean in the `format` configuration section
+controls whether to use this file format.
+This is disabled by default as of this writing.
+To enable it for a single repository, run for example::
+
+    $ hg init my-project --config format.exp-dirstate-v2=1
+
+Checking the format of an existing local repsitory
+--------------------------------------------------
+
+The `debugformat` commands prints information about
+which of multiple optional formats are used in the current repository,
+including `dirstate-v2`::
+
+    $ hg debugformat
+    format-variant     repo
+    fncache:            yes
+    dirstate-v2:        yes
+    […]
+
+Upgrading or downgrading an existing local repository
+-----------------------------------------------------
+
+The `debugupgrade` command does various upgrades or downgrades
+on a local repository
+based on the current Mercurial version and on configuration.
+The same `format.exp-dirstate-v2` configuration is used again.
+
+Example to upgrade::
+
+    $ hg debugupgrade --config format.exp-dirstate-v2=1
+
+Example to downgrade to `dirstate-v1`::
+
+    $ hg debugupgrade --config format.exp-dirstate-v2=0
+
+Both of this commands do nothing but print a list of proposed changes,
+which may include changes unrelated to the dirstate.
+Those other changes are controlled by their own configuration keys.
+Add `--run` to a command to actually apply the proposed changes.
+
+Backups of `.hg/requires` and `.hg/dirstate` are created
+in a `.hg/upgradebackup.*` directory.
+If something goes wrong, restoring those files should undo the change.
+
+Note that upgrading affects compatibility with older versions of Mercurial
+as noted above.
+This can be relevant when a repository’s files are on a USB drive
+or some other removable media, or shared over the network, etc.
+
+Internal filesystem representation
+==================================
+
+Requirements file
+-----------------
+
+The `.hg/requires` file indicates which of various optional file formats
+are used by a given repository.
+Mercurial aborts when seeing a requirement it does not know about,
+which avoids older version accidentally messing up a respository
+that uses a format that was introduced later.
+For versions that do support a format, the presence or absence of
+the corresponding requirement indicates whether to use that format.
+
+When the file contains a `exp-dirstate-v2` line,
+the `dirstate-v2` format is used.
+With no such line `dirstate-v1` is used.
+
+High level description
+----------------------
+
+Whereas `dirstate-v1` uses a single `.hg/disrtate` file,
+in `dirstate-v2` that file is a "docket" file
+that only contains some metadata
+and points to separate data file named `.hg/dirstate.{ID}`,
+where `{ID}` is a random identifier.
+
+This separation allows making data files append-only
+and therefore safer to memory-map.
+Creating a new data file (occasionally to clean up unused data)
+can be done with a different ID
+without disrupting another Mercurial process
+that could still be using the previous data file.
+
+Both files have a format designed to reduce the need for parsing,
+by using fixed-size binary components as much as possible.
+For data that is not fixed-size,
+references to other parts of a file can be made by storing "pseudo-pointers":
+integers counted in bytes from the start of a file.
+For read-only access no data structure is needed,
+only a bytes buffer (possibly memory-mapped directly from the filesystem)
+with specific parts read on demand.
+
+The data file contains "nodes" organized in a tree.
+Each node represents a file or directory inside the working directory
+or its parent changeset.
+This tree has the same structure as the filesystem,
+so a node representing a directory has child nodes representing
+the files and subdirectories contained directly in that directory.
+
+The docket file format
+----------------------
+
+This is implemented in `rust/hg-core/src/dirstate_tree/on_disk.rs`
+and `mercurial/dirstateutils/docket.py`.
+
+Components of the docket file are found at fixed offsets,
+counted in bytes from the start of the file:
+
+* Offset 0:
+  The 12-bytes marker string "dirstate-v2\n" ending with a newline character.
+  This makes it easier to tell a dirstate-v2 file from a dirstate-v1 file,
+  although it is not strictly necessary
+  since `.hg/requires` determines which format to use.
+
+* Offset 12:
+  The changeset node ID on the first parent of the working directory,
+  as up to 32 binary bytes.
+  If a node ID is shorter (20 bytes for SHA-1),
+  it is start-aligned and the rest of the bytes are set to zero.
+
+* Offset 44:
+  The changeset node ID on the second parent of the working directory,
+  or all zeros if there isn’t one.
+  Also 32 binary bytes.
+
+* Offset 76:
+  Tree metadata on 44 bytes, described below.
+  Its separation in this documentation from the rest of the docket
+  reflects a detail of the current implementation.
+  Since tree metadata is also made of fields at fixed offsets, those could
+  be inlined here by adding 76 bytes to each offset.
+
+* Offset 120:
+  The used size of the data file, as a 32-bit big-endian integer.
+  The actual size of the data file may be larger
+  (if another Mercurial processis in appending to it
+  but has not updated the docket yet).
+  That extra data must be ignored.
+
+* Offset 124:
+  The length of the data file identifier, as a 8-bit integer.
+
+* Offset 125:
+  The data file identifier.
+
+* Any additional data is current ignored, and dropped when updating the file.
+
+Tree metadata in the docket file
+--------------------------------
+
+Tree metadata is similarly made of components at fixed offsets.
+These offsets are counted in bytes from the start of tree metadata,
+which is 76 bytes after the start of the docket file.
+
+This metadata can be thought of as the singular root of the tree
+formed by nodes in the data file.
+
+* Offset 0:
+  Pseudo-pointer to the start of root nodes,
+  counted in bytes from the start of the data file,
+  as a 32-bit big-endian integer.
+  These nodes describe files and directories found directly
+  at the root of the working directory.
+
+* Offset 4:
+  Number of root nodes, as a 32-bit big-endian integer.
+
+* Offset 8:
+  Total number of nodes in the entire tree that "have a dirstate entry",
+  as a 32-bit big-endian integer.
+  Those nodes represent files that would be present at all in `dirstate-v1`.
+  This is typically less than the total number of nodes.
+  This counter is used to implement `len(dirstatemap)`.
+
+* Offset 12:
+  Number of nodes in the entire tree that have a copy source,
+  as a 32-bit big-endian integer.
+  At the next commit, these files are recorded
+  as having been copied or moved/renamed from that source.
+  (A move is recorded as a copy and separate removal of the source.)
+  This counter is used to implement `len(dirstatemap.copymap)`.
+
+* Offset 16:
+  An estimation of how many bytes of the data file
+  (within its used size) are unused, as a 32-bit big-endian integer.
+  When appending to an existing data file,
+  some existing nodes or paths can be unreachable from the new root
+  but they still take up space.
+  This counter is used to decide when to write a new data file from scratch
+  instead of appending to an existing one,
+  in order to get rid of that unreachable data
+  and avoid unbounded file size growth.
+
+* Offset 20:
+  These four bytes are currently ignored
+  and reset to zero when updating a docket file.
+  This is an attempt at forward compatibility:
+  future Mercurial versions could use this as a bit field
+  to indicate that a dirstate has additional data or constraints.
+  Finding a dirstate file with the relevant bit unset indicates that
+  it was written by a then-older version
+  which is not aware of that future change.
+
+* Offset 24:
+  Either 20 zero bytes, or a SHA-1 hash as 20 binary bytes.
+  When present, the hash is of ignore patterns
+  that were used for some previous run of the `status` algorithm.
+
+* (Offset 44: end of tree metadata)
+
+Optional hash of ignore patterns
+--------------------------------
+
+The implementation of `status` at `rust/hg-core/src/dirstate_tree/status.rs`
+has been optimized such that its run time is dominated by calls
+to `stat` for reading the filesystem metadata of a file or directory,
+and to `readdir` for listing the contents of a directory.
+In some cases the algorithm can skip calls to `readdir`
+(saving significant time)
+because the dirstate already contains enough of the relevant information
+to build the correct `status` results.
+
+The default configuration of `hg status` is to list unknown files
+but not ignored files.
+In this case, it matters for the `readdir`-skipping optimization
+if a given file used to be ignored but became unknown
+because `.hgignore` changed.
+To detect the possibility of such a change,
+the tree metadata contains an optional hash of all ignore patterns.
+
+We define:
+
+* "Root" ignore files as:
+
+  - `.hgignore` at the root of the repository if it exists
+  - And all files from `ui.ignore.*` config.
+
+  This set of files is sorted by the string representation of their path.
+
+* The "expanded contents" of an ignore files is the byte string made
+  by the concatenation of its contents followed by the "expanded contents"
+  of other files included with `include:` or `subinclude:` directives,
+  in inclusion order. This definition is recursive, as included files can
+  themselves include more files.
+
+This hash is defined as the SHA-1 of the concatenation (in sorted
+order) of the "expanded contents" of each "root" ignore file.
+(Note that computing this does not require actually concatenating
+into a single contiguous byte sequence.
+Instead a SHA-1 hasher object can be created
+and fed separate chunks one by one.)
+
+The data file format
+--------------------
+
+This is implemented in `rust/hg-core/src/dirstate_tree/on_disk.rs`
+and `mercurial/dirstateutils/v2.py`.
+
+The data file contains two types of data: paths and nodes.
+
+Paths and nodes can be organized in any order in the file, except that sibling
+nodes must be next to each other and sorted by their path.
+Contiguity lets the parent refer to them all
+by their count and a single pseudo-pointer,
+instead of storing one pseudo-pointer per child node.
+Sorting allows using binary seach to find a child node with a given name
+in `O(log(n))` byte sequence comparisons.
+
+The current implemention writes paths and child node before a given node
+for ease of figuring out the value of pseudo-pointers by the time the are to be
+written, but this is not an obligation and readers must not rely on it.
+
+A path is stored as a byte string anywhere in the file, without delimiter.
+It is refered to by one or more node by a pseudo-pointer to its start, and its
+length in bytes. Since there is no delimiter,
+when a path is a substring of another the same bytes could be reused,
+although the implementation does not exploit this as of this writing.
+
+A node is stored on 43 bytes with components at fixed offsets. Paths and
+child nodes relevant to a node are stored externally and referenced though
+pseudo-pointers.
+
+All integers are stored in big-endian. All pseudo-pointers are 32-bit integers
+counting bytes from the start of the data file. Path lengths and positions
+are 16-bit integers, also counted in bytes.
+
+Node components are:
+
+* Offset 0:
+  Pseudo-pointer to the full path of this node,
+  from the working directory root.
+
+* Offset 4:
+  Length of the full path.
+
+* Offset 6:
+  Position of the last `/` path separator within the full path,
+  in bytes from the start of the full path,
+  or zero if there isn’t one.
+  The part of the full path after this position is the "base name".
+  Since sibling nodes have the same parent, only their base name vary
+  and needs to be considered when doing binary search to find a given path.
+
+* Offset 8:
+  Pseudo-pointer to the "copy source" path for this node,
+  or zero if there is no copy source.
+
+* Offset 12:
+  Length of the copy source path, or zero if there isn’t one.
+
+* Offset 14:
+  Pseudo-pointer to the start of child nodes.
+
+* Offset 18:
+  Number of child nodes, as a 32-bit integer.
+  They occupy 43 times this number of bytes
+  (not counting space for paths, and further descendants).
+
+* Offset 22:
+  Number as a 32-bit integer of descendant nodes in this subtree,
+  not including this node itself,
+  that "have a dirstate entry".
+  Those nodes represent files that would be present at all in `dirstate-v1`.
+  This is typically less than the total number of descendants.
+  This counter is used to implement `has_dir`.
+
+* Offset 26:
+  Number as a 32-bit integer of descendant nodes in this subtree,
+  not including this node itself,
+  that represent files tracked in the working directory.
+  (For example, `hg rm` makes a file untracked.)
+  This counter is used to implement `has_tracked_dir`.
+
+* Offset 30:
+  A `flags` fields  that packs some boolean values as bits of a 16-bit integer.
+  Starting from least-significant, bit masks are::
+
+    WDIR_TRACKED = 1 << 0
+    P1_TRACKED = 1 << 1
+    P2_INFO = 1 << 2
+    MODE_EXEC_PERM = 1 << 3
+    MODE_IS_SYMLINK = 1 << 4
+    HAS_FALLBACK_EXEC = 1 << 5
+    FALLBACK_EXEC = 1 << 6
+    HAS_FALLBACK_SYMLINK = 1 << 7
+    FALLBACK_SYMLINK = 1 << 8
+    EXPECTED_STATE_IS_MODIFIED = 1 << 9
+    HAS_MODE_AND_SIZE = 1 << 10
+    HAS_MTIME = 1 << 11
+    MTIME_SECOND_AMBIGUOUS = 1 << 12
+    DIRECTORY = 1 << 13
+    ALL_UNKNOWN_RECORDED = 1 << 14
+    ALL_IGNORED_RECORDED = 1 << 15
+
+  The meaning of each bit is described below.
+
+  Other bits are unset.
+  They may be assigned meaning if the future,
+  with the limitation that Mercurial versions that pre-date such meaning
+  will always reset those bits to unset when writing nodes.
+  (A new node is written for any mutation in its subtree,
+  leaving the bytes of the old node unreachable
+  until the data file is rewritten entirely.)
+
+* Offset 32:
+  A `size` field described below, as a 32-bit integer.
+  Unlike in dirstate-v1, negative values are not used.
+
+* Offset 36:
+  The seconds component of an `mtime` field described below,
+  as a 32-bit integer.
+  Unlike in dirstate-v1, negative values are not used.
+  When `mtime` is used, this is number of seconds since the Unix epoch
+  truncated to its lower 31 bits.
+
+* Offset 40:
+  The nanoseconds component of an `mtime` field described below,
+  as a 32-bit integer.
+  When `mtime` is used,
+  this is the number of nanoseconds since `mtime.seconds`,
+  always stritctly less than one billion.
+
+  This may be zero if more precision is not available.
+  (This can happen because of limitations in any of Mercurial, Python,
+  libc, the operating system, …)
+
+  When comparing two mtimes and either has this component set to zero,
+  the sub-second precision of both should be ignored.
+  False positives when checking mtime equality due to clock resolution
+  are always possible and the status algorithm needs to deal with them,
+  but having too many false negatives could be harmful too.
+
+* (Offset 44: end of this node)
+
+The meaning of the boolean values packed in `flags` is:
+
+`WDIR_TRACKED`
+    Set if the working directory contains a tracked file at this node’s path.
+    This is typically set and unset by `hg add` and `hg rm`.
+
+`P1_TRACKED`
+    Set if the working directory’s first parent changeset
+    (whose node identifier is found in tree metadata)
+    contains a tracked file at this node’s path.
+    This is a cache to reduce manifest lookups.
+
+`P2_INFO`
+    Set if the file has been involved in some merge operation.
+    Either because it was actually merged,
+    or because the version in the second parent p2 version was ahead,
+    or because some rename moved it there.
+    In either case `hg status` will want it displayed as modified.
+
+Files that would be mentioned at all in the `dirstate-v1` file format
+have a node with at least one of the above three bits set in `dirstate-v2`.
+Let’s call these files "tracked anywhere",
+and "untracked" the nodes with all three of these bits unset.
+Untracked nodes are typically for directories:
+they hold child nodes and form the tree structure.
+Additional untracked nodes may also exist.
+Although implementations should strive to clean up nodes
+that are entirely unused, other untracked nodes may also exist.
+For example, a future version of Mercurial might in some cases
+add nodes for untracked files or/and ignored files in the working directory
+in order to optimize `hg status`
+by enabling it to skip `readdir` in more cases.
+
+`HAS_MODE_AND_SIZE`
+    Must be unset for untracked nodes.
+    For files tracked anywhere, if this is set:
+    - The `size` field is the expected file size,
+      in bytes truncated its lower to 31 bits.
+    - The expected execute permission for the file’s owner
+      is given by `MODE_EXEC_PERM`
+    - The expected file type is given by `MODE_IS_SIMLINK`:
+      a symbolic link if set, or a normal file if unset.
+    If this is unset the expected size, permission, and file type are unknown.
+    The `size` field is unused (set to zero).
+
+`HAS_MTIME`
+    The nodes contains a "valid" last modification time in the `mtime` field.
+
+
+    It means the `mtime` was already strictly in the past when observed,
+    meaning that later changes cannot happen in the same clock tick
+    and must cause a different modification time
+    (unless the system clock jumps back and we get unlucky,
+    which is not impossible but deemed unlikely enough).
+
+    This means that if `std::fs::symlink_metadata` later reports
+    the same modification time
+    and ignored patterns haven’t changed,
+    we can assume the node to be unchanged on disk.
+
+    The `mtime` field can then be used to skip more expensive lookup when
+    checking the status of "tracked" nodes.
+
+    It can also be set for node where `DIRECTORY` is set.
+    See `DIRECTORY` documentation for details.
+
+`DIRECTORY`
+    When set, this entry will match a directory that exists or existed on the
+    file system.
+
+    * When `HAS_MTIME` is set a directory has been seen on the file system and
+      `mtime` matches its last modificiation time. However, `HAS_MTIME` not being set
+      does not indicate the lack of directory on the file system.
+
+    * When not tracked anywhere, this node does not represent an ignored or
+      unknown file on disk.
+
+    If `HAS_MTIME` is set
+    and `mtime` matches the last modification time of the directory on disk,
+    the directory is unchanged
+    and we can skip calling `std::fs::read_dir` again for this directory,
+    and iterate child dirstate nodes instead.
+    (as long as `ALL_UNKNOWN_RECORDED` and `ALL_IGNORED_RECORDED` are taken
+    into account)
+
+`MODE_EXEC_PERM`
+    Must be unset if `HAS_MODE_AND_SIZE` is unset.
+    If `HAS_MODE_AND_SIZE` is set,
+    this indicates whether the file’s own is expected
+    to have execute permission.
+
+`MODE_IS_SYMLINK`
+    Must be unset if `HAS_MODE_AND_SIZE` is unset.
+    If `HAS_MODE_AND_SIZE` is set,
+    this indicates whether the file is expected to be a symlink
+    as opposed to a normal file.
+
+`EXPECTED_STATE_IS_MODIFIED`
+    Must be unset for untracked nodes.
+    For:
+    - a file tracked anywhere
+    - that has expected metadata (`HAS_MODE_AND_SIZE` and `HAS_MTIME`)
+    - if that metadata matches
+      metadata found in the working directory with `stat`
+    This bit indicates the status of the file.
+    If set, the status is modified. If unset, it is clean.
+
+    In cases where `hg status` needs to read the contents of a file
+    because metadata is ambiguous, this bit lets it record the result
+    if the result is modified so that a future run of `hg status`
+    does not need to do the same again.
+    It is valid to never set this bit,
+    and consider expected metadata ambiguous if it is set.
+
+`ALL_UNKNOWN_RECORDED`
+    If set, all "unknown" children existing on disk (at the time of the last
+    status) have been recorded and the `mtime` associated with
+    `DIRECTORY` can be used for optimization even when "unknown" file
+    are listed.
+
+    Note that the amount recorded "unknown" children can still be zero if None
+    where present.
+
+    Also note that having this flag unset does not imply that no "unknown"
+    children have been recorded. Some might be present, but there is no garantee
+    that is will be all of them.
+
+`ALL_IGNORED_RECORDED`
+    If set, all "ignored" children existing on disk (at the time of the last
+    status) have been recorded and the `mtime` associated with
+    `DIRECTORY` can be used for optimization even when "ignored" file
+    are listed.
+
+    Note that the amount recorded "ignored" children can still be zero if None
+    where present.
+
+    Also note that having this flag unset does not imply that no "ignored"
+    children have been recorded. Some might be present, but there is no garantee
+    that is will be all of them.
+
+`HAS_FALLBACK_EXEC`
+    If this flag is set, the entry carries "fallback" information for the
+    executable bit in the `FALLBACK_EXEC` flag.
+
+    Fallback information can be stored in the dirstate to keep track of
+    filesystem attribute tracked by Mercurial when the underlying file
+    system or operating system does not support that property, (e.g.
+    Windows).
+
+`FALLBACK_EXEC`
+    Should be ignored if `HAS_FALLBACK_EXEC` is unset. If set the file for this
+    entry should be considered executable if that information cannot be
+    extracted from the file system. If unset it should be considered
+    non-executable instead.
+
+`HAS_FALLBACK_SYMLINK`
+    If this flag is set, the entry carries "fallback" information for symbolic
+    link status in the `FALLBACK_SYMLINK` flag.
+
+    Fallback information can be stored in the dirstate to keep track of
+    filesystem attribute tracked by Mercurial when the underlying file
+    system or operating system does not support that property, (e.g.
+    Windows).
+
+`FALLBACK_SYMLINK`
+    Should be ignored if `HAS_FALLBACK_SYMLINK` is unset. If set the file for
+    this entry should be considered a symlink if that information cannot be
+    extracted from the file system. If unset it should be considered a normal
+    file instead.
+
+`MTIME_SECOND_AMBIGUOUS`
+    This flag is relevant only when `HAS_FILE_MTIME` is set.  When set, the
+    `mtime` stored in the entry is only valid for comparison with timestamps
+    that have nanosecond information. If available timestamp does not carries
+    nanosecond information, the `mtime` should be ignored and no optimisation
+    can be applied.
--- a/mercurial/hg.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/hg.py	Thu Oct 21 17:25:41 2021 +0200
@@ -942,7 +942,7 @@
                     exchange.pull(
                         local,
                         srcpeer,
-                        revs,
+                        heads=revs,
                         streamclonerequested=stream,
                         includepats=storeincludepats,
                         excludepats=storeexcludepats,
@@ -1261,13 +1261,14 @@
         (remoterepo, incomingchangesetlist, displayer) parameters,
     and is supposed to contain only code that can't be unified.
     """
-    srcs = urlutil.get_pull_paths(repo, ui, [source], opts.get(b'branch'))
+    srcs = urlutil.get_pull_paths(repo, ui, [source])
     srcs = list(srcs)
     if len(srcs) != 1:
         msg = _(b'for now, incoming supports only a single source, %d provided')
         msg %= len(srcs)
         raise error.Abort(msg)
-    source, branches = srcs[0]
+    path = srcs[0]
+    source, branches = urlutil.parseurl(path.rawloc, opts.get(b'branch'))
     if subpath is not None:
         subpath = urlutil.url(subpath)
         if subpath.isabs():
@@ -1285,7 +1286,7 @@
         if revs:
             revs = [other.lookup(rev) for rev in revs]
         other, chlist, cleanupfn = bundlerepo.getremotechanges(
-            ui, repo, other, revs, opts[b"bundle"], opts[b"force"]
+            ui, repo, other, revs, opts.get(b"bundle"), opts.get(b"force")
         )
 
         if not chlist:
@@ -1352,7 +1353,7 @@
         ui.status(_(b'comparing with %s\n') % urlutil.hidepassword(dest))
         revs, checkout = addbranchrevs(repo, repo, branches, opts.get(b'rev'))
         if revs:
-            revs = [repo[rev].node() for rev in scmutil.revrange(repo, revs)]
+            revs = [repo[rev].node() for rev in logcmdutil.revrange(repo, revs)]
 
         other = peer(repo, opts, dest)
         try:
--- a/mercurial/hgweb/hgwebdir_mod.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/hgweb/hgwebdir_mod.py	Thu Oct 21 17:25:41 2021 +0200
@@ -285,6 +285,7 @@
         self.lastrefresh = 0
         self.motd = None
         self.refresh()
+        self.requests_count = 0
         if not baseui:
             # set up environment for new ui
             extensions.loadall(self.ui)
@@ -341,6 +342,10 @@
 
         self.repos = repos
         self.ui = u
+        self.gc_full_collect_rate = self.ui.configint(
+            b'experimental', b'web.full-garbage-collection-rate'
+        )
+        self.gc_full_collections_done = 0
         encoding.encoding = self.ui.config(b'web', b'encoding')
         self.style = self.ui.config(b'web', b'style')
         self.templatepath = self.ui.config(
@@ -383,12 +388,27 @@
             finally:
                 # There are known cycles in localrepository that prevent
                 # those objects (and tons of held references) from being
-                # collected through normal refcounting. We mitigate those
-                # leaks by performing an explicit GC on every request.
-                # TODO remove this once leaks are fixed.
-                # TODO only run this on requests that create localrepository
-                # instances instead of every request.
-                gc.collect()
+                # collected through normal refcounting.
+                # In some cases, the resulting memory consumption can
+                # be tamed by performing explicit garbage collections.
+                # In presence of actual leaks or big long-lived caches, the
+                # impact on performance of such collections can become a
+                # problem, hence the rate shouldn't be set too low.
+                # See "Collecting the oldest generation" in
+                # https://devguide.python.org/garbage_collector
+                # for more about such trade-offs.
+                rate = self.gc_full_collect_rate
+
+                # this is not thread safe, but the consequence (skipping
+                # a garbage collection) is arguably better than risking
+                # to have several threads perform a collection in parallel
+                # (long useless wait on all threads).
+                self.requests_count += 1
+                if rate > 0 and self.requests_count % rate == 0:
+                    gc.collect()
+                    self.gc_full_collections_done += 1
+                else:
+                    gc.collect(generation=1)
 
     def _runwsgi(self, req, res):
         try:
--- a/mercurial/interfaces/dirstate.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/interfaces/dirstate.py	Thu Oct 21 17:25:41 2021 +0200
@@ -132,36 +132,6 @@
     def copies():
         pass
 
-    def normal(f, parentfiledata=None):
-        """Mark a file normal and clean.
-
-        parentfiledata: (mode, size, mtime) of the clean file
-
-        parentfiledata should be computed from memory (for mode,
-        size), as or close as possible from the point where we
-        determined the file was clean, to limit the risk of the
-        file having been changed by an external process between the
-        moment where the file was determined to be clean and now."""
-        pass
-
-    def normallookup(f):
-        '''Mark a file normal, but possibly dirty.'''
-
-    def otherparent(f):
-        '''Mark as coming from the other parent, always dirty.'''
-
-    def add(f):
-        '''Mark a file added.'''
-
-    def remove(f):
-        '''Mark a file removed.'''
-
-    def merge(f):
-        '''Mark a file merged.'''
-
-    def drop(f):
-        '''Drop a file from the dirstate'''
-
     def normalize(path, isknown=False, ignoremissing=False):
         """
         normalize the case of a pathname when on a casefolding filesystem
--- a/mercurial/localrepo.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/localrepo.py	Thu Oct 21 17:25:41 2021 +0200
@@ -917,9 +917,6 @@
     # Start with all requirements supported by this file.
     supported = set(localrepository._basesupported)
 
-    if dirstate.SUPPORTS_DIRSTATE_V2:
-        supported.add(requirementsmod.DIRSTATE_V2_REQUIREMENT)
-
     # Execute ``featuresetupfuncs`` entries if they belong to an extension
     # relevant to this ui instance.
     modules = {m.__name__ for n, m in extensions.extensions(ui)}
@@ -1177,6 +1174,32 @@
             if slow_path == b'abort':
                 raise error.Abort(msg, hint=hint)
         options[b'persistent-nodemap'] = True
+    if requirementsmod.DIRSTATE_V2_REQUIREMENT in requirements:
+        slow_path = ui.config(b'storage', b'dirstate-v2.slow-path')
+        if slow_path not in (b'allow', b'warn', b'abort'):
+            default = ui.config_default(b'storage', b'dirstate-v2.slow-path')
+            msg = _(b'unknown value for config "dirstate-v2.slow-path": "%s"\n')
+            ui.warn(msg % slow_path)
+            if not ui.quiet:
+                ui.warn(_(b'falling back to default value: %s\n') % default)
+            slow_path = default
+
+        msg = _(
+            b"accessing `dirstate-v2` repository without associated "
+            b"fast implementation."
+        )
+        hint = _(
+            b"check `hg help config.format.exp-dirstate-v2` " b"for details"
+        )
+        if not dirstate.HAS_FAST_DIRSTATE_V2:
+            if slow_path == b'warn':
+                msg = b"warning: " + msg + b'\n'
+                ui.warn(msg)
+                if not ui.quiet:
+                    hint = b'(' + hint + b')\n'
+                    ui.warn(hint)
+            if slow_path == b'abort':
+                raise error.Abort(msg, hint=hint)
     if ui.configbool(b'storage', b'revlog.persistent-nodemap.mmap'):
         options[b'persistent-nodemap.mmap'] = True
     if ui.configbool(b'devel', b'persistent-nodemap'):
@@ -1266,6 +1289,7 @@
         requirementsmod.NODEMAP_REQUIREMENT,
         bookmarks.BOOKMARKS_IN_STORE_REQUIREMENT,
         requirementsmod.SHARESAFE_REQUIREMENT,
+        requirementsmod.DIRSTATE_V2_REQUIREMENT,
     }
     _basesupported = supportedformats | {
         requirementsmod.STORE_REQUIREMENT,
@@ -3609,15 +3633,7 @@
     # experimental config: format.exp-dirstate-v2
     # Keep this logic in sync with `has_dirstate_v2()` in `tests/hghave.py`
     if ui.configbool(b'format', b'exp-dirstate-v2'):
-        if dirstate.SUPPORTS_DIRSTATE_V2:
-            requirements.add(requirementsmod.DIRSTATE_V2_REQUIREMENT)
-        else:
-            raise error.Abort(
-                _(
-                    b"dirstate v2 format requested by config "
-                    b"but not supported (requires Rust extensions)"
-                )
-            )
+        requirements.add(requirementsmod.DIRSTATE_V2_REQUIREMENT)
 
     # experimental config: format.exp-use-copies-side-data-changeset
     if ui.configbool(b'format', b'exp-use-copies-side-data-changeset'):
--- a/mercurial/logcmdutil.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/logcmdutil.py	Thu Oct 21 17:25:41 2021 +0200
@@ -46,13 +46,12 @@
         Any,
         Callable,
         Dict,
-        List,
         Optional,
         Sequence,
         Tuple,
     )
 
-    for t in (Any, Callable, Dict, List, Optional, Tuple):
+    for t in (Any, Callable, Dict, Optional, Tuple):
         assert t
 
 
@@ -714,43 +713,43 @@
     """
 
     # raw command-line parameters, which a matcher will be built from
-    pats = attr.ib()  # type: List[bytes]
-    opts = attr.ib()  # type: Dict[bytes, Any]
+    pats = attr.ib()
+    opts = attr.ib()
 
     # a list of revset expressions to be traversed; if follow, it specifies
     # the start revisions
-    revspec = attr.ib()  # type: List[bytes]
+    revspec = attr.ib()
 
     # miscellaneous queries to filter revisions (see "hg help log" for details)
-    bookmarks = attr.ib(default=attr.Factory(list))  # type: List[bytes]
-    branches = attr.ib(default=attr.Factory(list))  # type: List[bytes]
-    date = attr.ib(default=None)  # type: Optional[bytes]
-    keywords = attr.ib(default=attr.Factory(list))  # type: List[bytes]
-    no_merges = attr.ib(default=False)  # type: bool
-    only_merges = attr.ib(default=False)  # type: bool
-    prune_ancestors = attr.ib(default=attr.Factory(list))  # type: List[bytes]
-    users = attr.ib(default=attr.Factory(list))  # type: List[bytes]
+    bookmarks = attr.ib(default=attr.Factory(list))
+    branches = attr.ib(default=attr.Factory(list))
+    date = attr.ib(default=None)
+    keywords = attr.ib(default=attr.Factory(list))
+    no_merges = attr.ib(default=False)
+    only_merges = attr.ib(default=False)
+    prune_ancestors = attr.ib(default=attr.Factory(list))
+    users = attr.ib(default=attr.Factory(list))
 
     # miscellaneous matcher arguments
-    include_pats = attr.ib(default=attr.Factory(list))  # type: List[bytes]
-    exclude_pats = attr.ib(default=attr.Factory(list))  # type: List[bytes]
+    include_pats = attr.ib(default=attr.Factory(list))
+    exclude_pats = attr.ib(default=attr.Factory(list))
 
     # 0: no follow, 1: follow first, 2: follow both parents
-    follow = attr.ib(default=0)  # type: int
+    follow = attr.ib(default=0)
 
     # do not attempt filelog-based traversal, which may be fast but cannot
     # include revisions where files were removed
-    force_changelog_traversal = attr.ib(default=False)  # type: bool
+    force_changelog_traversal = attr.ib(default=False)
 
     # filter revisions by file patterns, which should be disabled only if
     # you want to include revisions where files were unmodified
-    filter_revisions_by_pats = attr.ib(default=True)  # type: bool
+    filter_revisions_by_pats = attr.ib(default=True)
 
     # sort revisions prior to traversal: 'desc', 'topo', or None
-    sort_revisions = attr.ib(default=None)  # type: Optional[bytes]
+    sort_revisions = attr.ib(default=None)
 
     # limit number of changes displayed; None means unlimited
-    limit = attr.ib(default=None)  # type: Optional[int]
+    limit = attr.ib(default=None)
 
 
 def parseopts(ui, pats, opts):
@@ -913,6 +912,42 @@
     return None
 
 
+def revsingle(repo, revspec, default=b'.', localalias=None):
+    """Resolves user-provided revset(s) into a single revision.
+
+    This just wraps the lower-level scmutil.revsingle() in order to raise an
+    exception indicating user error.
+    """
+    try:
+        return scmutil.revsingle(repo, revspec, default, localalias)
+    except error.RepoLookupError as e:
+        raise error.InputError(e.args[0], hint=e.hint)
+
+
+def revpair(repo, revs):
+    """Resolves user-provided revset(s) into two revisions.
+
+    This just wraps the lower-level scmutil.revpair() in order to raise an
+    exception indicating user error.
+    """
+    try:
+        return scmutil.revpair(repo, revs)
+    except error.RepoLookupError as e:
+        raise error.InputError(e.args[0], hint=e.hint)
+
+
+def revrange(repo, specs, localalias=None):
+    """Resolves user-provided revset(s).
+
+    This just wraps the lower-level scmutil.revrange() in order to raise an
+    exception indicating user error.
+    """
+    try:
+        return scmutil.revrange(repo, specs, localalias)
+    except error.RepoLookupError as e:
+        raise error.InputError(e.args[0], hint=e.hint)
+
+
 _opt2logrevset = {
     b'no_merges': (b'not merge()', None),
     b'only_merges': (b'merge()', None),
@@ -988,7 +1023,7 @@
 def _initialrevs(repo, wopts):
     """Return the initial set of revisions to be filtered or followed"""
     if wopts.revspec:
-        revs = scmutil.revrange(repo, wopts.revspec)
+        revs = revrange(repo, wopts.revspec)
     elif wopts.follow and repo.dirstate.p1() == repo.nullid:
         revs = smartset.baseset()
     elif wopts.follow:
--- a/mercurial/merge.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/merge.py	Thu Oct 21 17:25:41 2021 +0200
@@ -9,13 +9,13 @@
 
 import collections
 import errno
-import stat
 import struct
 
 from .i18n import _
 from .node import nullrev
 from .thirdparty import attr
 from .utils import stringutil
+from .dirstateutils import timestamp
 from . import (
     copies,
     encoding,
@@ -1406,8 +1406,9 @@
             if wantfiledata:
                 s = wfctx.lstat()
                 mode = s.st_mode
-                mtime = s[stat.ST_MTIME]
-                filedata[f] = (mode, size, mtime)  # for dirstate.normal
+                mtime = timestamp.mtime_of(s)
+                # for dirstate.update_file's parentfiledata argument:
+                filedata[f] = (mode, size, mtime)
             if i == 100:
                 yield False, (i, f)
                 i = 0
--- a/mercurial/mergestate.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/mergestate.py	Thu Oct 21 17:25:41 2021 +0200
@@ -796,12 +796,13 @@
     for f, args, msg in actions.get(ACTION_GET, []):
         if branchmerge:
             # tracked in p1 can be True also but update_file should not care
+            old_entry = repo.dirstate.get_entry(f)
+            p1_tracked = old_entry.any_tracked and not old_entry.added
             repo.dirstate.update_file(
                 f,
-                p1_tracked=False,
-                p2_tracked=True,
+                p1_tracked=p1_tracked,
                 wc_tracked=True,
-                clean_p2=True,
+                p2_info=True,
             )
         else:
             parentfiledata = getfiledata[f] if getfiledata else None
@@ -818,8 +819,12 @@
         if branchmerge:
             # We've done a branch merge, mark this file as merged
             # so that we properly record the merger later
+            p1_tracked = f1 == f
             repo.dirstate.update_file(
-                f, p1_tracked=True, wc_tracked=True, merged=True
+                f,
+                p1_tracked=p1_tracked,
+                wc_tracked=True,
+                p2_info=True,
             )
             if f1 != f2:  # copy/rename
                 if move:
--- a/mercurial/mpatch.h	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/mpatch.h	Thu Oct 21 17:25:41 2021 +0200
@@ -1,5 +1,5 @@
-#ifndef _HG_MPATCH_H_
-#define _HG_MPATCH_H_
+#ifndef HG_MPATCH_H
+#define HG_MPATCH_H
 
 #define MPATCH_ERR_NO_MEM -3
 #define MPATCH_ERR_CANNOT_BE_DECODED -2
--- a/mercurial/narrowspec.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/narrowspec.py	Thu Oct 21 17:25:41 2021 +0200
@@ -299,7 +299,7 @@
     storespec = repo.svfs.tryread(FILENAME)
     wcspec = repo.vfs.tryread(DIRSTATE_FILENAME)
     if wcspec != storespec:
-        raise error.Abort(
+        raise error.StateError(
             _(b"working copy's narrowspec is stale"),
             hint=_(b"run 'hg tracked --update-working-copy'"),
         )
--- a/mercurial/parser.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/parser.py	Thu Oct 21 17:25:41 2021 +0200
@@ -21,7 +21,6 @@
 from .i18n import _
 from . import (
     error,
-    pycompat,
     util,
 )
 from .utils import stringutil
@@ -216,7 +215,11 @@
         return stringutil.unescapestr(s)
     except ValueError as e:
         # mangle Python's exception into our format
-        raise error.ParseError(pycompat.bytestr(e).lower())
+        # TODO: remove this suppression.  For some reason, pytype 2021.09.09
+        #   thinks .lower() is being called on Union[ValueError, bytes].
+        # pytype: disable=attribute-error
+        raise error.ParseError(stringutil.forcebytestr(e).lower())
+        # pytype: enable=attribute-error
 
 
 def _prettyformat(tree, leafnodes, level, lines):
--- a/mercurial/patch.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/patch.py	Thu Oct 21 17:25:41 2021 +0200
@@ -550,7 +550,9 @@
         self.copied = []
 
     def _checkknown(self, fname):
-        if self.repo.dirstate[fname] == b'?' and self.exists(fname):
+        if not self.repo.dirstate.get_entry(fname).any_tracked and self.exists(
+            fname
+        ):
             raise PatchError(_(b'cannot patch %s: file is not tracked') % fname)
 
     def setfile(self, fname, data, mode, copysource):
--- a/mercurial/pathutil.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/pathutil.py	Thu Oct 21 17:25:41 2021 +0200
@@ -315,20 +315,19 @@
 class dirs(object):
     '''a multiset of directory names from a set of file paths'''
 
-    def __init__(self, map, skip=None):
+    def __init__(self, map, only_tracked=False):
         """
         a dict map indicates a dirstate while a list indicates a manifest
         """
         self._dirs = {}
         addpath = self.addpath
-        if isinstance(map, dict) and skip is not None:
+        if isinstance(map, dict) and only_tracked:
             for f, s in pycompat.iteritems(map):
-                if s.state != skip:
+                if s.state != b'r':
                     addpath(f)
-        elif skip is not None:
-            raise error.ProgrammingError(
-                b"skip character is only supported with a dict source"
-            )
+        elif only_tracked:
+            msg = b"`only_tracked` is only supported with a dict source"
+            raise error.ProgrammingError(msg)
         else:
             for f in map:
                 addpath(f)
--- a/mercurial/pure/parsers.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/pure/parsers.py	Thu Oct 21 17:25:41 2021 +0200
@@ -7,6 +7,7 @@
 
 from __future__ import absolute_import
 
+import stat
 import struct
 import zlib
 
@@ -43,29 +44,143 @@
 # a special value used internally for `time` if the time is ambigeous
 AMBIGUOUS_TIME = -1
 
+# Bits of the `flags` byte inside a node in the file format
+DIRSTATE_V2_WDIR_TRACKED = 1 << 0
+DIRSTATE_V2_P1_TRACKED = 1 << 1
+DIRSTATE_V2_P2_INFO = 1 << 2
+DIRSTATE_V2_MODE_EXEC_PERM = 1 << 3
+DIRSTATE_V2_MODE_IS_SYMLINK = 1 << 4
+DIRSTATE_V2_HAS_FALLBACK_EXEC = 1 << 5
+DIRSTATE_V2_FALLBACK_EXEC = 1 << 6
+DIRSTATE_V2_HAS_FALLBACK_SYMLINK = 1 << 7
+DIRSTATE_V2_FALLBACK_SYMLINK = 1 << 8
+DIRSTATE_V2_EXPECTED_STATE_IS_MODIFIED = 1 << 9
+DIRSTATE_V2_HAS_MODE_AND_SIZE = 1 << 10
+DIRSTATE_V2_HAS_MTIME = 1 << 11
+DIRSTATE_V2_MTIME_SECOND_AMBIGUOUS = 1 << 12
+DIRSTATE_V2_DIRECTORY = 1 << 13
+DIRSTATE_V2_ALL_UNKNOWN_RECORDED = 1 << 14
+DIRSTATE_V2_ALL_IGNORED_RECORDED = 1 << 15
+
 
 @attr.s(slots=True, init=False)
 class DirstateItem(object):
     """represent a dirstate entry
 
-    It contains:
+    It hold multiple attributes
+
+    # about file tracking
+    - wc_tracked: is the file tracked by the working copy
+    - p1_tracked: is the file tracked in working copy first parent
+    - p2_info: the file has been involved in some merge operation. Either
+               because it was actually merged, or because the p2 version was
+               ahead, or because some rename moved it there. In either case
+               `hg status` will want it displayed as modified.
 
-    - state (one of 'n', 'a', 'r', 'm')
-    - mode,
-    - size,
-    - mtime,
+    # about the file state expected from p1 manifest:
+    - mode: the file mode in p1
+    - size: the file size in p1
+
+    These value can be set to None, which mean we don't have a meaningful value
+    to compare with. Either because we don't really care about them as there
+    `status` is known without having to look at the disk or because we don't
+    know these right now and a full comparison will be needed to find out if
+    the file is clean.
+
+    # about the file state on disk last time we saw it:
+    - mtime: the last known clean mtime for the file.
+
+    This value can be set to None if no cachable state exist. Either because we
+    do not care (see previous section) or because we could not cache something
+    yet.
     """
 
-    _state = attr.ib()
+    _wc_tracked = attr.ib()
+    _p1_tracked = attr.ib()
+    _p2_info = attr.ib()
     _mode = attr.ib()
     _size = attr.ib()
-    _mtime = attr.ib()
+    _mtime_s = attr.ib()
+    _mtime_ns = attr.ib()
+    _fallback_exec = attr.ib()
+    _fallback_symlink = attr.ib()
+
+    def __init__(
+        self,
+        wc_tracked=False,
+        p1_tracked=False,
+        p2_info=False,
+        has_meaningful_data=True,
+        has_meaningful_mtime=True,
+        parentfiledata=None,
+        fallback_exec=None,
+        fallback_symlink=None,
+    ):
+        self._wc_tracked = wc_tracked
+        self._p1_tracked = p1_tracked
+        self._p2_info = p2_info
+
+        self._fallback_exec = fallback_exec
+        self._fallback_symlink = fallback_symlink
+
+        self._mode = None
+        self._size = None
+        self._mtime_s = None
+        self._mtime_ns = None
+        if parentfiledata is None:
+            has_meaningful_mtime = False
+            has_meaningful_data = False
+        if has_meaningful_data:
+            self._mode = parentfiledata[0]
+            self._size = parentfiledata[1]
+        if has_meaningful_mtime:
+            self._mtime_s, self._mtime_ns = parentfiledata[2]
 
-    def __init__(self, state, mode, size, mtime):
-        self._state = state
-        self._mode = mode
-        self._size = size
-        self._mtime = mtime
+    @classmethod
+    def from_v2_data(cls, flags, size, mtime_s, mtime_ns):
+        """Build a new DirstateItem object from V2 data"""
+        has_mode_size = bool(flags & DIRSTATE_V2_HAS_MODE_AND_SIZE)
+        has_meaningful_mtime = bool(flags & DIRSTATE_V2_HAS_MTIME)
+        if flags & DIRSTATE_V2_MTIME_SECOND_AMBIGUOUS:
+            # The current code is not able to do the more subtle comparison that the
+            # MTIME_SECOND_AMBIGUOUS requires. So we ignore the mtime
+            has_meaningful_mtime = False
+        mode = None
+
+        if flags & +DIRSTATE_V2_EXPECTED_STATE_IS_MODIFIED:
+            # we do not have support for this flag in the code yet,
+            # force a lookup for this file.
+            has_mode_size = False
+            has_meaningful_mtime = False
+
+        fallback_exec = None
+        if flags & DIRSTATE_V2_HAS_FALLBACK_EXEC:
+            fallback_exec = flags & DIRSTATE_V2_FALLBACK_EXEC
+
+        fallback_symlink = None
+        if flags & DIRSTATE_V2_HAS_FALLBACK_SYMLINK:
+            fallback_symlink = flags & DIRSTATE_V2_FALLBACK_SYMLINK
+
+        if has_mode_size:
+            assert stat.S_IXUSR == 0o100
+            if flags & DIRSTATE_V2_MODE_EXEC_PERM:
+                mode = 0o755
+            else:
+                mode = 0o644
+            if flags & DIRSTATE_V2_MODE_IS_SYMLINK:
+                mode |= stat.S_IFLNK
+            else:
+                mode |= stat.S_IFREG
+        return cls(
+            wc_tracked=bool(flags & DIRSTATE_V2_WDIR_TRACKED),
+            p1_tracked=bool(flags & DIRSTATE_V2_P1_TRACKED),
+            p2_info=bool(flags & DIRSTATE_V2_P2_INFO),
+            has_meaningful_data=has_mode_size,
+            has_meaningful_mtime=has_meaningful_mtime,
+            parentfiledata=(mode, size, (mtime_s, mtime_ns)),
+            fallback_exec=fallback_exec,
+            fallback_symlink=fallback_symlink,
+        )
 
     @classmethod
     def from_v1_data(cls, state, mode, size, mtime):
@@ -74,12 +189,41 @@
         Since the dirstate-v1 format is frozen, the signature of this function
         is not expected to change, unlike the __init__ one.
         """
-        return cls(
-            state=state,
-            mode=mode,
-            size=size,
-            mtime=mtime,
-        )
+        if state == b'm':
+            return cls(wc_tracked=True, p1_tracked=True, p2_info=True)
+        elif state == b'a':
+            return cls(wc_tracked=True)
+        elif state == b'r':
+            if size == NONNORMAL:
+                p1_tracked = True
+                p2_info = True
+            elif size == FROM_P2:
+                p1_tracked = False
+                p2_info = True
+            else:
+                p1_tracked = True
+                p2_info = False
+            return cls(p1_tracked=p1_tracked, p2_info=p2_info)
+        elif state == b'n':
+            if size == FROM_P2:
+                return cls(wc_tracked=True, p2_info=True)
+            elif size == NONNORMAL:
+                return cls(wc_tracked=True, p1_tracked=True)
+            elif mtime == AMBIGUOUS_TIME:
+                return cls(
+                    wc_tracked=True,
+                    p1_tracked=True,
+                    has_meaningful_mtime=False,
+                    parentfiledata=(mode, size, (42, 0)),
+                )
+            else:
+                return cls(
+                    wc_tracked=True,
+                    p1_tracked=True,
+                    parentfiledata=(mode, size, (mtime, 0)),
+                )
+        else:
+            raise RuntimeError(b'unknown state: %s' % state)
 
     def set_possibly_dirty(self):
         """Mark a file as "possibly dirty"
@@ -87,39 +231,80 @@
         This means the next status call will have to actually check its content
         to make sure it is correct.
         """
-        self._mtime = AMBIGUOUS_TIME
+        self._mtime_s = None
+        self._mtime_ns = None
+
+    def set_clean(self, mode, size, mtime):
+        """mark a file as "clean" cancelling potential "possibly dirty call"
+
+        Note: this function is a descendant of `dirstate.normal` and is
+        currently expected to be call on "normal" entry only. There are not
+        reason for this to not change in the future as long as the ccode is
+        updated to preserve the proper state of the non-normal files.
+        """
+        self._wc_tracked = True
+        self._p1_tracked = True
+        self._mode = mode
+        self._size = size
+        self._mtime_s, self._mtime_ns = mtime
+
+    def set_tracked(self):
+        """mark a file as tracked in the working copy
 
-    def __getitem__(self, idx):
-        if idx == 0 or idx == -4:
-            msg = b"do not use item[x], use item.state"
-            util.nouideprecwarn(msg, b'6.0', stacklevel=2)
-            return self._state
-        elif idx == 1 or idx == -3:
-            msg = b"do not use item[x], use item.mode"
-            util.nouideprecwarn(msg, b'6.0', stacklevel=2)
-            return self._mode
-        elif idx == 2 or idx == -2:
-            msg = b"do not use item[x], use item.size"
-            util.nouideprecwarn(msg, b'6.0', stacklevel=2)
-            return self._size
-        elif idx == 3 or idx == -1:
-            msg = b"do not use item[x], use item.mtime"
-            util.nouideprecwarn(msg, b'6.0', stacklevel=2)
-            return self._mtime
-        else:
-            raise IndexError(idx)
+        This will ultimately be called by command like `hg add`.
+        """
+        self._wc_tracked = True
+        # `set_tracked` is replacing various `normallookup` call. So we mark
+        # the files as needing lookup
+        #
+        # Consider dropping this in the future in favor of something less broad.
+        self._mtime_s = None
+        self._mtime_ns = None
+
+    def set_untracked(self):
+        """mark a file as untracked in the working copy
+
+        This will ultimately be called by command like `hg remove`.
+        """
+        self._wc_tracked = False
+        self._mode = None
+        self._size = None
+        self._mtime_s = None
+        self._mtime_ns = None
+
+    def drop_merge_data(self):
+        """remove all "merge-only" from a DirstateItem
+
+        This is to be call by the dirstatemap code when the second parent is dropped
+        """
+        if self._p2_info:
+            self._p2_info = False
+            self._mode = None
+            self._size = None
+            self._mtime_s = None
+            self._mtime_ns = None
 
     @property
     def mode(self):
-        return self._mode
+        return self.v1_mode()
 
     @property
     def size(self):
-        return self._size
+        return self.v1_size()
 
     @property
     def mtime(self):
-        return self._mtime
+        return self.v1_mtime()
+
+    def mtime_likely_equal_to(self, other_mtime):
+        self_sec = self._mtime_s
+        if self_sec is None:
+            return False
+        self_ns = self._mtime_ns
+        other_sec, other_ns = other_mtime
+        return self_sec == other_sec and (
+            self_ns == other_ns or self_ns == 0 or other_ns == 0
+        )
 
     @property
     def state(self):
@@ -134,94 +319,224 @@
         dirstatev1 format. It would make sense to ultimately deprecate it in
         favor of the more "semantic" attributes.
         """
-        return self._state
+        if not self.any_tracked:
+            return b'?'
+        return self.v1_state()
+
+    @property
+    def has_fallback_exec(self):
+        """True if "fallback" information are available for the "exec" bit
+
+        Fallback information can be stored in the dirstate to keep track of
+        filesystem attribute tracked by Mercurial when the underlying file
+        system or operating system does not support that property, (e.g.
+        Windows).
+
+        Not all version of the dirstate on-disk storage support preserving this
+        information.
+        """
+        return self._fallback_exec is not None
+
+    @property
+    def fallback_exec(self):
+        """ "fallback" information for the executable bit
+
+        True if the file should be considered executable when we cannot get
+        this information from the files system. False if it should be
+        considered non-executable.
+
+        See has_fallback_exec for details."""
+        return self._fallback_exec
+
+    @fallback_exec.setter
+    def set_fallback_exec(self, value):
+        """control "fallback" executable bit
+
+        Set to:
+        - True if the file should be considered executable,
+        - False if the file should be considered non-executable,
+        - None if we do not have valid fallback data.
+
+        See has_fallback_exec for details."""
+        if value is None:
+            self._fallback_exec = None
+        else:
+            self._fallback_exec = bool(value)
+
+    @property
+    def has_fallback_symlink(self):
+        """True if "fallback" information are available for symlink status
+
+        Fallback information can be stored in the dirstate to keep track of
+        filesystem attribute tracked by Mercurial when the underlying file
+        system or operating system does not support that property, (e.g.
+        Windows).
+
+        Not all version of the dirstate on-disk storage support preserving this
+        information."""
+        return self._fallback_symlink is not None
+
+    @property
+    def fallback_symlink(self):
+        """ "fallback" information for symlink status
+
+        True if the file should be considered executable when we cannot get
+        this information from the files system. False if it should be
+        considered non-executable.
+
+        See has_fallback_exec for details."""
+        return self._fallback_symlink
+
+    @fallback_symlink.setter
+    def set_fallback_symlink(self, value):
+        """control "fallback" symlink status
+
+        Set to:
+        - True if the file should be considered a symlink,
+        - False if the file should be considered not a symlink,
+        - None if we do not have valid fallback data.
+
+        See has_fallback_symlink for details."""
+        if value is None:
+            self._fallback_symlink = None
+        else:
+            self._fallback_symlink = bool(value)
 
     @property
     def tracked(self):
         """True is the file is tracked in the working copy"""
-        return self._state in b"nma"
+        return self._wc_tracked
+
+    @property
+    def any_tracked(self):
+        """True is the file is tracked anywhere (wc or parents)"""
+        return self._wc_tracked or self._p1_tracked or self._p2_info
 
     @property
     def added(self):
         """True if the file has been added"""
-        return self._state == b'a'
-
-    @property
-    def merged(self):
-        """True if the file has been merged
-
-        Should only be set if a merge is in progress in the dirstate
-        """
-        return self._state == b'm'
+        return self._wc_tracked and not (self._p1_tracked or self._p2_info)
 
     @property
-    def from_p2(self):
-        """True if the file have been fetched from p2 during the current merge
-
-        This is only True is the file is currently tracked.
-
-        Should only be set if a merge is in progress in the dirstate
-        """
-        return self._state == b'n' and self._size == FROM_P2
+    def maybe_clean(self):
+        """True if the file has a chance to be in the "clean" state"""
+        if not self._wc_tracked:
+            return False
+        elif not self._p1_tracked:
+            return False
+        elif self._p2_info:
+            return False
+        return True
 
     @property
-    def from_p2_removed(self):
-        """True if the file has been removed, but was "from_p2" initially
+    def p1_tracked(self):
+        """True if the file is tracked in the first parent manifest"""
+        return self._p1_tracked
 
-        This property seems like an abstraction leakage and should probably be
-        dealt in this class (or maybe the dirstatemap) directly.
+    @property
+    def p2_info(self):
+        """True if the file needed to merge or apply any input from p2
+
+        See the class documentation for details.
         """
-        return self._state == b'r' and self._size == FROM_P2
+        return self._wc_tracked and self._p2_info
 
     @property
     def removed(self):
         """True if the file has been removed"""
-        return self._state == b'r'
-
-    @property
-    def merged_removed(self):
-        """True if the file has been removed, but was "merged" initially
-
-        This property seems like an abstraction leakage and should probably be
-        dealt in this class (or maybe the dirstatemap)  directly.
-        """
-        return self._state == b'r' and self._size == NONNORMAL
+        return not self._wc_tracked and (self._p1_tracked or self._p2_info)
 
-    @property
-    def dm_nonnormal(self):
-        """True is the entry is non-normal in the dirstatemap sense
-
-        There is no reason for any code, but the dirstatemap one to use this.
-        """
-        return self.state != b'n' or self.mtime == AMBIGUOUS_TIME
+    def v2_data(self):
+        """Returns (flags, mode, size, mtime) for v2 serialization"""
+        flags = 0
+        if self._wc_tracked:
+            flags |= DIRSTATE_V2_WDIR_TRACKED
+        if self._p1_tracked:
+            flags |= DIRSTATE_V2_P1_TRACKED
+        if self._p2_info:
+            flags |= DIRSTATE_V2_P2_INFO
+        if self._mode is not None and self._size is not None:
+            flags |= DIRSTATE_V2_HAS_MODE_AND_SIZE
+            if self.mode & stat.S_IXUSR:
+                flags |= DIRSTATE_V2_MODE_EXEC_PERM
+            if stat.S_ISLNK(self.mode):
+                flags |= DIRSTATE_V2_MODE_IS_SYMLINK
+        if self._mtime_s is not None:
+            flags |= DIRSTATE_V2_HAS_MTIME
 
-    @property
-    def dm_otherparent(self):
-        """True is the entry is `otherparent` in the dirstatemap sense
+        if self._fallback_exec is not None:
+            flags |= DIRSTATE_V2_HAS_FALLBACK_EXEC
+            if self._fallback_exec:
+                flags |= DIRSTATE_V2_FALLBACK_EXEC
 
-        There is no reason for any code, but the dirstatemap one to use this.
-        """
-        return self._size == FROM_P2
+        if self._fallback_symlink is not None:
+            flags |= DIRSTATE_V2_HAS_FALLBACK_SYMLINK
+            if self._fallback_symlink:
+                flags |= DIRSTATE_V2_FALLBACK_SYMLINK
+
+        # Note: we do not need to do anything regarding
+        # DIRSTATE_V2_ALL_UNKNOWN_RECORDED and DIRSTATE_V2_ALL_IGNORED_RECORDED
+        # since we never set _DIRSTATE_V2_HAS_DIRCTORY_MTIME
+        return (flags, self._size or 0, self._mtime_s or 0, self._mtime_ns or 0)
 
     def v1_state(self):
         """return a "state" suitable for v1 serialization"""
-        return self._state
+        if not self.any_tracked:
+            # the object has no state to record, this is -currently-
+            # unsupported
+            raise RuntimeError('untracked item')
+        elif self.removed:
+            return b'r'
+        elif self._p1_tracked and self._p2_info:
+            return b'm'
+        elif self.added:
+            return b'a'
+        else:
+            return b'n'
 
     def v1_mode(self):
         """return a "mode" suitable for v1 serialization"""
-        return self._mode
+        return self._mode if self._mode is not None else 0
 
     def v1_size(self):
         """return a "size" suitable for v1 serialization"""
-        return self._size
+        if not self.any_tracked:
+            # the object has no state to record, this is -currently-
+            # unsupported
+            raise RuntimeError('untracked item')
+        elif self.removed and self._p1_tracked and self._p2_info:
+            return NONNORMAL
+        elif self._p2_info:
+            return FROM_P2
+        elif self.removed:
+            return 0
+        elif self.added:
+            return NONNORMAL
+        elif self._size is None:
+            return NONNORMAL
+        else:
+            return self._size
 
     def v1_mtime(self):
         """return a "mtime" suitable for v1 serialization"""
-        return self._mtime
+        if not self.any_tracked:
+            # the object has no state to record, this is -currently-
+            # unsupported
+            raise RuntimeError('untracked item')
+        elif self.removed:
+            return 0
+        elif self._mtime_s is None:
+            return AMBIGUOUS_TIME
+        elif self._p2_info:
+            return AMBIGUOUS_TIME
+        elif not self._p1_tracked:
+            return AMBIGUOUS_TIME
+        else:
+            return self._mtime_s
 
     def need_delay(self, now):
         """True if the stored mtime would be ambiguous with the current time"""
-        return self._state == b'n' and self._mtime == now
+        return self.v1_state() == b'n' and self._mtime_s == now[0]
 
 
 def gettype(q):
@@ -589,7 +904,6 @@
 
 
 def pack_dirstate(dmap, copymap, pl, now):
-    now = int(now)
     cs = stringio()
     write = cs.write
     write(b"".join(pl))
--- a/mercurial/pycompat.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/pycompat.py	Thu Oct 21 17:25:41 2021 +0200
@@ -222,6 +222,15 @@
         >>> assert type(t) is bytes
         """
 
+        # Trick pytype into not demanding Iterable[int] be passed to __new__(),
+        # since the appropriate bytes format is done internally.
+        #
+        # https://github.com/google/pytype/issues/500
+        if TYPE_CHECKING:
+
+            def __init__(self, s=b''):
+                pass
+
         def __new__(cls, s=b''):
             if isinstance(s, bytestr):
                 return s
--- a/mercurial/repair.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/repair.py	Thu Oct 21 17:25:41 2021 +0200
@@ -433,7 +433,7 @@
     if scmutil.istreemanifest(repo):
         # This logic is safe if treemanifest isn't enabled, but also
         # pointless, so we skip it if treemanifest isn't enabled.
-        for t, unencoded, encoded, size in repo.store.datafiles():
+        for t, unencoded, size in repo.store.datafiles():
             if unencoded.startswith(b'meta/') and unencoded.endswith(
                 b'00manifest.i'
             ):
@@ -441,7 +441,7 @@
                 yield repo.manifestlog.getstorage(dir)
 
 
-def rebuildfncache(ui, repo):
+def rebuildfncache(ui, repo, only_data=False):
     """Rebuilds the fncache file from repo history.
 
     Missing entries will be added. Extra entries will be removed.
@@ -465,28 +465,40 @@
         newentries = set()
         seenfiles = set()
 
-        progress = ui.makeprogress(
-            _(b'rebuilding'), unit=_(b'changesets'), total=len(repo)
-        )
-        for rev in repo:
-            progress.update(rev)
+        if only_data:
+            # Trust the listing of .i from the fncache, but not the .d. This is
+            # much faster, because we only need to stat every possible .d files,
+            # instead of reading the full changelog
+            for f in fnc:
+                if f[:5] == b'data/' and f[-2:] == b'.i':
+                    seenfiles.add(f[5:-2])
+                    newentries.add(f)
+                    dataf = f[:-2] + b'.d'
+                    if repo.store._exists(dataf):
+                        newentries.add(dataf)
+        else:
+            progress = ui.makeprogress(
+                _(b'rebuilding'), unit=_(b'changesets'), total=len(repo)
+            )
+            for rev in repo:
+                progress.update(rev)
 
-            ctx = repo[rev]
-            for f in ctx.files():
-                # This is to minimize I/O.
-                if f in seenfiles:
-                    continue
-                seenfiles.add(f)
+                ctx = repo[rev]
+                for f in ctx.files():
+                    # This is to minimize I/O.
+                    if f in seenfiles:
+                        continue
+                    seenfiles.add(f)
 
-                i = b'data/%s.i' % f
-                d = b'data/%s.d' % f
+                    i = b'data/%s.i' % f
+                    d = b'data/%s.d' % f
 
-                if repo.store._exists(i):
-                    newentries.add(i)
-                if repo.store._exists(d):
-                    newentries.add(d)
+                    if repo.store._exists(i):
+                        newentries.add(i)
+                    if repo.store._exists(d):
+                        newentries.add(d)
 
-        progress.complete()
+            progress.complete()
 
         if requirements.TREEMANIFEST_REQUIREMENT in repo.requirements:
             # This logic is safe if treemanifest isn't enabled, but also
--- a/mercurial/revlogutils/rewrite.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/revlogutils/rewrite.py	Thu Oct 21 17:25:41 2021 +0200
@@ -826,7 +826,7 @@
     with context():
         files = list(
             (file_type, path)
-            for (file_type, path, _e, _s) in repo.store.datafiles()
+            for (file_type, path, _s) in repo.store.datafiles()
             if path.endswith(b'.i') and file_type & store.FILEFLAGS_FILELOG
         )
 
--- a/mercurial/scmutil.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/scmutil.py	Thu Oct 21 17:25:41 2021 +0200
@@ -689,7 +689,7 @@
 
     l = revrange(repo, [revspec], localalias=localalias)
     if not l:
-        raise error.Abort(_(b'empty revision set'))
+        raise error.InputError(_(b'empty revision set'))
     return repo[l.last()]
 
 
@@ -710,7 +710,7 @@
     l = revrange(repo, revs)
 
     if not l:
-        raise error.Abort(_(b'empty revision range'))
+        raise error.InputError(_(b'empty revision range'))
 
     first = l.first()
     second = l.last()
@@ -720,7 +720,7 @@
         and len(revs) >= 2
         and not all(revrange(repo, [r]) for r in revs)
     ):
-        raise error.Abort(_(b'empty revision on one side of range'))
+        raise error.InputError(_(b'empty revision on one side of range'))
 
     # if top-level is range expression, the result must always be a pair
     if first == second and len(revs) == 1 and not _pairspec(revs[0]):
@@ -1211,9 +1211,9 @@
     try:
         similarity = float(opts.get(b'similarity') or 0)
     except ValueError:
-        raise error.Abort(_(b'similarity must be a number'))
+        raise error.InputError(_(b'similarity must be a number'))
     if similarity < 0 or similarity > 100:
-        raise error.Abort(_(b'similarity must be between 0 and 100'))
+        raise error.InputError(_(b'similarity must be between 0 and 100'))
     similarity /= 100.0
 
     ret = 0
@@ -1327,17 +1327,17 @@
         full=False,
     )
     for abs, st in pycompat.iteritems(walkresults):
-        dstate = dirstate[abs]
-        if dstate == b'?' and audit_path.check(abs):
+        entry = dirstate.get_entry(abs)
+        if (not entry.any_tracked) and audit_path.check(abs):
             unknown.append(abs)
-        elif dstate != b'r' and not st:
+        elif (not entry.removed) and not st:
             deleted.append(abs)
-        elif dstate == b'r' and st:
+        elif entry.removed and st:
             forgotten.append(abs)
         # for finding renames
-        elif dstate == b'r' and not st:
+        elif entry.removed and not st:
             removed.append(abs)
-        elif dstate == b'a':
+        elif entry.added:
             added.append(abs)
 
     return added, unknown, deleted, removed, forgotten
@@ -1455,10 +1455,11 @@
     """
     origsrc = repo.dirstate.copied(src) or src
     if dst == origsrc:  # copying back a copy?
-        if repo.dirstate[dst] not in b'mn' and not dryrun:
+        entry = repo.dirstate.get_entry(dst)
+        if (entry.added or not entry.tracked) and not dryrun:
             repo.dirstate.set_tracked(dst)
     else:
-        if repo.dirstate[origsrc] == b'a' and origsrc == src:
+        if repo.dirstate.get_entry(origsrc).added and origsrc == src:
             if not ui.quiet:
                 ui.warn(
                     _(
@@ -1467,7 +1468,7 @@
                     )
                     % (repo.pathto(origsrc, cwd), repo.pathto(dst, cwd))
                 )
-            if repo.dirstate[dst] in b'?r' and not dryrun:
+            if not repo.dirstate.get_entry(dst).tracked and not dryrun:
                 wctx.add([dst])
         elif not dryrun:
             wctx.copy(origsrc, dst)
@@ -1504,7 +1505,7 @@
     }
     # Adjust the dirstate copies
     for dst, src in pycompat.iteritems(copies):
-        if src not in newctx or dst in newctx or ds[dst] != b'a':
+        if src not in newctx or dst in newctx or not ds.get_entry(dst).added:
             src = None
         ds.copy(src, dst)
     repo._quick_access_changeid_invalidate()
--- a/mercurial/store.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/store.py	Thu Oct 21 17:25:41 2021 +0200
@@ -472,7 +472,7 @@
         return self.path + b'/' + encodedir(f)
 
     def _walk(self, relpath, recurse):
-        '''yields (unencoded, encoded, size)'''
+        '''yields (revlog_type, unencoded, size)'''
         path = self.path
         if relpath:
             path += b'/' + relpath
@@ -488,7 +488,7 @@
                     rl_type = is_revlog(f, kind, st)
                     if rl_type is not None:
                         n = util.pconvert(fp[striplen:])
-                        l.append((rl_type, decodedir(n), n, st.st_size))
+                        l.append((rl_type, decodedir(n), st.st_size))
                     elif kind == stat.S_IFDIR and recurse:
                         visit.append(fp)
         l.sort()
@@ -505,26 +505,32 @@
         rootstore = manifest.manifestrevlog(repo.nodeconstants, self.vfs)
         return manifest.manifestlog(self.vfs, repo, rootstore, storenarrowmatch)
 
-    def datafiles(self, matcher=None):
+    def datafiles(self, matcher=None, undecodable=None):
+        """Like walk, but excluding the changelog and root manifest.
+
+        When [undecodable] is None, revlogs names that can't be
+        decoded cause an exception. When it is provided, it should
+        be a list and the filenames that can't be decoded are added
+        to it instead. This is very rarely needed."""
         files = self._walk(b'data', True) + self._walk(b'meta', True)
-        for (t, u, e, s) in files:
-            yield (FILEFLAGS_FILELOG | t, u, e, s)
+        for (t, u, s) in files:
+            yield (FILEFLAGS_FILELOG | t, u, s)
 
     def topfiles(self):
         # yield manifest before changelog
         files = reversed(self._walk(b'', False))
-        for (t, u, e, s) in files:
+        for (t, u, s) in files:
             if u.startswith(b'00changelog'):
-                yield (FILEFLAGS_CHANGELOG | t, u, e, s)
+                yield (FILEFLAGS_CHANGELOG | t, u, s)
             elif u.startswith(b'00manifest'):
-                yield (FILEFLAGS_MANIFESTLOG | t, u, e, s)
+                yield (FILEFLAGS_MANIFESTLOG | t, u, s)
             else:
-                yield (FILETYPE_OTHER | t, u, e, s)
+                yield (FILETYPE_OTHER | t, u, s)
 
     def walk(self, matcher=None):
         """return file related to data storage (ie: revlogs)
 
-        yields (file_type, unencoded, encoded, size)
+        yields (file_type, unencoded, size)
 
         if a matcher is passed, storage files of only those tracked paths
         are passed with matches the matcher
@@ -574,15 +580,20 @@
     # However that might change so we should probably add a test and encoding
     # decoding for it too. see issue6548
 
-    def datafiles(self, matcher=None):
-        for t, a, b, size in super(encodedstore, self).datafiles():
+    def datafiles(self, matcher=None, undecodable=None):
+        for t, f1, size in super(encodedstore, self).datafiles():
             try:
-                a = decodefilename(a)
+                f2 = decodefilename(f1)
             except KeyError:
-                a = None
-            if a is not None and not _matchtrackedpath(a, matcher):
+                if undecodable is None:
+                    msg = _(b'undecodable revlog name %s') % f1
+                    raise error.StorageError(msg)
+                else:
+                    undecodable.append(f1)
+                    continue
+            if not _matchtrackedpath(f2, matcher):
                 continue
-            yield t, a, b, size
+            yield t, f2, size
 
     def join(self, f):
         return self.path + b'/' + encodefilename(f)
@@ -770,7 +781,7 @@
     def getsize(self, path):
         return self.rawvfs.stat(path).st_size
 
-    def datafiles(self, matcher=None):
+    def datafiles(self, matcher=None, undecodable=None):
         for f in sorted(self.fncache):
             if not _matchtrackedpath(f, matcher):
                 continue
@@ -779,7 +790,7 @@
                 t = revlog_type(f)
                 assert t is not None, f
                 t |= FILEFLAGS_FILELOG
-                yield t, f, ef, self.getsize(ef)
+                yield t, f, self.getsize(ef)
             except OSError as err:
                 if err.errno != errno.ENOENT:
                     raise
--- a/mercurial/streamclone.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/streamclone.py	Thu Oct 21 17:25:41 2021 +0200
@@ -248,7 +248,7 @@
     # Get consistent snapshot of repo, lock during scan.
     with repo.lock():
         repo.ui.debug(b'scanning\n')
-        for file_type, name, ename, size in _walkstreamfiles(repo):
+        for file_type, name, size in _walkstreamfiles(repo):
             if size:
                 entries.append((name, size))
                 total_bytes += size
@@ -650,7 +650,7 @@
     if includes or excludes:
         matcher = narrowspec.match(repo.root, includes, excludes)
 
-    for rl_type, name, ename, size in _walkstreamfiles(repo, matcher):
+    for rl_type, name, size in _walkstreamfiles(repo, matcher):
         if size:
             ft = _fileappend
             if rl_type & store.FILEFLAGS_VOLATILE:
--- a/mercurial/strip.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/strip.py	Thu Oct 21 17:25:41 2021 +0200
@@ -8,6 +8,7 @@
     error,
     hg,
     lock as lockmod,
+    logcmdutil,
     mergestate as mergestatemod,
     pycompat,
     registrar,
@@ -178,7 +179,7 @@
 
     cl = repo.changelog
     revs = list(revs) + opts.get(b'rev')
-    revs = set(scmutil.revrange(repo, revs))
+    revs = set(logcmdutil.revrange(repo, revs))
 
     with repo.wlock():
         bookmarks = set(opts.get(b'bookmark'))
@@ -255,7 +256,9 @@
 
             # reset files that only changed in the dirstate too
             dirstate = repo.dirstate
-            dirchanges = [f for f in dirstate if dirstate[f] != b'n']
+            dirchanges = [
+                f for f in dirstate if not dirstate.get_entry(f).maybe_clean
+            ]
             changedfiles.extend(dirchanges)
 
             repo.dirstate.rebuild(urev, uctx.manifest(), changedfiles)
--- a/mercurial/upgrade_utils/engine.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/upgrade_utils/engine.py	Thu Oct 21 17:25:41 2021 +0200
@@ -201,7 +201,7 @@
 
     # Perform a pass to collect metadata. This validates we can open all
     # source files and allows a unified progress bar to be displayed.
-    for rl_type, unencoded, encoded, size in alldatafiles:
+    for rl_type, unencoded, size in alldatafiles:
         if not rl_type & store.FILEFLAGS_REVLOG_MAIN:
             continue
 
@@ -638,7 +638,6 @@
         )
 
     assert srcrepo.dirstate._use_dirstate_v2 == (old == b'v2')
-    srcrepo.dirstate._map._use_dirstate_tree = True
     srcrepo.dirstate._map.preload()
     srcrepo.dirstate._use_dirstate_v2 = new == b'v2'
     srcrepo.dirstate._map._use_dirstate_v2 = srcrepo.dirstate._use_dirstate_v2
--- a/mercurial/util.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/util.py	Thu Oct 21 17:25:41 2021 +0200
@@ -449,8 +449,8 @@
         return b''
     elif size is None:
         size = 0
+    fd = getattr(fp, 'fileno', lambda: fp)()
     try:
-        fd = getattr(fp, 'fileno', lambda: fp)()
         return mmap.mmap(fd, size, access=mmap.ACCESS_READ)
     except ValueError:
         # Empty files cannot be mmapped, but mmapread should still work.  Check
@@ -1225,6 +1225,8 @@
     if n == 4:
         return (vints[0], vints[1], vints[2], extra)
 
+    raise error.ProgrammingError(b"invalid version part request: %d" % n)
+
 
 def cachefunc(func):
     '''cache the result of function calls'''
--- a/mercurial/utils/resourceutil.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/utils/resourceutil.py	Thu Oct 21 17:25:41 2021 +0200
@@ -57,30 +57,11 @@
 try:
     # importlib.resources exists from Python 3.7; see fallback in except clause
     # further down
-    from importlib import resources
-
-    from .. import encoding
+    from importlib import resources  # pytype: disable=import-error
 
     # Force loading of the resources module
     resources.open_binary  # pytype: disable=module-attr
 
-    def open_resource(package, name):
-        return resources.open_binary(  # pytype: disable=module-attr
-            pycompat.sysstr(package), pycompat.sysstr(name)
-        )
-
-    def is_resource(package, name):
-        return resources.is_resource(  # pytype: disable=module-attr
-            pycompat.sysstr(package), encoding.strfromlocal(name)
-        )
-
-    def contents(package):
-        # pytype: disable=module-attr
-        for r in resources.contents(pycompat.sysstr(package)):
-            # pytype: enable=module-attr
-            yield encoding.strtolocal(r)
-
-
 except (ImportError, AttributeError):
     # importlib.resources was not found (almost definitely because we're on a
     # Python version before 3.7)
@@ -102,3 +83,23 @@
 
         for p in os.listdir(path):
             yield pycompat.fsencode(p)
+
+
+else:
+    from .. import encoding
+
+    def open_resource(package, name):
+        return resources.open_binary(  # pytype: disable=module-attr
+            pycompat.sysstr(package), pycompat.sysstr(name)
+        )
+
+    def is_resource(package, name):
+        return resources.is_resource(  # pytype: disable=module-attr
+            pycompat.sysstr(package), encoding.strfromlocal(name)
+        )
+
+    def contents(package):
+        # pytype: disable=module-attr
+        for r in resources.contents(pycompat.sysstr(package)):
+            # pytype: enable=module-attr
+            yield encoding.strtolocal(r)
--- a/mercurial/utils/urlutil.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/utils/urlutil.py	Thu Oct 21 17:25:41 2021 +0200
@@ -503,22 +503,17 @@
                 yield path
 
 
-def get_pull_paths(repo, ui, sources, default_branches=()):
+def get_pull_paths(repo, ui, sources):
     """yields all the `(path, branch)` selected as pull source by `sources`"""
     if not sources:
         sources = [b'default']
     for source in sources:
         if source in ui.paths:
             for p in ui.paths[source]:
-                yield parseurl(p.rawloc, default_branches)
+                yield p
         else:
-            # Try to resolve as a local path or URI.
-            path = try_path(ui, source)
-            if path is not None:
-                url = path.rawloc
-            else:
-                url = source
-            yield parseurl(url, default_branches)
+            p = path(ui, None, source, validate_path=False)
+            yield p
 
 
 def get_unique_push_path(action, repo, ui, dest=None):
@@ -771,6 +766,28 @@
     return value
 
 
+SUPPORTED_BOOKMARKS_MODES = {
+    b'default',
+    b'mirror',
+    b'ignore',
+}
+
+
+@pathsuboption(b'bookmarks.mode', b'bookmarks_mode')
+def bookmarks_mode_option(ui, path, value):
+    if value not in SUPPORTED_BOOKMARKS_MODES:
+        path_name = path.name
+        if path_name is None:
+            # this is an "anonymous" path, config comes from the global one
+            path_name = b'*'
+        msg = _(b'(paths.%s:bookmarks.mode has unknown value: "%s")\n')
+        msg %= (path_name, value)
+        ui.warn(msg)
+    if value == b'default':
+        value = None
+    return value
+
+
 @pathsuboption(b'multi-urls', b'multi_urls')
 def multiurls_pathoption(ui, path, value):
     res = stringutil.parsebool(value)
@@ -818,7 +835,14 @@
 class path(object):
     """Represents an individual path and its configuration."""
 
-    def __init__(self, ui=None, name=None, rawloc=None, suboptions=None):
+    def __init__(
+        self,
+        ui=None,
+        name=None,
+        rawloc=None,
+        suboptions=None,
+        validate_path=True,
+    ):
         """Construct a path from its config options.
 
         ``ui`` is the ``ui`` instance the path is coming from.
@@ -856,7 +880,8 @@
         self.rawloc = rawloc
         self.loc = b'%s' % u
 
-        self._validate_path()
+        if validate_path:
+            self._validate_path()
 
         _path, sub_opts = ui.configsuboptions(b'paths', b'*')
         self._own_sub_opts = {}
--- a/mercurial/verify.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/verify.py	Thu Oct 21 17:25:41 2021 +0200
@@ -395,12 +395,13 @@
             storefiles = set()
             subdirs = set()
             revlogv1 = self.revlogv1
-            for t, f, f2, size in repo.store.datafiles():
-                if not f:
-                    self._err(None, _(b"cannot decode filename '%s'") % f2)
-                elif (size > 0 or not revlogv1) and f.startswith(b'meta/'):
+            undecodable = []
+            for t, f, size in repo.store.datafiles(undecodable=undecodable):
+                if (size > 0 or not revlogv1) and f.startswith(b'meta/'):
                     storefiles.add(_normpath(f))
                     subdirs.add(os.path.dirname(f))
+            for f in undecodable:
+                self._err(None, _(b"cannot decode filename '%s'") % f)
             subdirprogress = ui.makeprogress(
                 _(b'checking'), unit=_(b'manifests'), total=len(subdirs)
             )
@@ -459,11 +460,12 @@
         ui.status(_(b"checking files\n"))
 
         storefiles = set()
-        for rl_type, f, f2, size in repo.store.datafiles():
-            if not f:
-                self._err(None, _(b"cannot decode filename '%s'") % f2)
-            elif (size > 0 or not revlogv1) and f.startswith(b'data/'):
+        undecodable = []
+        for t, f, size in repo.store.datafiles(undecodable=undecodable):
+            if (size > 0 or not revlogv1) and f.startswith(b'data/'):
                 storefiles.add(_normpath(f))
+        for f in undecodable:
+            self._err(None, _(b"cannot decode filename '%s'") % f)
 
         state = {
             # TODO this assumes revlog storage for changelog.
--- a/mercurial/windows.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/windows.py	Thu Oct 21 17:25:41 2021 +0200
@@ -175,7 +175,7 @@
             return mixedfilemodewrapper(fp)
 
         return fp
-    except WindowsError as err:
+    except WindowsError as err:  # pytype: disable=name-error
         # convert to a friendlier exception
         raise IOError(
             err.errno, '%s: %s' % (encoding.strfromlocal(name), err.strerror)
--- a/mercurial/wireprotov1peer.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/wireprotov1peer.py	Thu Oct 21 17:25:41 2021 +0200
@@ -44,13 +44,9 @@
     def sample(self, one, two=None):
         # Build list of encoded arguments suitable for your wire protocol:
         encoded_args = [('one', encode(one),), ('two', encode(two),)]
-        # Create future for injection of encoded result:
-        encoded_res_future = future()
-        # Return encoded arguments and future:
-        yield encoded_args, encoded_res_future
-        # Assuming the future to be filled with the result from the batched
-        # request now. Decode it:
-        yield decode(encoded_res_future.value)
+        # Return it, along with a function that will receive the result
+        # from the batched request.
+        return encoded_args, decode
 
     The decorator returns a function which wraps this coroutine as a plain
     method, but adds the original method as an attribute called "batchable",
@@ -59,29 +55,19 @@
     """
 
     def plain(*args, **opts):
-        batchable = f(*args, **opts)
-        encoded_args_or_res, encoded_res_future = next(batchable)
-        if not encoded_res_future:
+        encoded_args_or_res, decode = f(*args, **opts)
+        if not decode:
             return encoded_args_or_res  # a local result in this case
         self = args[0]
         cmd = pycompat.bytesurl(f.__name__)  # ensure cmd is ascii bytestr
-        encoded_res_future.set(self._submitone(cmd, encoded_args_or_res))
-        return next(batchable)
+        encoded_res = self._submitone(cmd, encoded_args_or_res)
+        return decode(encoded_res)
 
     setattr(plain, 'batchable', f)
     setattr(plain, '__name__', f.__name__)
     return plain
 
 
-class future(object):
-    '''placeholder for a value to be set later'''
-
-    def set(self, value):
-        if util.safehasattr(self, b'value'):
-            raise error.RepoError(b"future is already set")
-        self.value = value
-
-
 def encodebatchcmds(req):
     """Return a ``cmds`` argument value for the ``batch`` command."""
     escapearg = wireprototypes.escapebatcharg
@@ -248,25 +234,18 @@
                 continue
 
             try:
-                batchable = fn.batchable(
+                encoded_args_or_res, decode = fn.batchable(
                     fn.__self__, **pycompat.strkwargs(args)
                 )
             except Exception:
                 pycompat.future_set_exception_info(f, sys.exc_info()[1:])
                 return
 
-            # Encoded arguments and future holding remote result.
-            try:
-                encoded_args_or_res, fremote = next(batchable)
-            except Exception:
-                pycompat.future_set_exception_info(f, sys.exc_info()[1:])
-                return
-
-            if not fremote:
+            if not decode:
                 f.set_result(encoded_args_or_res)
             else:
                 requests.append((command, encoded_args_or_res))
-                states.append((command, f, batchable, fremote))
+                states.append((command, f, batchable, decode))
 
         if not requests:
             return
@@ -319,7 +298,7 @@
     def _readbatchresponse(self, states, wireresults):
         # Executes in a thread to read data off the wire.
 
-        for command, f, batchable, fremote in states:
+        for command, f, batchable, decode in states:
             # Grab raw result off the wire and teach the internal future
             # about it.
             try:
@@ -334,11 +313,8 @@
                     )
                 )
             else:
-                fremote.set(remoteresult)
-
-                # And ask the coroutine to decode that value.
                 try:
-                    result = next(batchable)
+                    result = decode(remoteresult)
                 except Exception:
                     pycompat.future_set_exception_info(f, sys.exc_info()[1:])
                 else:
@@ -369,87 +345,90 @@
     @batchable
     def lookup(self, key):
         self.requirecap(b'lookup', _(b'look up remote revision'))
-        f = future()
-        yield {b'key': encoding.fromlocal(key)}, f
-        d = f.value
-        success, data = d[:-1].split(b" ", 1)
-        if int(success):
-            yield bin(data)
-        else:
-            self._abort(error.RepoError(data))
+
+        def decode(d):
+            success, data = d[:-1].split(b" ", 1)
+            if int(success):
+                return bin(data)
+            else:
+                self._abort(error.RepoError(data))
+
+        return {b'key': encoding.fromlocal(key)}, decode
 
     @batchable
     def heads(self):
-        f = future()
-        yield {}, f
-        d = f.value
-        try:
-            yield wireprototypes.decodelist(d[:-1])
-        except ValueError:
-            self._abort(error.ResponseError(_(b"unexpected response:"), d))
+        def decode(d):
+            try:
+                return wireprototypes.decodelist(d[:-1])
+            except ValueError:
+                self._abort(error.ResponseError(_(b"unexpected response:"), d))
+
+        return {}, decode
 
     @batchable
     def known(self, nodes):
-        f = future()
-        yield {b'nodes': wireprototypes.encodelist(nodes)}, f
-        d = f.value
-        try:
-            yield [bool(int(b)) for b in pycompat.iterbytestr(d)]
-        except ValueError:
-            self._abort(error.ResponseError(_(b"unexpected response:"), d))
+        def decode(d):
+            try:
+                return [bool(int(b)) for b in pycompat.iterbytestr(d)]
+            except ValueError:
+                self._abort(error.ResponseError(_(b"unexpected response:"), d))
+
+        return {b'nodes': wireprototypes.encodelist(nodes)}, decode
 
     @batchable
     def branchmap(self):
-        f = future()
-        yield {}, f
-        d = f.value
-        try:
-            branchmap = {}
-            for branchpart in d.splitlines():
-                branchname, branchheads = branchpart.split(b' ', 1)
-                branchname = encoding.tolocal(urlreq.unquote(branchname))
-                branchheads = wireprototypes.decodelist(branchheads)
-                branchmap[branchname] = branchheads
-            yield branchmap
-        except TypeError:
-            self._abort(error.ResponseError(_(b"unexpected response:"), d))
+        def decode(d):
+            try:
+                branchmap = {}
+                for branchpart in d.splitlines():
+                    branchname, branchheads = branchpart.split(b' ', 1)
+                    branchname = encoding.tolocal(urlreq.unquote(branchname))
+                    branchheads = wireprototypes.decodelist(branchheads)
+                    branchmap[branchname] = branchheads
+                return branchmap
+            except TypeError:
+                self._abort(error.ResponseError(_(b"unexpected response:"), d))
+
+        return {}, decode
 
     @batchable
     def listkeys(self, namespace):
         if not self.capable(b'pushkey'):
-            yield {}, None
-        f = future()
+            return {}, None
         self.ui.debug(b'preparing listkeys for "%s"\n' % namespace)
-        yield {b'namespace': encoding.fromlocal(namespace)}, f
-        d = f.value
-        self.ui.debug(
-            b'received listkey for "%s": %i bytes\n' % (namespace, len(d))
-        )
-        yield pushkeymod.decodekeys(d)
+
+        def decode(d):
+            self.ui.debug(
+                b'received listkey for "%s": %i bytes\n' % (namespace, len(d))
+            )
+            return pushkeymod.decodekeys(d)
+
+        return {b'namespace': encoding.fromlocal(namespace)}, decode
 
     @batchable
     def pushkey(self, namespace, key, old, new):
         if not self.capable(b'pushkey'):
-            yield False, None
-        f = future()
+            return False, None
         self.ui.debug(b'preparing pushkey for "%s:%s"\n' % (namespace, key))
-        yield {
+
+        def decode(d):
+            d, output = d.split(b'\n', 1)
+            try:
+                d = bool(int(d))
+            except ValueError:
+                raise error.ResponseError(
+                    _(b'push failed (unexpected response):'), d
+                )
+            for l in output.splitlines(True):
+                self.ui.status(_(b'remote: '), l)
+            return d
+
+        return {
             b'namespace': encoding.fromlocal(namespace),
             b'key': encoding.fromlocal(key),
             b'old': encoding.fromlocal(old),
             b'new': encoding.fromlocal(new),
-        }, f
-        d = f.value
-        d, output = d.split(b'\n', 1)
-        try:
-            d = bool(int(d))
-        except ValueError:
-            raise error.ResponseError(
-                _(b'push failed (unexpected response):'), d
-            )
-        for l in output.splitlines(True):
-            self.ui.status(_(b'remote: '), l)
-        yield d
+        }, decode
 
     def stream_out(self):
         return self._callstream(b'stream_out')
--- a/mercurial/wireprotov2server.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/mercurial/wireprotov2server.py	Thu Oct 21 17:25:41 2021 +0200
@@ -1579,7 +1579,7 @@
 
     # TODO this is a bunch of storage layer interface abstractions because
     # it assumes revlogs.
-    for rl_type, name, encodedname, size in topfiles:
+    for rl_type, name, size in topfiles:
         # XXX use the `rl_type` for that
         if b'changelog' in files and name.startswith(b'00changelog'):
             pass
--- a/relnotes/next	Thu Oct 21 14:03:33 2021 +0200
+++ b/relnotes/next	Thu Oct 21 17:25:41 2021 +0200
@@ -1,6 +1,10 @@
 == New Features ==
   * `debugrebuildfncache` now has an option to rebuild only the index files
 
+  * a new `bookmarks.mode` path option have been introduced to control the
+    bookmark update strategy during exchange with a peer. See hg help paths for
+    details.
+
 
 == Default Format Change ==
 
--- a/rust/Cargo.lock	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/Cargo.lock	Thu Oct 21 17:25:41 2021 +0200
@@ -157,9 +157,9 @@
 
 [[package]]
 name = "cpython"
-version = "0.6.0"
+version = "0.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8094679a4e9bfc8035572162624bc800eda35b5f9eff2537b9cd9aacc3d9782e"
+checksum = "b7d46ba8ace7f3a1d204ac5060a706d0a68de6b42eafb6a586cc08bebcffe664"
 dependencies = [
  "libc",
  "num-traits",
@@ -374,6 +374,7 @@
 name = "hg-core"
 version = "0.1.0"
 dependencies = [
+ "bitflags",
  "byteorder",
  "bytes-cast",
  "clap",
@@ -385,8 +386,9 @@
  "im-rc",
  "itertools",
  "lazy_static",
+ "libc",
  "log",
- "memmap",
+ "memmap2",
  "micro-timer",
  "pretty_assertions",
  "rand",
@@ -396,6 +398,7 @@
  "regex",
  "same-file",
  "sha-1",
+ "stable_deref_trait",
  "tempfile",
  "twox-hash",
  "zstd",
@@ -411,6 +414,7 @@
  "hg-core",
  "libc",
  "log",
+ "stable_deref_trait",
 ]
 
 [[package]]
@@ -508,13 +512,13 @@
 checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525"
 
 [[package]]
-name = "memmap"
-version = "0.7.0"
+name = "memmap2"
+version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6585fd95e7bb50d6cc31e20d4cf9afb4e2ba16c5846fc76793f11218da9c475b"
+checksum = "de5d3112c080d58ce560081baeaab7e1e864ca21795ddbf533d5b1842bb1ecf8"
 dependencies = [
  "libc",
- "winapi",
+ "stable_deref_trait",
 ]
 
 [[package]]
@@ -649,9 +653,9 @@
 
 [[package]]
 name = "python27-sys"
-version = "0.6.0"
+version = "0.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5826ddbc5366eb0b0492040fdc25bf50bb49092c192bd45e80fb7a24dc6832ab"
+checksum = "94670354e264300dde81a5864cbb6bfc9d56ac3dcf3a278c32cb52f816f4dfd1"
 dependencies = [
  "libc",
  "regex",
@@ -659,9 +663,9 @@
 
 [[package]]
 name = "python3-sys"
-version = "0.6.0"
+version = "0.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b78af21b29594951a47fc3dac9b9eff0a3f077dec2f780ee943ae16a668f3b6a"
+checksum = "b18b32e64c103d5045f44644d7ddddd65336f7a0521f6fde673240a9ecceb77e"
 dependencies = [
  "libc",
  "regex",
@@ -865,6 +869,12 @@
 ]
 
 [[package]]
+name = "stable_deref_trait"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
+
+[[package]]
 name = "static_assertions"
 version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
--- a/rust/hg-core/Cargo.toml	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/Cargo.toml	Thu Oct 21 17:25:41 2021 +0200
@@ -9,6 +9,7 @@
 name = "hg"
 
 [dependencies]
+bitflags = "1.2"
 bytes-cast = "0.2"
 byteorder = "1.3.4"
 derive_more = "0.99"
@@ -16,6 +17,7 @@
 im-rc = "15.0.*"
 itertools = "0.9"
 lazy_static = "1.4.0"
+libc = "0.2"
 rand = "0.7.3"
 rand_pcg = "0.2.1"
 rand_distr = "0.2.2"
@@ -24,11 +26,12 @@
 sha-1 = "0.9.6"
 twox-hash = "1.5.0"
 same-file = "1.0.6"
+stable_deref_trait = "1.2.0"
 tempfile = "3.1.0"
 crossbeam-channel = "0.4"
 micro-timer = "0.3.0"
 log = "0.4.8"
-memmap = "0.7.0"
+memmap2 = {version = "0.4", features = ["stable_deref_trait"]}
 zstd = "0.5.3"
 format-bytes = "0.2.2"
 
--- a/rust/hg-core/examples/nodemap/index.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/examples/nodemap/index.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -5,7 +5,7 @@
 
 //! Minimal `RevlogIndex`, readable from standard Mercurial file format
 use hg::*;
-use memmap::*;
+use memmap2::*;
 use std::fs::File;
 use std::ops::Deref;
 use std::path::Path;
--- a/rust/hg-core/examples/nodemap/main.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/examples/nodemap/main.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -7,7 +7,7 @@
 use hg::revlog::node::*;
 use hg::revlog::nodemap::*;
 use hg::revlog::*;
-use memmap::MmapOptions;
+use memmap2::MmapOptions;
 use rand::Rng;
 use std::fs::File;
 use std::io;
--- a/rust/hg-core/src/config/config.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/config/config.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -13,7 +13,6 @@
     ConfigError, ConfigLayer, ConfigOrigin, ConfigValue,
 };
 use crate::utils::files::get_bytes_from_os_str;
-use crate::utils::SliceExt;
 use format_bytes::{write_bytes, DisplayBytes};
 use std::collections::HashSet;
 use std::env;
@@ -362,30 +361,14 @@
         Ok(self.get_option(section, item)?.unwrap_or(false))
     }
 
-    /// Returns the corresponding list-value in the config if found, or `None`.
-    ///
-    /// This is appropriate for new configuration keys. The value syntax is
-    /// **not** the same as most existing list-valued config, which has Python
-    /// parsing implemented in `parselist()` in
-    /// `mercurial/utils/stringutil.py`. Faithfully porting that parsing
-    /// algorithm to Rust (including behavior that are arguably bugs)
-    /// turned out to be non-trivial and hasn’t been completed as of this
-    /// writing.
-    ///
-    /// Instead, the "simple" syntax is: split on comma, then trim leading and
-    /// trailing whitespace of each component. Quotes or backslashes are not
-    /// interpreted in any way. Commas are mandatory between values. Values
-    /// that contain a comma are not supported.
-    pub fn get_simple_list(
+    /// If there is an `item` value in `section`, parse and return a list of
+    /// byte strings.
+    pub fn get_list(
         &self,
         section: &[u8],
         item: &[u8],
-    ) -> Option<impl Iterator<Item = &[u8]>> {
-        self.get(section, item).map(|value| {
-            value
-                .split(|&byte| byte == b',')
-                .map(|component| component.trim())
-        })
+    ) -> Option<Vec<Vec<u8>>> {
+        self.get(section, item).map(values::parse_list)
     }
 
     /// Returns the raw value bytes of the first one found, or `None`.
--- a/rust/hg-core/src/config/values.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/config/values.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -8,6 +8,8 @@
 //! details about where the value came from (but omits details of what’s
 //! invalid inside the value).
 
+use crate::utils::SliceExt;
+
 pub(super) fn parse_bool(v: &[u8]) -> Option<bool> {
     match v.to_ascii_lowercase().as_slice() {
         b"1" | b"yes" | b"true" | b"on" | b"always" => Some(true),
@@ -42,6 +44,216 @@
     value.parse().ok()
 }
 
+/// Parse a config value as a list of sub-values.
+///
+/// Ported from `parselist` in `mercurial/utils/stringutil.py`
+
+// Note: keep behavior in sync with the Python one.
+
+// Note: this could return `Vec<Cow<[u8]>>` instead and borrow `input` when
+// possible (when there’s no backslash-escapes) but this is probably not worth
+// the complexity as config is presumably not accessed inside
+// preformance-sensitive loops.
+pub(super) fn parse_list(input: &[u8]) -> Vec<Vec<u8>> {
+    // Port of Python’s `value.lstrip(b' ,\n')`
+    // TODO: is this really what we want?
+    let input =
+        input.trim_start_matches(|b| b == b' ' || b == b',' || b == b'\n');
+    parse_list_without_trim_start(input)
+}
+
+fn parse_list_without_trim_start(input: &[u8]) -> Vec<Vec<u8>> {
+    // Start of port of Python’s `_configlist`
+    let input = input.trim_end_matches(|b| b == b' ' || b == b',');
+    if input.is_empty() {
+        return Vec::new();
+    }
+
+    // Just to make “a string” less confusable with “a list of strings”.
+    type ByteString = Vec<u8>;
+
+    // These correspond to Python’s…
+    let mut mode = ParserMode::Plain; // `parser`
+    let mut values = Vec::new(); // `parts[:-1]`
+    let mut next_value = ByteString::new(); // `parts[-1]`
+    let mut offset = 0; // `offset`
+
+    // Setting `parser` to `None` is instead handled by returning immediately
+    enum ParserMode {
+        Plain,
+        Quoted,
+    }
+
+    loop {
+        match mode {
+            ParserMode::Plain => {
+                // Start of port of Python’s `_parse_plain`
+                let mut whitespace = false;
+                while let Some(&byte) = input.get(offset) {
+                    if is_space(byte) || byte == b',' {
+                        whitespace = true;
+                        offset += 1;
+                    } else {
+                        break;
+                    }
+                }
+                if let Some(&byte) = input.get(offset) {
+                    if whitespace {
+                        values.push(std::mem::take(&mut next_value))
+                    }
+                    if byte == b'"' && next_value.is_empty() {
+                        mode = ParserMode::Quoted;
+                    } else {
+                        if byte == b'"' && next_value.ends_with(b"\\") {
+                            next_value.pop();
+                        }
+                        next_value.push(byte);
+                    }
+                    offset += 1;
+                } else {
+                    values.push(next_value);
+                    return values;
+                }
+            }
+            ParserMode::Quoted => {
+                // Start of port of Python’s `_parse_quote`
+                if let Some(&byte) = input.get(offset) {
+                    if byte == b'"' {
+                        // The input contains a quoted zero-length value `""`
+                        debug_assert_eq!(next_value, b"");
+                        values.push(std::mem::take(&mut next_value));
+                        offset += 1;
+                        while let Some(&byte) = input.get(offset) {
+                            if is_space(byte) || byte == b',' {
+                                offset += 1;
+                            } else {
+                                break;
+                            }
+                        }
+                        mode = ParserMode::Plain;
+                        continue;
+                    }
+                }
+
+                while let Some(&byte) = input.get(offset) {
+                    if byte == b'"' {
+                        break;
+                    }
+                    if byte == b'\\' && input.get(offset + 1) == Some(&b'"') {
+                        next_value.push(b'"');
+                        offset += 2;
+                    } else {
+                        next_value.push(byte);
+                        offset += 1;
+                    }
+                }
+
+                if offset >= input.len() {
+                    // We didn’t find a closing double-quote,
+                    // so treat the opening one as part of an unquoted value
+                    // instead of delimiting the start of a quoted value.
+
+                    // `next_value` may have had some backslash-escapes
+                    // unescaped. TODO: shouldn’t we use a slice of `input`
+                    // instead?
+                    let mut real_values =
+                        parse_list_without_trim_start(&next_value);
+
+                    if let Some(first) = real_values.first_mut() {
+                        first.insert(0, b'"');
+                        // Drop `next_value`
+                        values.extend(real_values)
+                    } else {
+                        next_value.push(b'"');
+                        values.push(next_value);
+                    }
+                    return values;
+                }
+
+                // We’re not at the end of the input, which means the `while`
+                // loop above ended at at double quote. Skip
+                // over that.
+                offset += 1;
+
+                while let Some(&byte) = input.get(offset) {
+                    if byte == b' ' || byte == b',' {
+                        offset += 1;
+                    } else {
+                        break;
+                    }
+                }
+
+                if offset >= input.len() {
+                    values.push(next_value);
+                    return values;
+                }
+
+                if offset + 1 == input.len() && input[offset] == b'"' {
+                    next_value.push(b'"');
+                    offset += 1;
+                } else {
+                    values.push(std::mem::take(&mut next_value));
+                }
+
+                mode = ParserMode::Plain;
+            }
+        }
+    }
+
+    // https://docs.python.org/3/library/stdtypes.html?#bytes.isspace
+    fn is_space(byte: u8) -> bool {
+        if let b' ' | b'\t' | b'\n' | b'\r' | b'\x0b' | b'\x0c' = byte {
+            true
+        } else {
+            false
+        }
+    }
+}
+
+#[test]
+fn test_parse_list() {
+    // Make `assert_eq` error messages nicer
+    fn as_strings(values: &[Vec<u8>]) -> Vec<String> {
+        values
+            .iter()
+            .map(|v| std::str::from_utf8(v.as_ref()).unwrap().to_owned())
+            .collect()
+    }
+    macro_rules! assert_parse_list {
+        ( $input: expr => [ $( $output: expr ),* ] ) => {
+            assert_eq!(
+                as_strings(&parse_list($input)),
+                as_strings(&[ $( Vec::from(&$output[..]) ),* ]),
+            );
+        }
+    }
+
+    // Keep these Rust tests in sync with the Python ones in
+    // `tests/test-config-parselist.py`
+    assert_parse_list!(b"" => []);
+    assert_parse_list!(b"," => []);
+    assert_parse_list!(b"A" => [b"A"]);
+    assert_parse_list!(b"B,B" => [b"B", b"B"]);
+    assert_parse_list!(b", C, ,C," => [b"C", b"C"]);
+    assert_parse_list!(b"\"" => [b"\""]);
+    assert_parse_list!(b"\"\"" => [b"", b""]);
+    assert_parse_list!(b"D,\"" => [b"D", b"\""]);
+    assert_parse_list!(b"E,\"\"" => [b"E", b"", b""]);
+    assert_parse_list!(b"\"F,F\"" => [b"F,F"]);
+    assert_parse_list!(b"\"G,G" => [b"\"G", b"G"]);
+    assert_parse_list!(b"\"H \\\",\\\"H" => [b"\"H", b",", b"H"]);
+    assert_parse_list!(b"I,I\"" => [b"I", b"I\""]);
+    assert_parse_list!(b"J,\"J" => [b"J", b"\"J"]);
+    assert_parse_list!(b"K K" => [b"K", b"K"]);
+    assert_parse_list!(b"\"K\" K" => [b"K", b"K"]);
+    assert_parse_list!(b"L\tL" => [b"L", b"L"]);
+    assert_parse_list!(b"\"L\"\tL" => [b"L", b"", b"L"]);
+    assert_parse_list!(b"M\x0bM" => [b"M", b"M"]);
+    assert_parse_list!(b"\"M\"\x0bM" => [b"M", b"", b"M"]);
+    assert_parse_list!(b"\"N\"  , ,\"" => [b"N\""]);
+    assert_parse_list!(b"\" ,O,  " => [b"\"", b"O"]);
+}
+
 #[test]
 fn test_parse_byte_size() {
     assert_eq!(parse_byte_size(b""), None);
--- a/rust/hg-core/src/dirstate.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/dirstate.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -6,20 +6,19 @@
 // GNU General Public License version 2 or any later version.
 
 use crate::dirstate_tree::on_disk::DirstateV2ParseError;
-use crate::errors::HgError;
 use crate::revlog::node::NULL_NODE;
 use crate::revlog::Node;
-use crate::utils::hg_path::{HgPath, HgPathBuf};
-use crate::FastHashMap;
-use bytes_cast::{unaligned, BytesCast};
-use std::convert::TryFrom;
+use crate::utils::hg_path::HgPath;
+use bytes_cast::BytesCast;
 
 pub mod dirs_multiset;
-pub mod dirstate_map;
+pub mod entry;
 pub mod parsers;
 pub mod status;
 
-#[derive(Debug, PartialEq, Clone, BytesCast)]
+pub use self::entry::*;
+
+#[derive(Debug, PartialEq, Copy, Clone, BytesCast)]
 #[repr(C)]
 pub struct DirstateParents {
     pub p1: Node,
@@ -33,69 +32,6 @@
     };
 }
 
-/// The C implementation uses all signed types. This will be an issue
-/// either when 4GB+ source files are commonplace or in 2038, whichever
-/// comes first.
-#[derive(Debug, PartialEq, Copy, Clone)]
-pub struct DirstateEntry {
-    pub state: EntryState,
-    pub mode: i32,
-    pub mtime: i32,
-    pub size: i32,
-}
-
-impl DirstateEntry {
-    pub fn is_non_normal(&self) -> bool {
-        self.state != EntryState::Normal || self.mtime == MTIME_UNSET
-    }
-
-    pub fn is_from_other_parent(&self) -> bool {
-        self.state == EntryState::Normal && self.size == SIZE_FROM_OTHER_PARENT
-    }
-
-    // TODO: other platforms
-    #[cfg(unix)]
-    pub fn mode_changed(
-        &self,
-        filesystem_metadata: &std::fs::Metadata,
-    ) -> bool {
-        use std::os::unix::fs::MetadataExt;
-        const EXEC_BIT_MASK: u32 = 0o100;
-        let dirstate_exec_bit = (self.mode as u32) & EXEC_BIT_MASK;
-        let fs_exec_bit = filesystem_metadata.mode() & EXEC_BIT_MASK;
-        dirstate_exec_bit != fs_exec_bit
-    }
-
-    /// Returns a `(state, mode, size, mtime)` tuple as for
-    /// `DirstateMapMethods::debug_iter`.
-    pub fn debug_tuple(&self) -> (u8, i32, i32, i32) {
-        (self.state.into(), self.mode, self.size, self.mtime)
-    }
-}
-
-#[derive(BytesCast)]
-#[repr(C)]
-struct RawEntry {
-    state: u8,
-    mode: unaligned::I32Be,
-    size: unaligned::I32Be,
-    mtime: unaligned::I32Be,
-    length: unaligned::I32Be,
-}
-
-pub const V1_RANGEMASK: i32 = 0x7FFFFFFF;
-
-pub const MTIME_UNSET: i32 = -1;
-
-/// A `DirstateEntry` with a size of `-2` means that it was merged from the
-/// other parent. This allows revert to pick the right status back during a
-/// merge.
-pub const SIZE_FROM_OTHER_PARENT: i32 = -2;
-/// A special value used for internal representation of special case in
-/// dirstate v1 format.
-pub const SIZE_NON_NORMAL: i32 = -1;
-
-pub type StateMap = FastHashMap<HgPathBuf, DirstateEntry>;
 pub type StateMapIter<'a> = Box<
     dyn Iterator<
             Item = Result<(&'a HgPath, DirstateEntry), DirstateV2ParseError>,
@@ -103,58 +39,8 @@
         + 'a,
 >;
 
-pub type CopyMap = FastHashMap<HgPathBuf, HgPathBuf>;
 pub type CopyMapIter<'a> = Box<
     dyn Iterator<Item = Result<(&'a HgPath, &'a HgPath), DirstateV2ParseError>>
         + Send
         + 'a,
 >;
-
-#[derive(Copy, Clone, Debug, Eq, PartialEq)]
-pub enum EntryState {
-    Normal,
-    Added,
-    Removed,
-    Merged,
-    Unknown,
-}
-
-impl EntryState {
-    pub fn is_tracked(self) -> bool {
-        use EntryState::*;
-        match self {
-            Normal | Added | Merged => true,
-            Removed | Unknown => false,
-        }
-    }
-}
-
-impl TryFrom<u8> for EntryState {
-    type Error = HgError;
-
-    fn try_from(value: u8) -> Result<Self, Self::Error> {
-        match value {
-            b'n' => Ok(EntryState::Normal),
-            b'a' => Ok(EntryState::Added),
-            b'r' => Ok(EntryState::Removed),
-            b'm' => Ok(EntryState::Merged),
-            b'?' => Ok(EntryState::Unknown),
-            _ => Err(HgError::CorruptedRepository(format!(
-                "Incorrect dirstate entry state {}",
-                value
-            ))),
-        }
-    }
-}
-
-impl Into<u8> for EntryState {
-    fn into(self) -> u8 {
-        match self {
-            EntryState::Normal => b'n',
-            EntryState::Added => b'a',
-            EntryState::Removed => b'r',
-            EntryState::Merged => b'm',
-            EntryState::Unknown => b'?',
-        }
-    }
-}
--- a/rust/hg-core/src/dirstate/dirs_multiset.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/dirstate/dirs_multiset.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -33,7 +33,7 @@
     /// If `skip_state` is provided, skips dirstate entries with equal state.
     pub fn from_dirstate<I, P>(
         dirstate: I,
-        skip_state: Option<EntryState>,
+        only_tracked: bool,
     ) -> Result<Self, DirstateError>
     where
         I: IntoIterator<
@@ -48,8 +48,8 @@
             let (filename, entry) = item?;
             let filename = filename.as_ref();
             // This `if` is optimized out of the loop
-            if let Some(skip) = skip_state {
-                if skip != entry.state {
+            if only_tracked {
+                if entry.state() != EntryState::Removed {
                     multiset.add_path(filename)?;
                 }
             } else {
@@ -216,7 +216,6 @@
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::StateMap;
 
     #[test]
     fn test_delete_path_path_not_found() {
@@ -341,9 +340,9 @@
         };
         assert_eq!(expected, new);
 
-        let new = DirsMultiset::from_dirstate(
-            StateMap::default().into_iter().map(Ok),
-            None,
+        let new = DirsMultiset::from_dirstate::<_, HgPathBuf>(
+            std::iter::empty(),
+            false,
         )
         .unwrap();
         let expected = DirsMultiset {
@@ -372,12 +371,7 @@
         let input_map = ["b/x", "a/c", "a/d/x"].iter().map(|f| {
             Ok((
                 HgPathBuf::from_bytes(f.as_bytes()),
-                DirstateEntry {
-                    state: EntryState::Normal,
-                    mode: 0,
-                    mtime: 0,
-                    size: 0,
-                },
+                DirstateEntry::from_v1_data(EntryState::Normal, 0, 0, 0),
             ))
         });
         let expected_inner = [("", 2), ("a", 2), ("b", 1), ("a/d", 1)]
@@ -385,7 +379,7 @@
             .map(|(k, v)| (HgPathBuf::from_bytes(k.as_bytes()), *v))
             .collect();
 
-        let new = DirsMultiset::from_dirstate(input_map, None).unwrap();
+        let new = DirsMultiset::from_dirstate(input_map, false).unwrap();
         let expected = DirsMultiset {
             inner: expected_inner,
         };
@@ -404,24 +398,17 @@
         .map(|(f, state)| {
             Ok((
                 HgPathBuf::from_bytes(f.as_bytes()),
-                DirstateEntry {
-                    state: *state,
-                    mode: 0,
-                    mtime: 0,
-                    size: 0,
-                },
+                DirstateEntry::from_v1_data(*state, 0, 0, 0),
             ))
         });
 
         // "a" incremented with "a/c" and "a/d/"
-        let expected_inner = [("", 1), ("a", 2)]
+        let expected_inner = [("", 1), ("a", 3)]
             .iter()
             .map(|(k, v)| (HgPathBuf::from_bytes(k.as_bytes()), *v))
             .collect();
 
-        let new =
-            DirsMultiset::from_dirstate(input_map, Some(EntryState::Normal))
-                .unwrap();
+        let new = DirsMultiset::from_dirstate(input_map, true).unwrap();
         let expected = DirsMultiset {
             inner: expected_inner,
         };
--- a/rust/hg-core/src/dirstate/dirstate_map.rs	Thu Oct 21 14:03:33 2021 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,494 +0,0 @@
-// dirstate_map.rs
-//
-// Copyright 2019 Raphaël Gomès <rgomes@octobus.net>
-//
-// This software may be used and distributed according to the terms of the
-// GNU General Public License version 2 or any later version.
-
-use crate::dirstate::parsers::Timestamp;
-use crate::{
-    dirstate::EntryState,
-    dirstate::MTIME_UNSET,
-    dirstate::SIZE_FROM_OTHER_PARENT,
-    dirstate::SIZE_NON_NORMAL,
-    dirstate::V1_RANGEMASK,
-    pack_dirstate, parse_dirstate,
-    utils::hg_path::{HgPath, HgPathBuf},
-    CopyMap, DirsMultiset, DirstateEntry, DirstateError, DirstateParents,
-    StateMap,
-};
-use micro_timer::timed;
-use std::collections::HashSet;
-use std::iter::FromIterator;
-use std::ops::Deref;
-
-#[derive(Default)]
-pub struct DirstateMap {
-    state_map: StateMap,
-    pub copy_map: CopyMap,
-    pub dirs: Option<DirsMultiset>,
-    pub all_dirs: Option<DirsMultiset>,
-    non_normal_set: Option<HashSet<HgPathBuf>>,
-    other_parent_set: Option<HashSet<HgPathBuf>>,
-}
-
-/// Should only really be used in python interface code, for clarity
-impl Deref for DirstateMap {
-    type Target = StateMap;
-
-    fn deref(&self) -> &Self::Target {
-        &self.state_map
-    }
-}
-
-impl FromIterator<(HgPathBuf, DirstateEntry)> for DirstateMap {
-    fn from_iter<I: IntoIterator<Item = (HgPathBuf, DirstateEntry)>>(
-        iter: I,
-    ) -> Self {
-        Self {
-            state_map: iter.into_iter().collect(),
-            ..Self::default()
-        }
-    }
-}
-
-impl DirstateMap {
-    pub fn new() -> Self {
-        Self::default()
-    }
-
-    pub fn clear(&mut self) {
-        self.state_map = StateMap::default();
-        self.copy_map.clear();
-        self.non_normal_set = None;
-        self.other_parent_set = None;
-    }
-
-    pub fn set_v1_inner(&mut self, filename: &HgPath, entry: DirstateEntry) {
-        self.state_map.insert(filename.to_owned(), entry);
-    }
-
-    /// Add a tracked file to the dirstate
-    pub fn add_file(
-        &mut self,
-        filename: &HgPath,
-        entry: DirstateEntry,
-        // XXX once the dust settle this should probably become an enum
-        added: bool,
-        merged: bool,
-        from_p2: bool,
-        possibly_dirty: bool,
-    ) -> Result<(), DirstateError> {
-        let mut entry = entry;
-        if added {
-            assert!(!merged);
-            assert!(!possibly_dirty);
-            assert!(!from_p2);
-            entry.state = EntryState::Added;
-            entry.size = SIZE_NON_NORMAL;
-            entry.mtime = MTIME_UNSET;
-        } else if merged {
-            assert!(!possibly_dirty);
-            assert!(!from_p2);
-            entry.state = EntryState::Merged;
-            entry.size = SIZE_FROM_OTHER_PARENT;
-            entry.mtime = MTIME_UNSET;
-        } else if from_p2 {
-            assert!(!possibly_dirty);
-            entry.state = EntryState::Normal;
-            entry.size = SIZE_FROM_OTHER_PARENT;
-            entry.mtime = MTIME_UNSET;
-        } else if possibly_dirty {
-            entry.state = EntryState::Normal;
-            entry.size = SIZE_NON_NORMAL;
-            entry.mtime = MTIME_UNSET;
-        } else {
-            entry.state = EntryState::Normal;
-            entry.size = entry.size & V1_RANGEMASK;
-            entry.mtime = entry.mtime & V1_RANGEMASK;
-        }
-        let old_state = match self.get(filename) {
-            Some(e) => e.state,
-            None => EntryState::Unknown,
-        };
-        if old_state == EntryState::Unknown || old_state == EntryState::Removed
-        {
-            if let Some(ref mut dirs) = self.dirs {
-                dirs.add_path(filename)?;
-            }
-        }
-        if old_state == EntryState::Unknown {
-            if let Some(ref mut all_dirs) = self.all_dirs {
-                all_dirs.add_path(filename)?;
-            }
-        }
-        self.state_map.insert(filename.to_owned(), entry.to_owned());
-
-        if entry.is_non_normal() {
-            self.get_non_normal_other_parent_entries()
-                .0
-                .insert(filename.to_owned());
-        }
-
-        if entry.is_from_other_parent() {
-            self.get_non_normal_other_parent_entries()
-                .1
-                .insert(filename.to_owned());
-        }
-        Ok(())
-    }
-
-    /// Mark a file as removed in the dirstate.
-    ///
-    /// The `size` parameter is used to store sentinel values that indicate
-    /// the file's previous state.  In the future, we should refactor this
-    /// to be more explicit about what that state is.
-    pub fn remove_file(
-        &mut self,
-        filename: &HgPath,
-        in_merge: bool,
-    ) -> Result<(), DirstateError> {
-        let old_entry_opt = self.get(filename);
-        let old_state = match old_entry_opt {
-            Some(e) => e.state,
-            None => EntryState::Unknown,
-        };
-        let mut size = 0;
-        if in_merge {
-            // XXX we should not be able to have 'm' state and 'FROM_P2' if not
-            // during a merge. So I (marmoute) am not sure we need the
-            // conditionnal at all. Adding double checking this with assert
-            // would be nice.
-            if let Some(old_entry) = old_entry_opt {
-                // backup the previous state
-                if old_entry.state == EntryState::Merged {
-                    size = SIZE_NON_NORMAL;
-                } else if old_entry.state == EntryState::Normal
-                    && old_entry.size == SIZE_FROM_OTHER_PARENT
-                {
-                    // other parent
-                    size = SIZE_FROM_OTHER_PARENT;
-                    self.get_non_normal_other_parent_entries()
-                        .1
-                        .insert(filename.to_owned());
-                }
-            }
-        }
-        if old_state != EntryState::Unknown && old_state != EntryState::Removed
-        {
-            if let Some(ref mut dirs) = self.dirs {
-                dirs.delete_path(filename)?;
-            }
-        }
-        if old_state == EntryState::Unknown {
-            if let Some(ref mut all_dirs) = self.all_dirs {
-                all_dirs.add_path(filename)?;
-            }
-        }
-        if size == 0 {
-            self.copy_map.remove(filename);
-        }
-
-        self.state_map.insert(
-            filename.to_owned(),
-            DirstateEntry {
-                state: EntryState::Removed,
-                mode: 0,
-                size,
-                mtime: 0,
-            },
-        );
-        self.get_non_normal_other_parent_entries()
-            .0
-            .insert(filename.to_owned());
-        Ok(())
-    }
-
-    /// Remove a file from the dirstate.
-    /// Returns `true` if the file was previously recorded.
-    pub fn drop_file(
-        &mut self,
-        filename: &HgPath,
-    ) -> Result<bool, DirstateError> {
-        let old_state = match self.get(filename) {
-            Some(e) => e.state,
-            None => EntryState::Unknown,
-        };
-        let exists = self.state_map.remove(filename).is_some();
-
-        if exists {
-            if old_state != EntryState::Removed {
-                if let Some(ref mut dirs) = self.dirs {
-                    dirs.delete_path(filename)?;
-                }
-            }
-            if let Some(ref mut all_dirs) = self.all_dirs {
-                all_dirs.delete_path(filename)?;
-            }
-        }
-        self.get_non_normal_other_parent_entries()
-            .0
-            .remove(filename);
-
-        Ok(exists)
-    }
-
-    pub fn clear_ambiguous_times(
-        &mut self,
-        filenames: Vec<HgPathBuf>,
-        now: i32,
-    ) {
-        for filename in filenames {
-            if let Some(entry) = self.state_map.get_mut(&filename) {
-                if entry.clear_ambiguous_mtime(now) {
-                    self.get_non_normal_other_parent_entries()
-                        .0
-                        .insert(filename.to_owned());
-                }
-            }
-        }
-    }
-
-    pub fn non_normal_entries_remove(
-        &mut self,
-        key: impl AsRef<HgPath>,
-    ) -> bool {
-        self.get_non_normal_other_parent_entries()
-            .0
-            .remove(key.as_ref())
-    }
-
-    pub fn non_normal_entries_add(&mut self, key: impl AsRef<HgPath>) {
-        self.get_non_normal_other_parent_entries()
-            .0
-            .insert(key.as_ref().into());
-    }
-
-    pub fn non_normal_entries_union(
-        &mut self,
-        other: HashSet<HgPathBuf>,
-    ) -> Vec<HgPathBuf> {
-        self.get_non_normal_other_parent_entries()
-            .0
-            .union(&other)
-            .map(ToOwned::to_owned)
-            .collect()
-    }
-
-    pub fn get_non_normal_other_parent_entries(
-        &mut self,
-    ) -> (&mut HashSet<HgPathBuf>, &mut HashSet<HgPathBuf>) {
-        self.set_non_normal_other_parent_entries(false);
-        (
-            self.non_normal_set.as_mut().unwrap(),
-            self.other_parent_set.as_mut().unwrap(),
-        )
-    }
-
-    /// Useful to get immutable references to those sets in contexts where
-    /// you only have an immutable reference to the `DirstateMap`, like when
-    /// sharing references with Python.
-    ///
-    /// TODO, get rid of this along with the other "setter/getter" stuff when
-    /// a nice typestate plan is defined.
-    ///
-    /// # Panics
-    ///
-    /// Will panic if either set is `None`.
-    pub fn get_non_normal_other_parent_entries_panic(
-        &self,
-    ) -> (&HashSet<HgPathBuf>, &HashSet<HgPathBuf>) {
-        (
-            self.non_normal_set.as_ref().unwrap(),
-            self.other_parent_set.as_ref().unwrap(),
-        )
-    }
-
-    pub fn set_non_normal_other_parent_entries(&mut self, force: bool) {
-        if !force
-            && self.non_normal_set.is_some()
-            && self.other_parent_set.is_some()
-        {
-            return;
-        }
-        let mut non_normal = HashSet::new();
-        let mut other_parent = HashSet::new();
-
-        for (filename, entry) in self.state_map.iter() {
-            if entry.is_non_normal() {
-                non_normal.insert(filename.to_owned());
-            }
-            if entry.is_from_other_parent() {
-                other_parent.insert(filename.to_owned());
-            }
-        }
-        self.non_normal_set = Some(non_normal);
-        self.other_parent_set = Some(other_parent);
-    }
-
-    /// Both of these setters and their uses appear to be the simplest way to
-    /// emulate a Python lazy property, but it is ugly and unidiomatic.
-    /// TODO One day, rewriting this struct using the typestate might be a
-    /// good idea.
-    pub fn set_all_dirs(&mut self) -> Result<(), DirstateError> {
-        if self.all_dirs.is_none() {
-            self.all_dirs = Some(DirsMultiset::from_dirstate(
-                self.state_map.iter().map(|(k, v)| Ok((k, *v))),
-                None,
-            )?);
-        }
-        Ok(())
-    }
-
-    pub fn set_dirs(&mut self) -> Result<(), DirstateError> {
-        if self.dirs.is_none() {
-            self.dirs = Some(DirsMultiset::from_dirstate(
-                self.state_map.iter().map(|(k, v)| Ok((k, *v))),
-                Some(EntryState::Removed),
-            )?);
-        }
-        Ok(())
-    }
-
-    pub fn has_tracked_dir(
-        &mut self,
-        directory: &HgPath,
-    ) -> Result<bool, DirstateError> {
-        self.set_dirs()?;
-        Ok(self.dirs.as_ref().unwrap().contains(directory))
-    }
-
-    pub fn has_dir(
-        &mut self,
-        directory: &HgPath,
-    ) -> Result<bool, DirstateError> {
-        self.set_all_dirs()?;
-        Ok(self.all_dirs.as_ref().unwrap().contains(directory))
-    }
-
-    #[timed]
-    pub fn read(
-        &mut self,
-        file_contents: &[u8],
-    ) -> Result<Option<DirstateParents>, DirstateError> {
-        if file_contents.is_empty() {
-            return Ok(None);
-        }
-
-        let (parents, entries, copies) = parse_dirstate(file_contents)?;
-        self.state_map.extend(
-            entries
-                .into_iter()
-                .map(|(path, entry)| (path.to_owned(), entry)),
-        );
-        self.copy_map.extend(
-            copies
-                .into_iter()
-                .map(|(path, copy)| (path.to_owned(), copy.to_owned())),
-        );
-        Ok(Some(parents.clone()))
-    }
-
-    pub fn pack(
-        &mut self,
-        parents: DirstateParents,
-        now: Timestamp,
-    ) -> Result<Vec<u8>, DirstateError> {
-        let packed =
-            pack_dirstate(&mut self.state_map, &self.copy_map, parents, now)?;
-
-        self.set_non_normal_other_parent_entries(true);
-        Ok(packed)
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn test_dirs_multiset() {
-        let mut map = DirstateMap::new();
-        assert!(map.dirs.is_none());
-        assert!(map.all_dirs.is_none());
-
-        assert_eq!(map.has_dir(HgPath::new(b"nope")).unwrap(), false);
-        assert!(map.all_dirs.is_some());
-        assert!(map.dirs.is_none());
-
-        assert_eq!(map.has_tracked_dir(HgPath::new(b"nope")).unwrap(), false);
-        assert!(map.dirs.is_some());
-    }
-
-    #[test]
-    fn test_add_file() {
-        let mut map = DirstateMap::new();
-
-        assert_eq!(0, map.len());
-
-        map.add_file(
-            HgPath::new(b"meh"),
-            DirstateEntry {
-                state: EntryState::Normal,
-                mode: 1337,
-                mtime: 1337,
-                size: 1337,
-            },
-            false,
-            false,
-            false,
-            false,
-        )
-        .unwrap();
-
-        assert_eq!(1, map.len());
-        assert_eq!(0, map.get_non_normal_other_parent_entries().0.len());
-        assert_eq!(0, map.get_non_normal_other_parent_entries().1.len());
-    }
-
-    #[test]
-    fn test_non_normal_other_parent_entries() {
-        let mut map: DirstateMap = [
-            (b"f1", (EntryState::Removed, 1337, 1337, 1337)),
-            (b"f2", (EntryState::Normal, 1337, 1337, -1)),
-            (b"f3", (EntryState::Normal, 1337, 1337, 1337)),
-            (b"f4", (EntryState::Normal, 1337, -2, 1337)),
-            (b"f5", (EntryState::Added, 1337, 1337, 1337)),
-            (b"f6", (EntryState::Added, 1337, 1337, -1)),
-            (b"f7", (EntryState::Merged, 1337, 1337, -1)),
-            (b"f8", (EntryState::Merged, 1337, 1337, 1337)),
-            (b"f9", (EntryState::Merged, 1337, -2, 1337)),
-            (b"fa", (EntryState::Added, 1337, -2, 1337)),
-            (b"fb", (EntryState::Removed, 1337, -2, 1337)),
-        ]
-        .iter()
-        .map(|(fname, (state, mode, size, mtime))| {
-            (
-                HgPathBuf::from_bytes(fname.as_ref()),
-                DirstateEntry {
-                    state: *state,
-                    mode: *mode,
-                    size: *size,
-                    mtime: *mtime,
-                },
-            )
-        })
-        .collect();
-
-        let mut non_normal = [
-            b"f1", b"f2", b"f5", b"f6", b"f7", b"f8", b"f9", b"fa", b"fb",
-        ]
-        .iter()
-        .map(|x| HgPathBuf::from_bytes(x.as_ref()))
-        .collect();
-
-        let mut other_parent = HashSet::new();
-        other_parent.insert(HgPathBuf::from_bytes(b"f4"));
-        let entries = map.get_non_normal_other_parent_entries();
-
-        assert_eq!(
-            (&mut non_normal, &mut other_parent),
-            (entries.0, entries.1)
-        );
-    }
-}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/rust/hg-core/src/dirstate/entry.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -0,0 +1,643 @@
+use crate::dirstate_tree::on_disk::DirstateV2ParseError;
+use crate::errors::HgError;
+use bitflags::bitflags;
+use std::convert::{TryFrom, TryInto};
+use std::fs;
+use std::io;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+pub enum EntryState {
+    Normal,
+    Added,
+    Removed,
+    Merged,
+}
+
+/// `size` and `mtime.seconds` are truncated to 31 bits.
+///
+/// TODO: double-check status algorithm correctness for files
+/// larger than 2 GiB or modified after 2038.
+#[derive(Debug, Copy, Clone)]
+pub struct DirstateEntry {
+    pub(crate) flags: Flags,
+    mode_size: Option<(u32, u32)>,
+    mtime: Option<TruncatedTimestamp>,
+}
+
+bitflags! {
+    pub(crate) struct Flags: u8 {
+        const WDIR_TRACKED = 1 << 0;
+        const P1_TRACKED = 1 << 1;
+        const P2_INFO = 1 << 2;
+        const HAS_FALLBACK_EXEC = 1 << 3;
+        const FALLBACK_EXEC = 1 << 4;
+        const HAS_FALLBACK_SYMLINK = 1 << 5;
+        const FALLBACK_SYMLINK = 1 << 6;
+    }
+}
+
+/// A Unix timestamp with nanoseconds precision
+#[derive(Debug, Copy, Clone)]
+pub struct TruncatedTimestamp {
+    truncated_seconds: u32,
+    /// Always in the `0 .. 1_000_000_000` range.
+    nanoseconds: u32,
+}
+
+impl TruncatedTimestamp {
+    /// Constructs from a timestamp potentially outside of the supported range,
+    /// and truncate the seconds components to its lower 31 bits.
+    ///
+    /// Panics if the nanoseconds components is not in the expected range.
+    pub fn new_truncate(seconds: i64, nanoseconds: u32) -> Self {
+        assert!(nanoseconds < NSEC_PER_SEC);
+        Self {
+            truncated_seconds: seconds as u32 & RANGE_MASK_31BIT,
+            nanoseconds,
+        }
+    }
+
+    /// Construct from components. Returns an error if they are not in the
+    /// expcted range.
+    pub fn from_already_truncated(
+        truncated_seconds: u32,
+        nanoseconds: u32,
+    ) -> Result<Self, DirstateV2ParseError> {
+        if truncated_seconds & !RANGE_MASK_31BIT == 0
+            && nanoseconds < NSEC_PER_SEC
+        {
+            Ok(Self {
+                truncated_seconds,
+                nanoseconds,
+            })
+        } else {
+            Err(DirstateV2ParseError)
+        }
+    }
+
+    pub fn for_mtime_of(metadata: &fs::Metadata) -> io::Result<Self> {
+        #[cfg(unix)]
+        {
+            use std::os::unix::fs::MetadataExt;
+            let seconds = metadata.mtime();
+            // i64 -> u32 with value always in the `0 .. NSEC_PER_SEC` range
+            let nanoseconds = metadata.mtime_nsec().try_into().unwrap();
+            Ok(Self::new_truncate(seconds, nanoseconds))
+        }
+        #[cfg(not(unix))]
+        {
+            metadata.modified().map(Self::from)
+        }
+    }
+
+    /// The lower 31 bits of the number of seconds since the epoch.
+    pub fn truncated_seconds(&self) -> u32 {
+        self.truncated_seconds
+    }
+
+    /// The sub-second component of this timestamp, in nanoseconds.
+    /// Always in the `0 .. 1_000_000_000` range.
+    ///
+    /// This timestamp is after `(seconds, 0)` by this many nanoseconds.
+    pub fn nanoseconds(&self) -> u32 {
+        self.nanoseconds
+    }
+
+    /// Returns whether two timestamps are equal modulo 2**31 seconds.
+    ///
+    /// If this returns `true`, the original values converted from `SystemTime`
+    /// or given to `new_truncate` were very likely equal. A false positive is
+    /// possible if they were exactly a multiple of 2**31 seconds apart (around
+    /// 68 years). This is deemed very unlikely to happen by chance, especially
+    /// on filesystems that support sub-second precision.
+    ///
+    /// If someone is manipulating the modification times of some files to
+    /// intentionally make `hg status` return incorrect results, not truncating
+    /// wouldn’t help much since they can set exactly the expected timestamp.
+    ///
+    /// Sub-second precision is ignored if it is zero in either value.
+    /// Some APIs simply return zero when more precision is not available.
+    /// When comparing values from different sources, if only one is truncated
+    /// in that way, doing a simple comparison would cause many false
+    /// negatives.
+    pub fn likely_equal(self, other: Self) -> bool {
+        self.truncated_seconds == other.truncated_seconds
+            && (self.nanoseconds == other.nanoseconds
+                || self.nanoseconds == 0
+                || other.nanoseconds == 0)
+    }
+
+    pub fn likely_equal_to_mtime_of(
+        self,
+        metadata: &fs::Metadata,
+    ) -> io::Result<bool> {
+        Ok(self.likely_equal(Self::for_mtime_of(metadata)?))
+    }
+}
+
+impl From<SystemTime> for TruncatedTimestamp {
+    fn from(system_time: SystemTime) -> Self {
+        // On Unix, `SystemTime` is a wrapper for the `timespec` C struct:
+        // https://www.gnu.org/software/libc/manual/html_node/Time-Types.html#index-struct-timespec
+        // We want to effectively access its fields, but the Rust standard
+        // library does not expose them. The best we can do is:
+        let seconds;
+        let nanoseconds;
+        match system_time.duration_since(UNIX_EPOCH) {
+            Ok(duration) => {
+                seconds = duration.as_secs() as i64;
+                nanoseconds = duration.subsec_nanos();
+            }
+            Err(error) => {
+                // `system_time` is before `UNIX_EPOCH`.
+                // We need to undo this algorithm:
+                // https://github.com/rust-lang/rust/blob/6bed1f0bc3cc50c10aab26d5f94b16a00776b8a5/library/std/src/sys/unix/time.rs#L40-L41
+                let negative = error.duration();
+                let negative_secs = negative.as_secs() as i64;
+                let negative_nanos = negative.subsec_nanos();
+                if negative_nanos == 0 {
+                    seconds = -negative_secs;
+                    nanoseconds = 0;
+                } else {
+                    // For example if `system_time` was 4.3 seconds before
+                    // the Unix epoch we get a Duration that represents
+                    // `(-4, -0.3)` but we want `(-5, +0.7)`:
+                    seconds = -1 - negative_secs;
+                    nanoseconds = NSEC_PER_SEC - negative_nanos;
+                }
+            }
+        };
+        Self::new_truncate(seconds, nanoseconds)
+    }
+}
+
+const NSEC_PER_SEC: u32 = 1_000_000_000;
+const RANGE_MASK_31BIT: u32 = 0x7FFF_FFFF;
+
+pub const MTIME_UNSET: i32 = -1;
+
+/// A `DirstateEntry` with a size of `-2` means that it was merged from the
+/// other parent. This allows revert to pick the right status back during a
+/// merge.
+pub const SIZE_FROM_OTHER_PARENT: i32 = -2;
+/// A special value used for internal representation of special case in
+/// dirstate v1 format.
+pub const SIZE_NON_NORMAL: i32 = -1;
+
+impl DirstateEntry {
+    pub fn from_v2_data(
+        wdir_tracked: bool,
+        p1_tracked: bool,
+        p2_info: bool,
+        mode_size: Option<(u32, u32)>,
+        mtime: Option<TruncatedTimestamp>,
+        fallback_exec: Option<bool>,
+        fallback_symlink: Option<bool>,
+    ) -> Self {
+        if let Some((mode, size)) = mode_size {
+            // TODO: return an error for out of range values?
+            assert!(mode & !RANGE_MASK_31BIT == 0);
+            assert!(size & !RANGE_MASK_31BIT == 0);
+        }
+        let mut flags = Flags::empty();
+        flags.set(Flags::WDIR_TRACKED, wdir_tracked);
+        flags.set(Flags::P1_TRACKED, p1_tracked);
+        flags.set(Flags::P2_INFO, p2_info);
+        if let Some(exec) = fallback_exec {
+            flags.insert(Flags::HAS_FALLBACK_EXEC);
+            if exec {
+                flags.insert(Flags::FALLBACK_EXEC);
+            }
+        }
+        if let Some(exec) = fallback_symlink {
+            flags.insert(Flags::HAS_FALLBACK_SYMLINK);
+            if exec {
+                flags.insert(Flags::FALLBACK_SYMLINK);
+            }
+        }
+        Self {
+            flags,
+            mode_size,
+            mtime,
+        }
+    }
+
+    pub fn from_v1_data(
+        state: EntryState,
+        mode: i32,
+        size: i32,
+        mtime: i32,
+    ) -> Self {
+        match state {
+            EntryState::Normal => {
+                if size == SIZE_FROM_OTHER_PARENT {
+                    Self {
+                        // might be missing P1_TRACKED
+                        flags: Flags::WDIR_TRACKED | Flags::P2_INFO,
+                        mode_size: None,
+                        mtime: None,
+                    }
+                } else if size == SIZE_NON_NORMAL {
+                    Self {
+                        flags: Flags::WDIR_TRACKED | Flags::P1_TRACKED,
+                        mode_size: None,
+                        mtime: None,
+                    }
+                } else if mtime == MTIME_UNSET {
+                    // TODO: return an error for negative values?
+                    let mode = u32::try_from(mode).unwrap();
+                    let size = u32::try_from(size).unwrap();
+                    Self {
+                        flags: Flags::WDIR_TRACKED | Flags::P1_TRACKED,
+                        mode_size: Some((mode, size)),
+                        mtime: None,
+                    }
+                } else {
+                    // TODO: return an error for negative values?
+                    let mode = u32::try_from(mode).unwrap();
+                    let size = u32::try_from(size).unwrap();
+                    let mtime = u32::try_from(mtime).unwrap();
+                    let mtime =
+                        TruncatedTimestamp::from_already_truncated(mtime, 0)
+                            .unwrap();
+                    Self {
+                        flags: Flags::WDIR_TRACKED | Flags::P1_TRACKED,
+                        mode_size: Some((mode, size)),
+                        mtime: Some(mtime),
+                    }
+                }
+            }
+            EntryState::Added => Self {
+                flags: Flags::WDIR_TRACKED,
+                mode_size: None,
+                mtime: None,
+            },
+            EntryState::Removed => Self {
+                flags: if size == SIZE_NON_NORMAL {
+                    Flags::P1_TRACKED | Flags::P2_INFO
+                } else if size == SIZE_FROM_OTHER_PARENT {
+                    // We don’t know if P1_TRACKED should be set (file history)
+                    Flags::P2_INFO
+                } else {
+                    Flags::P1_TRACKED
+                },
+                mode_size: None,
+                mtime: None,
+            },
+            EntryState::Merged => Self {
+                flags: Flags::WDIR_TRACKED
+                    | Flags::P1_TRACKED // might not be true because of rename ?
+                    | Flags::P2_INFO, // might not be true because of rename ?
+                mode_size: None,
+                mtime: None,
+            },
+        }
+    }
+
+    /// Creates a new entry in "removed" state.
+    ///
+    /// `size` is expected to be zero, `SIZE_NON_NORMAL`, or
+    /// `SIZE_FROM_OTHER_PARENT`
+    pub fn new_removed(size: i32) -> Self {
+        Self::from_v1_data(EntryState::Removed, 0, size, 0)
+    }
+
+    pub fn tracked(&self) -> bool {
+        self.flags.contains(Flags::WDIR_TRACKED)
+    }
+
+    pub fn p1_tracked(&self) -> bool {
+        self.flags.contains(Flags::P1_TRACKED)
+    }
+
+    fn in_either_parent(&self) -> bool {
+        self.flags.intersects(Flags::P1_TRACKED | Flags::P2_INFO)
+    }
+
+    pub fn removed(&self) -> bool {
+        self.in_either_parent() && !self.flags.contains(Flags::WDIR_TRACKED)
+    }
+
+    pub fn p2_info(&self) -> bool {
+        self.flags.contains(Flags::WDIR_TRACKED | Flags::P2_INFO)
+    }
+
+    pub fn added(&self) -> bool {
+        self.flags.contains(Flags::WDIR_TRACKED) && !self.in_either_parent()
+    }
+
+    pub fn maybe_clean(&self) -> bool {
+        if !self.flags.contains(Flags::WDIR_TRACKED) {
+            false
+        } else if !self.flags.contains(Flags::P1_TRACKED) {
+            false
+        } else if self.flags.contains(Flags::P2_INFO) {
+            false
+        } else {
+            true
+        }
+    }
+
+    pub fn any_tracked(&self) -> bool {
+        self.flags.intersects(
+            Flags::WDIR_TRACKED | Flags::P1_TRACKED | Flags::P2_INFO,
+        )
+    }
+
+    /// Returns `(wdir_tracked, p1_tracked, p2_info, mode_size, mtime)`
+    pub(crate) fn v2_data(
+        &self,
+    ) -> (
+        bool,
+        bool,
+        bool,
+        Option<(u32, u32)>,
+        Option<TruncatedTimestamp>,
+        Option<bool>,
+        Option<bool>,
+    ) {
+        if !self.any_tracked() {
+            // TODO: return an Option instead?
+            panic!("Accessing v1_state of an untracked DirstateEntry")
+        }
+        let wdir_tracked = self.flags.contains(Flags::WDIR_TRACKED);
+        let p1_tracked = self.flags.contains(Flags::P1_TRACKED);
+        let p2_info = self.flags.contains(Flags::P2_INFO);
+        let mode_size = self.mode_size;
+        let mtime = self.mtime;
+        (
+            wdir_tracked,
+            p1_tracked,
+            p2_info,
+            mode_size,
+            mtime,
+            self.get_fallback_exec(),
+            self.get_fallback_symlink(),
+        )
+    }
+
+    fn v1_state(&self) -> EntryState {
+        if !self.any_tracked() {
+            // TODO: return an Option instead?
+            panic!("Accessing v1_state of an untracked DirstateEntry")
+        }
+        if self.removed() {
+            EntryState::Removed
+        } else if self
+            .flags
+            .contains(Flags::WDIR_TRACKED | Flags::P1_TRACKED | Flags::P2_INFO)
+        {
+            EntryState::Merged
+        } else if self.added() {
+            EntryState::Added
+        } else {
+            EntryState::Normal
+        }
+    }
+
+    fn v1_mode(&self) -> i32 {
+        if let Some((mode, _size)) = self.mode_size {
+            i32::try_from(mode).unwrap()
+        } else {
+            0
+        }
+    }
+
+    fn v1_size(&self) -> i32 {
+        if !self.any_tracked() {
+            // TODO: return an Option instead?
+            panic!("Accessing v1_size of an untracked DirstateEntry")
+        }
+        if self.removed()
+            && self.flags.contains(Flags::P1_TRACKED | Flags::P2_INFO)
+        {
+            SIZE_NON_NORMAL
+        } else if self.flags.contains(Flags::P2_INFO) {
+            SIZE_FROM_OTHER_PARENT
+        } else if self.removed() {
+            0
+        } else if self.added() {
+            SIZE_NON_NORMAL
+        } else if let Some((_mode, size)) = self.mode_size {
+            i32::try_from(size).unwrap()
+        } else {
+            SIZE_NON_NORMAL
+        }
+    }
+
+    fn v1_mtime(&self) -> i32 {
+        if !self.any_tracked() {
+            // TODO: return an Option instead?
+            panic!("Accessing v1_mtime of an untracked DirstateEntry")
+        }
+        if self.removed() {
+            0
+        } else if self.flags.contains(Flags::P2_INFO) {
+            MTIME_UNSET
+        } else if !self.flags.contains(Flags::P1_TRACKED) {
+            MTIME_UNSET
+        } else if let Some(mtime) = self.mtime {
+            i32::try_from(mtime.truncated_seconds()).unwrap()
+        } else {
+            MTIME_UNSET
+        }
+    }
+
+    // TODO: return `Option<EntryState>`? None when `!self.any_tracked`
+    pub fn state(&self) -> EntryState {
+        self.v1_state()
+    }
+
+    // TODO: return Option?
+    pub fn mode(&self) -> i32 {
+        self.v1_mode()
+    }
+
+    // TODO: return Option?
+    pub fn size(&self) -> i32 {
+        self.v1_size()
+    }
+
+    // TODO: return Option?
+    pub fn mtime(&self) -> i32 {
+        self.v1_mtime()
+    }
+
+    pub fn get_fallback_exec(&self) -> Option<bool> {
+        if self.flags.contains(Flags::HAS_FALLBACK_EXEC) {
+            Some(self.flags.contains(Flags::FALLBACK_EXEC))
+        } else {
+            None
+        }
+    }
+
+    pub fn set_fallback_exec(&mut self, value: Option<bool>) {
+        match value {
+            None => {
+                self.flags.remove(Flags::HAS_FALLBACK_EXEC);
+                self.flags.remove(Flags::FALLBACK_EXEC);
+            }
+            Some(exec) => {
+                self.flags.insert(Flags::HAS_FALLBACK_EXEC);
+                if exec {
+                    self.flags.insert(Flags::FALLBACK_EXEC);
+                }
+            }
+        }
+    }
+
+    pub fn get_fallback_symlink(&self) -> Option<bool> {
+        if self.flags.contains(Flags::HAS_FALLBACK_SYMLINK) {
+            Some(self.flags.contains(Flags::FALLBACK_SYMLINK))
+        } else {
+            None
+        }
+    }
+
+    pub fn set_fallback_symlink(&mut self, value: Option<bool>) {
+        match value {
+            None => {
+                self.flags.remove(Flags::HAS_FALLBACK_SYMLINK);
+                self.flags.remove(Flags::FALLBACK_SYMLINK);
+            }
+            Some(symlink) => {
+                self.flags.insert(Flags::HAS_FALLBACK_SYMLINK);
+                if symlink {
+                    self.flags.insert(Flags::FALLBACK_SYMLINK);
+                }
+            }
+        }
+    }
+
+    pub fn truncated_mtime(&self) -> Option<TruncatedTimestamp> {
+        self.mtime
+    }
+
+    pub fn drop_merge_data(&mut self) {
+        if self.flags.contains(Flags::P2_INFO) {
+            self.flags.remove(Flags::P2_INFO);
+            self.mode_size = None;
+            self.mtime = None;
+        }
+    }
+
+    pub fn set_possibly_dirty(&mut self) {
+        self.mtime = None
+    }
+
+    pub fn set_clean(
+        &mut self,
+        mode: u32,
+        size: u32,
+        mtime: TruncatedTimestamp,
+    ) {
+        let size = size & RANGE_MASK_31BIT;
+        self.flags.insert(Flags::WDIR_TRACKED | Flags::P1_TRACKED);
+        self.mode_size = Some((mode, size));
+        self.mtime = Some(mtime);
+    }
+
+    pub fn set_tracked(&mut self) {
+        self.flags.insert(Flags::WDIR_TRACKED);
+        // `set_tracked` is replacing various `normallookup` call. So we mark
+        // the files as needing lookup
+        //
+        // Consider dropping this in the future in favor of something less
+        // broad.
+        self.mtime = None;
+    }
+
+    pub fn set_untracked(&mut self) {
+        self.flags.remove(Flags::WDIR_TRACKED);
+        self.mode_size = None;
+        self.mtime = None;
+    }
+
+    /// Returns `(state, mode, size, mtime)` for the puprose of serialization
+    /// in the dirstate-v1 format.
+    ///
+    /// This includes marker values such as `mtime == -1`. In the future we may
+    /// want to not represent these cases that way in memory, but serialization
+    /// will need to keep the same format.
+    pub fn v1_data(&self) -> (u8, i32, i32, i32) {
+        (
+            self.v1_state().into(),
+            self.v1_mode(),
+            self.v1_size(),
+            self.v1_mtime(),
+        )
+    }
+
+    pub(crate) fn is_from_other_parent(&self) -> bool {
+        self.state() == EntryState::Normal
+            && self.size() == SIZE_FROM_OTHER_PARENT
+    }
+
+    // TODO: other platforms
+    #[cfg(unix)]
+    pub fn mode_changed(
+        &self,
+        filesystem_metadata: &std::fs::Metadata,
+    ) -> bool {
+        use std::os::unix::fs::MetadataExt;
+        const EXEC_BIT_MASK: u32 = 0o100;
+        let dirstate_exec_bit = (self.mode() as u32) & EXEC_BIT_MASK;
+        let fs_exec_bit = filesystem_metadata.mode() & EXEC_BIT_MASK;
+        dirstate_exec_bit != fs_exec_bit
+    }
+
+    /// Returns a `(state, mode, size, mtime)` tuple as for
+    /// `DirstateMapMethods::debug_iter`.
+    pub fn debug_tuple(&self) -> (u8, i32, i32, i32) {
+        (self.state().into(), self.mode(), self.size(), self.mtime())
+    }
+
+    /// True if the stored mtime would be ambiguous with the current time
+    pub fn need_delay(&self, now: TruncatedTimestamp) -> bool {
+        if let Some(mtime) = self.mtime {
+            self.state() == EntryState::Normal
+                && mtime.truncated_seconds() == now.truncated_seconds()
+        } else {
+            false
+        }
+    }
+}
+
+impl EntryState {
+    pub fn is_tracked(self) -> bool {
+        use EntryState::*;
+        match self {
+            Normal | Added | Merged => true,
+            Removed => false,
+        }
+    }
+}
+
+impl TryFrom<u8> for EntryState {
+    type Error = HgError;
+
+    fn try_from(value: u8) -> Result<Self, Self::Error> {
+        match value {
+            b'n' => Ok(EntryState::Normal),
+            b'a' => Ok(EntryState::Added),
+            b'r' => Ok(EntryState::Removed),
+            b'm' => Ok(EntryState::Merged),
+            _ => Err(HgError::CorruptedRepository(format!(
+                "Incorrect dirstate entry state {}",
+                value
+            ))),
+        }
+    }
+}
+
+impl Into<u8> for EntryState {
+    fn into(self) -> u8 {
+        match self {
+            EntryState::Normal => b'n',
+            EntryState::Added => b'a',
+            EntryState::Removed => b'r',
+            EntryState::Merged => b'm',
+        }
+    }
+}
--- a/rust/hg-core/src/dirstate/parsers.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/dirstate/parsers.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -5,14 +5,11 @@
 
 use crate::errors::HgError;
 use crate::utils::hg_path::HgPath;
-use crate::{
-    dirstate::{CopyMap, EntryState, RawEntry, StateMap},
-    DirstateEntry, DirstateParents,
-};
+use crate::{dirstate::EntryState, DirstateEntry, DirstateParents};
 use byteorder::{BigEndian, WriteBytesExt};
-use bytes_cast::BytesCast;
+use bytes_cast::{unaligned, BytesCast};
 use micro_timer::timed;
-use std::convert::{TryFrom, TryInto};
+use std::convert::TryFrom;
 
 /// Parents are stored in the dirstate as byte hashes.
 pub const PARENT_SIZE: usize = 20;
@@ -48,6 +45,16 @@
     Ok((parents, entries, copies))
 }
 
+#[derive(BytesCast)]
+#[repr(C)]
+struct RawEntry {
+    state: u8,
+    mode: unaligned::I32Be,
+    size: unaligned::I32Be,
+    mtime: unaligned::I32Be,
+    length: unaligned::I32Be,
+}
+
 pub fn parse_dirstate_entries<'a>(
     mut contents: &'a [u8],
     mut each_entry: impl FnMut(
@@ -63,12 +70,12 @@
         let (raw_entry, rest) = RawEntry::from_bytes(contents)
             .map_err(|_| HgError::corrupted("Overflow in dirstate."))?;
 
-        let entry = DirstateEntry {
-            state: EntryState::try_from(raw_entry.state)?,
-            mode: raw_entry.mode.get(),
-            mtime: raw_entry.mtime.get(),
-            size: raw_entry.size.get(),
-        };
+        let entry = DirstateEntry::from_v1_data(
+            EntryState::try_from(raw_entry.state)?,
+            raw_entry.mode.get(),
+            raw_entry.size.get(),
+            raw_entry.mtime.get(),
+        );
         let (paths, rest) =
             u8::slice_from_bytes(rest, raw_entry.length.get() as usize)
                 .map_err(|_| HgError::corrupted("Overflow in dirstate."))?;
@@ -114,12 +121,13 @@
     packed: &mut Vec<u8>,
 ) {
     let length = packed_filename_and_copy_source_size(filename, copy_source);
+    let (state, mode, size, mtime) = entry.v1_data();
 
     // Unwrapping because `impl std::io::Write for Vec<u8>` never errors
-    packed.write_u8(entry.state.into()).unwrap();
-    packed.write_i32::<BigEndian>(entry.mode).unwrap();
-    packed.write_i32::<BigEndian>(entry.size).unwrap();
-    packed.write_i32::<BigEndian>(entry.mtime).unwrap();
+    packed.write_u8(state).unwrap();
+    packed.write_i32::<BigEndian>(mode).unwrap();
+    packed.write_i32::<BigEndian>(size).unwrap();
+    packed.write_i32::<BigEndian>(mtime).unwrap();
     packed.write_i32::<BigEndian>(length as i32).unwrap();
     packed.extend(filename.as_bytes());
     if let Some(source) = copy_source {
@@ -127,363 +135,3 @@
         packed.extend(source.as_bytes());
     }
 }
-
-/// Seconds since the Unix epoch
-pub struct Timestamp(pub i64);
-
-impl DirstateEntry {
-    pub fn mtime_is_ambiguous(&self, now: i32) -> bool {
-        self.state == EntryState::Normal && self.mtime == now
-    }
-
-    pub fn clear_ambiguous_mtime(&mut self, now: i32) -> bool {
-        let ambiguous = self.mtime_is_ambiguous(now);
-        if ambiguous {
-            // The file was last modified "simultaneously" with the current
-            // write to dirstate (i.e. within the same second for file-
-            // systems with a granularity of 1 sec). This commonly happens
-            // for at least a couple of files on 'update'.
-            // The user could change the file without changing its size
-            // within the same second. Invalidate the file's mtime in
-            // dirstate, forcing future 'status' calls to compare the
-            // contents of the file if the size is the same. This prevents
-            // mistakenly treating such files as clean.
-            self.clear_mtime()
-        }
-        ambiguous
-    }
-
-    pub fn clear_mtime(&mut self) {
-        self.mtime = -1;
-    }
-}
-
-pub fn pack_dirstate(
-    state_map: &mut StateMap,
-    copy_map: &CopyMap,
-    parents: DirstateParents,
-    now: Timestamp,
-) -> Result<Vec<u8>, HgError> {
-    // TODO move away from i32 before 2038.
-    let now: i32 = now.0.try_into().expect("time overflow");
-
-    let expected_size: usize = state_map
-        .iter()
-        .map(|(filename, _)| {
-            packed_entry_size(filename, copy_map.get(filename).map(|p| &**p))
-        })
-        .sum();
-    let expected_size = expected_size + PARENT_SIZE * 2;
-
-    let mut packed = Vec::with_capacity(expected_size);
-
-    packed.extend(parents.p1.as_bytes());
-    packed.extend(parents.p2.as_bytes());
-
-    for (filename, entry) in state_map.iter_mut() {
-        entry.clear_ambiguous_mtime(now);
-        pack_entry(
-            filename,
-            entry,
-            copy_map.get(filename).map(|p| &**p),
-            &mut packed,
-        )
-    }
-
-    if packed.len() != expected_size {
-        return Err(HgError::CorruptedRepository(format!(
-            "bad dirstate size: {} != {}",
-            expected_size,
-            packed.len()
-        )));
-    }
-
-    Ok(packed)
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use crate::{utils::hg_path::HgPathBuf, FastHashMap};
-    use pretty_assertions::assert_eq;
-
-    #[test]
-    fn test_pack_dirstate_empty() {
-        let mut state_map = StateMap::default();
-        let copymap = FastHashMap::default();
-        let parents = DirstateParents {
-            p1: b"12345678910111213141".into(),
-            p2: b"00000000000000000000".into(),
-        };
-        let now = Timestamp(15000000);
-        let expected = b"1234567891011121314100000000000000000000".to_vec();
-
-        assert_eq!(
-            expected,
-            pack_dirstate(&mut state_map, &copymap, parents, now).unwrap()
-        );
-
-        assert!(state_map.is_empty())
-    }
-    #[test]
-    fn test_pack_dirstate_one_entry() {
-        let expected_state_map: StateMap = [(
-            HgPathBuf::from_bytes(b"f1"),
-            DirstateEntry {
-                state: EntryState::Normal,
-                mode: 0o644,
-                size: 0,
-                mtime: 791231220,
-            },
-        )]
-        .iter()
-        .cloned()
-        .collect();
-        let mut state_map = expected_state_map.clone();
-
-        let copymap = FastHashMap::default();
-        let parents = DirstateParents {
-            p1: b"12345678910111213141".into(),
-            p2: b"00000000000000000000".into(),
-        };
-        let now = Timestamp(15000000);
-        let expected = [
-            49, 50, 51, 52, 53, 54, 55, 56, 57, 49, 48, 49, 49, 49, 50, 49,
-            51, 49, 52, 49, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48,
-            48, 48, 48, 48, 48, 48, 48, 48, 110, 0, 0, 1, 164, 0, 0, 0, 0, 47,
-            41, 58, 244, 0, 0, 0, 2, 102, 49,
-        ]
-        .to_vec();
-
-        assert_eq!(
-            expected,
-            pack_dirstate(&mut state_map, &copymap, parents, now).unwrap()
-        );
-
-        assert_eq!(expected_state_map, state_map);
-    }
-    #[test]
-    fn test_pack_dirstate_one_entry_with_copy() {
-        let expected_state_map: StateMap = [(
-            HgPathBuf::from_bytes(b"f1"),
-            DirstateEntry {
-                state: EntryState::Normal,
-                mode: 0o644,
-                size: 0,
-                mtime: 791231220,
-            },
-        )]
-        .iter()
-        .cloned()
-        .collect();
-        let mut state_map = expected_state_map.clone();
-        let mut copymap = FastHashMap::default();
-        copymap.insert(
-            HgPathBuf::from_bytes(b"f1"),
-            HgPathBuf::from_bytes(b"copyname"),
-        );
-        let parents = DirstateParents {
-            p1: b"12345678910111213141".into(),
-            p2: b"00000000000000000000".into(),
-        };
-        let now = Timestamp(15000000);
-        let expected = [
-            49, 50, 51, 52, 53, 54, 55, 56, 57, 49, 48, 49, 49, 49, 50, 49,
-            51, 49, 52, 49, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48,
-            48, 48, 48, 48, 48, 48, 48, 48, 110, 0, 0, 1, 164, 0, 0, 0, 0, 47,
-            41, 58, 244, 0, 0, 0, 11, 102, 49, 0, 99, 111, 112, 121, 110, 97,
-            109, 101,
-        ]
-        .to_vec();
-
-        assert_eq!(
-            expected,
-            pack_dirstate(&mut state_map, &copymap, parents, now).unwrap()
-        );
-        assert_eq!(expected_state_map, state_map);
-    }
-
-    #[test]
-    fn test_parse_pack_one_entry_with_copy() {
-        let mut state_map: StateMap = [(
-            HgPathBuf::from_bytes(b"f1"),
-            DirstateEntry {
-                state: EntryState::Normal,
-                mode: 0o644,
-                size: 0,
-                mtime: 791231220,
-            },
-        )]
-        .iter()
-        .cloned()
-        .collect();
-        let mut copymap = FastHashMap::default();
-        copymap.insert(
-            HgPathBuf::from_bytes(b"f1"),
-            HgPathBuf::from_bytes(b"copyname"),
-        );
-        let parents = DirstateParents {
-            p1: b"12345678910111213141".into(),
-            p2: b"00000000000000000000".into(),
-        };
-        let now = Timestamp(15000000);
-        let result =
-            pack_dirstate(&mut state_map, &copymap, parents.clone(), now)
-                .unwrap();
-
-        let (new_parents, entries, copies) =
-            parse_dirstate(result.as_slice()).unwrap();
-        let new_state_map: StateMap = entries
-            .into_iter()
-            .map(|(path, entry)| (path.to_owned(), entry))
-            .collect();
-        let new_copy_map: CopyMap = copies
-            .into_iter()
-            .map(|(path, copy)| (path.to_owned(), copy.to_owned()))
-            .collect();
-
-        assert_eq!(
-            (&parents, state_map, copymap),
-            (new_parents, new_state_map, new_copy_map)
-        )
-    }
-
-    #[test]
-    fn test_parse_pack_multiple_entries_with_copy() {
-        let mut state_map: StateMap = [
-            (
-                HgPathBuf::from_bytes(b"f1"),
-                DirstateEntry {
-                    state: EntryState::Normal,
-                    mode: 0o644,
-                    size: 0,
-                    mtime: 791231220,
-                },
-            ),
-            (
-                HgPathBuf::from_bytes(b"f2"),
-                DirstateEntry {
-                    state: EntryState::Merged,
-                    mode: 0o777,
-                    size: 1000,
-                    mtime: 791231220,
-                },
-            ),
-            (
-                HgPathBuf::from_bytes(b"f3"),
-                DirstateEntry {
-                    state: EntryState::Removed,
-                    mode: 0o644,
-                    size: 234553,
-                    mtime: 791231220,
-                },
-            ),
-            (
-                HgPathBuf::from_bytes(b"f4\xF6"),
-                DirstateEntry {
-                    state: EntryState::Added,
-                    mode: 0o644,
-                    size: -1,
-                    mtime: -1,
-                },
-            ),
-        ]
-        .iter()
-        .cloned()
-        .collect();
-        let mut copymap = FastHashMap::default();
-        copymap.insert(
-            HgPathBuf::from_bytes(b"f1"),
-            HgPathBuf::from_bytes(b"copyname"),
-        );
-        copymap.insert(
-            HgPathBuf::from_bytes(b"f4\xF6"),
-            HgPathBuf::from_bytes(b"copyname2"),
-        );
-        let parents = DirstateParents {
-            p1: b"12345678910111213141".into(),
-            p2: b"00000000000000000000".into(),
-        };
-        let now = Timestamp(15000000);
-        let result =
-            pack_dirstate(&mut state_map, &copymap, parents.clone(), now)
-                .unwrap();
-
-        let (new_parents, entries, copies) =
-            parse_dirstate(result.as_slice()).unwrap();
-        let new_state_map: StateMap = entries
-            .into_iter()
-            .map(|(path, entry)| (path.to_owned(), entry))
-            .collect();
-        let new_copy_map: CopyMap = copies
-            .into_iter()
-            .map(|(path, copy)| (path.to_owned(), copy.to_owned()))
-            .collect();
-
-        assert_eq!(
-            (&parents, state_map, copymap),
-            (new_parents, new_state_map, new_copy_map)
-        )
-    }
-
-    #[test]
-    /// https://www.mercurial-scm.org/repo/hg/rev/af3f26b6bba4
-    fn test_parse_pack_one_entry_with_copy_and_time_conflict() {
-        let mut state_map: StateMap = [(
-            HgPathBuf::from_bytes(b"f1"),
-            DirstateEntry {
-                state: EntryState::Normal,
-                mode: 0o644,
-                size: 0,
-                mtime: 15000000,
-            },
-        )]
-        .iter()
-        .cloned()
-        .collect();
-        let mut copymap = FastHashMap::default();
-        copymap.insert(
-            HgPathBuf::from_bytes(b"f1"),
-            HgPathBuf::from_bytes(b"copyname"),
-        );
-        let parents = DirstateParents {
-            p1: b"12345678910111213141".into(),
-            p2: b"00000000000000000000".into(),
-        };
-        let now = Timestamp(15000000);
-        let result =
-            pack_dirstate(&mut state_map, &copymap, parents.clone(), now)
-                .unwrap();
-
-        let (new_parents, entries, copies) =
-            parse_dirstate(result.as_slice()).unwrap();
-        let new_state_map: StateMap = entries
-            .into_iter()
-            .map(|(path, entry)| (path.to_owned(), entry))
-            .collect();
-        let new_copy_map: CopyMap = copies
-            .into_iter()
-            .map(|(path, copy)| (path.to_owned(), copy.to_owned()))
-            .collect();
-
-        assert_eq!(
-            (
-                &parents,
-                [(
-                    HgPathBuf::from_bytes(b"f1"),
-                    DirstateEntry {
-                        state: EntryState::Normal,
-                        mode: 0o644,
-                        size: 0,
-                        mtime: -1
-                    }
-                )]
-                .iter()
-                .cloned()
-                .collect::<StateMap>(),
-                copymap,
-            ),
-            (new_parents, new_state_map, new_copy_map)
-        )
-    }
-}
--- a/rust/hg-core/src/dirstate/status.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/dirstate/status.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -10,33 +10,14 @@
 //! and will only be triggered in narrow cases.
 
 use crate::dirstate_tree::on_disk::DirstateV2ParseError;
-use crate::utils::path_auditor::PathAuditor;
+
 use crate::{
-    dirstate::SIZE_FROM_OTHER_PARENT,
-    filepatterns::PatternFileWarning,
-    matchers::{get_ignore_function, Matcher, VisitChildrenSet},
-    utils::{
-        files::{find_dirs, HgMetadata},
-        hg_path::{
-            hg_path_to_path_buf, os_string_to_hg_path_buf, HgPath, HgPathBuf,
-            HgPathError,
-        },
-    },
-    CopyMap, DirstateEntry, DirstateMap, EntryState, FastHashMap,
+    dirstate::TruncatedTimestamp,
+    utils::hg_path::{HgPath, HgPathError},
     PatternError,
 };
-use lazy_static::lazy_static;
-use micro_timer::timed;
-use rayon::prelude::*;
-use std::{
-    borrow::Cow,
-    collections::HashSet,
-    fmt,
-    fs::{read_dir, DirEntry},
-    io::ErrorKind,
-    ops::Deref,
-    path::{Path, PathBuf},
-};
+
+use std::{borrow::Cow, fmt};
 
 /// Wrong type of file from a `BadMatch`
 /// Note: a lot of those don't exist on all platforms.
@@ -70,32 +51,6 @@
     BadType(BadType),
 }
 
-/// Enum used to dispatch new status entries into the right collections.
-/// Is similar to `crate::EntryState`, but represents the transient state of
-/// entries during the lifetime of a command.
-#[derive(Debug, Copy, Clone)]
-pub enum Dispatch {
-    Unsure,
-    Modified,
-    Added,
-    Removed,
-    Deleted,
-    Clean,
-    Unknown,
-    Ignored,
-    /// Empty dispatch, the file is not worth listing
-    None,
-    /// Was explicitly matched but cannot be found/accessed
-    Bad(BadMatch),
-    Directory {
-        /// True if the directory used to be a file in the dmap so we can say
-        /// that it's been removed.
-        was_file: bool,
-    },
-}
-
-type IoResult<T> = std::io::Result<T>;
-
 /// `Box<dyn Trait>` is syntactic sugar for `Box<dyn Trait + 'static>`, so add
 /// an explicit lifetime here to not fight `'static` bounds "out of nowhere".
 pub type IgnoreFnType<'a> =
@@ -105,147 +60,12 @@
 /// the dirstate/explicit) paths, this comes up a lot.
 pub type HgPathCow<'a> = Cow<'a, HgPath>;
 
-/// A path with its computed ``Dispatch`` information
-type DispatchedPath<'a> = (HgPathCow<'a>, Dispatch);
-
-/// The conversion from `HgPath` to a real fs path failed.
-/// `22` is the error code for "Invalid argument"
-const INVALID_PATH_DISPATCH: Dispatch = Dispatch::Bad(BadMatch::OsError(22));
-
-/// Dates and times that are outside the 31-bit signed range are compared
-/// modulo 2^31. This should prevent hg from behaving badly with very large
-/// files or corrupt dates while still having a high probability of detecting
-/// changes. (issue2608)
-/// TODO I haven't found a way of having `b` be `Into<i32>`, since `From<u64>`
-/// is not defined for `i32`, and there is no `As` trait. This forces the
-/// caller to cast `b` as `i32`.
-fn mod_compare(a: i32, b: i32) -> bool {
-    a & i32::max_value() != b & i32::max_value()
-}
-
-/// Return a sorted list containing information about the entries
-/// in the directory.
-///
-/// * `skip_dot_hg` - Return an empty vec if `path` contains a `.hg` directory
-fn list_directory(
-    path: impl AsRef<Path>,
-    skip_dot_hg: bool,
-) -> std::io::Result<Vec<(HgPathBuf, DirEntry)>> {
-    let mut results = vec![];
-    let entries = read_dir(path.as_ref())?;
-
-    for entry in entries {
-        let entry = entry?;
-        let filename = os_string_to_hg_path_buf(entry.file_name())?;
-        let file_type = entry.file_type()?;
-        if skip_dot_hg && filename.as_bytes() == b".hg" && file_type.is_dir() {
-            return Ok(vec![]);
-        } else {
-            results.push((filename, entry))
-        }
-    }
-
-    results.sort_unstable_by_key(|e| e.0.clone());
-    Ok(results)
-}
-
-/// The file corresponding to the dirstate entry was found on the filesystem.
-fn dispatch_found(
-    filename: impl AsRef<HgPath>,
-    entry: DirstateEntry,
-    metadata: HgMetadata,
-    copy_map: &CopyMap,
-    options: StatusOptions,
-) -> Dispatch {
-    let DirstateEntry {
-        state,
-        mode,
-        mtime,
-        size,
-    } = entry;
-
-    let HgMetadata {
-        st_mode,
-        st_size,
-        st_mtime,
-        ..
-    } = metadata;
-
-    match state {
-        EntryState::Normal => {
-            let size_changed = mod_compare(size, st_size as i32);
-            let mode_changed =
-                (mode ^ st_mode as i32) & 0o100 != 0o000 && options.check_exec;
-            let metadata_changed = size >= 0 && (size_changed || mode_changed);
-            let other_parent = size == SIZE_FROM_OTHER_PARENT;
-
-            if metadata_changed
-                || other_parent
-                || copy_map.contains_key(filename.as_ref())
-            {
-                if metadata.is_symlink() && size_changed {
-                    // issue6456: Size returned may be longer due to encryption
-                    // on EXT-4 fscrypt. TODO maybe only do it on EXT4?
-                    Dispatch::Unsure
-                } else {
-                    Dispatch::Modified
-                }
-            } else if mod_compare(mtime, st_mtime as i32)
-                || st_mtime == options.last_normal_time
-            {
-                // the file may have just been marked as normal and
-                // it may have changed in the same second without
-                // changing its size. This can happen if we quickly
-                // do multiple commits. Force lookup, so we don't
-                // miss such a racy file change.
-                Dispatch::Unsure
-            } else if options.list_clean {
-                Dispatch::Clean
-            } else {
-                Dispatch::None
-            }
-        }
-        EntryState::Merged => Dispatch::Modified,
-        EntryState::Added => Dispatch::Added,
-        EntryState::Removed => Dispatch::Removed,
-        EntryState::Unknown => Dispatch::Unknown,
-    }
-}
-
-/// The file corresponding to this Dirstate entry is missing.
-fn dispatch_missing(state: EntryState) -> Dispatch {
-    match state {
-        // File was removed from the filesystem during commands
-        EntryState::Normal | EntryState::Merged | EntryState::Added => {
-            Dispatch::Deleted
-        }
-        // File was removed, everything is normal
-        EntryState::Removed => Dispatch::Removed,
-        // File is unknown to Mercurial, everything is normal
-        EntryState::Unknown => Dispatch::Unknown,
-    }
-}
-
-fn dispatch_os_error(e: &std::io::Error) -> Dispatch {
-    Dispatch::Bad(BadMatch::OsError(
-        e.raw_os_error().expect("expected real OS error"),
-    ))
-}
-
-lazy_static! {
-    static ref DEFAULT_WORK: HashSet<&'static HgPath> = {
-        let mut h = HashSet::new();
-        h.insert(HgPath::new(b""));
-        h
-    };
-}
-
 #[derive(Debug, Copy, Clone)]
 pub struct StatusOptions {
     /// Remember the most recent modification timeslot for status, to make
     /// sure we won't miss future size-preserving file content modifications
     /// that happen within the same timeslot.
-    pub last_normal_time: i64,
+    pub last_normal_time: TruncatedTimestamp,
     /// Whether we are on a filesystem with UNIX-like exec flags
     pub check_exec: bool,
     pub list_clean: bool,
@@ -325,623 +145,3 @@
         }
     }
 }
-
-/// Gives information about which files are changed in the working directory
-/// and how, compared to the revision we're based on
-pub struct Status<'a, M: ?Sized + Matcher + Sync> {
-    dmap: &'a DirstateMap,
-    pub(crate) matcher: &'a M,
-    root_dir: PathBuf,
-    pub(crate) options: StatusOptions,
-    ignore_fn: IgnoreFnType<'a>,
-}
-
-impl<'a, M> Status<'a, M>
-where
-    M: ?Sized + Matcher + Sync,
-{
-    pub fn new(
-        dmap: &'a DirstateMap,
-        matcher: &'a M,
-        root_dir: PathBuf,
-        ignore_files: Vec<PathBuf>,
-        options: StatusOptions,
-    ) -> StatusResult<(Self, Vec<PatternFileWarning>)> {
-        // Needs to outlive `dir_ignore_fn` since it's captured.
-
-        let (ignore_fn, warnings): (IgnoreFnType, _) =
-            if options.list_ignored || options.list_unknown {
-                get_ignore_function(ignore_files, &root_dir, &mut |_| {})?
-            } else {
-                (Box::new(|&_| true), vec![])
-            };
-
-        Ok((
-            Self {
-                dmap,
-                matcher,
-                root_dir,
-                options,
-                ignore_fn,
-            },
-            warnings,
-        ))
-    }
-
-    /// Is the path ignored?
-    pub fn is_ignored(&self, path: impl AsRef<HgPath>) -> bool {
-        (self.ignore_fn)(path.as_ref())
-    }
-
-    /// Is the path or one of its ancestors ignored?
-    pub fn dir_ignore(&self, dir: impl AsRef<HgPath>) -> bool {
-        // Only involve ignore mechanism if we're listing unknowns or ignored.
-        if self.options.list_ignored || self.options.list_unknown {
-            if self.is_ignored(&dir) {
-                true
-            } else {
-                for p in find_dirs(dir.as_ref()) {
-                    if self.is_ignored(p) {
-                        return true;
-                    }
-                }
-                false
-            }
-        } else {
-            true
-        }
-    }
-
-    /// Get stat data about the files explicitly specified by the matcher.
-    /// Returns a tuple of the directories that need to be traversed and the
-    /// files with their corresponding `Dispatch`.
-    /// TODO subrepos
-    #[timed]
-    pub fn walk_explicit(
-        &self,
-        traversed_sender: crossbeam_channel::Sender<HgPathBuf>,
-    ) -> (Vec<DispatchedPath<'a>>, Vec<DispatchedPath<'a>>) {
-        self.matcher
-            .file_set()
-            .unwrap_or(&DEFAULT_WORK)
-            .par_iter()
-            .flat_map(|&filename| -> Option<_> {
-                // TODO normalization
-                let normalized = filename;
-
-                let buf = match hg_path_to_path_buf(normalized) {
-                    Ok(x) => x,
-                    Err(_) => {
-                        return Some((
-                            Cow::Borrowed(normalized),
-                            INVALID_PATH_DISPATCH,
-                        ))
-                    }
-                };
-                let target = self.root_dir.join(buf);
-                let st = target.symlink_metadata();
-                let in_dmap = self.dmap.get(normalized);
-                match st {
-                    Ok(meta) => {
-                        let file_type = meta.file_type();
-                        return if file_type.is_file() || file_type.is_symlink()
-                        {
-                            if let Some(entry) = in_dmap {
-                                return Some((
-                                    Cow::Borrowed(normalized),
-                                    dispatch_found(
-                                        &normalized,
-                                        *entry,
-                                        HgMetadata::from_metadata(meta),
-                                        &self.dmap.copy_map,
-                                        self.options,
-                                    ),
-                                ));
-                            }
-                            Some((
-                                Cow::Borrowed(normalized),
-                                Dispatch::Unknown,
-                            ))
-                        } else if file_type.is_dir() {
-                            if self.options.collect_traversed_dirs {
-                                traversed_sender
-                                    .send(normalized.to_owned())
-                                    .expect("receiver should outlive sender");
-                            }
-                            Some((
-                                Cow::Borrowed(normalized),
-                                Dispatch::Directory {
-                                    was_file: in_dmap.is_some(),
-                                },
-                            ))
-                        } else {
-                            Some((
-                                Cow::Borrowed(normalized),
-                                Dispatch::Bad(BadMatch::BadType(
-                                    // TODO do more than unknown
-                                    // Support for all `BadType` variant
-                                    // varies greatly between platforms.
-                                    // So far, no tests check the type and
-                                    // this should be good enough for most
-                                    // users.
-                                    BadType::Unknown,
-                                )),
-                            ))
-                        };
-                    }
-                    Err(_) => {
-                        if let Some(entry) = in_dmap {
-                            return Some((
-                                Cow::Borrowed(normalized),
-                                dispatch_missing(entry.state),
-                            ));
-                        }
-                    }
-                };
-                None
-            })
-            .partition(|(_, dispatch)| match dispatch {
-                Dispatch::Directory { .. } => true,
-                _ => false,
-            })
-    }
-
-    /// Walk the working directory recursively to look for changes compared to
-    /// the current `DirstateMap`.
-    ///
-    /// This takes a mutable reference to the results to account for the
-    /// `extend` in timings
-    #[timed]
-    pub fn traverse(
-        &self,
-        path: impl AsRef<HgPath>,
-        old_results: &FastHashMap<HgPathCow<'a>, Dispatch>,
-        results: &mut Vec<DispatchedPath<'a>>,
-        traversed_sender: crossbeam_channel::Sender<HgPathBuf>,
-    ) {
-        // The traversal is done in parallel, so use a channel to gather
-        // entries. `crossbeam_channel::Sender` is `Sync`, while `mpsc::Sender`
-        // is not.
-        let (files_transmitter, files_receiver) =
-            crossbeam_channel::unbounded();
-
-        self.traverse_dir(
-            &files_transmitter,
-            path,
-            &old_results,
-            traversed_sender,
-        );
-
-        // Disconnect the channel so the receiver stops waiting
-        drop(files_transmitter);
-
-        let new_results = files_receiver
-            .into_iter()
-            .par_bridge()
-            .map(|(f, d)| (Cow::Owned(f), d));
-
-        results.par_extend(new_results);
-    }
-
-    /// Dispatch a single entry (file, folder, symlink...) found during
-    /// `traverse`. If the entry is a folder that needs to be traversed, it
-    /// will be handled in a separate thread.
-    fn handle_traversed_entry<'b>(
-        &'a self,
-        scope: &rayon::Scope<'b>,
-        files_sender: &'b crossbeam_channel::Sender<(HgPathBuf, Dispatch)>,
-        old_results: &'a FastHashMap<Cow<HgPath>, Dispatch>,
-        filename: HgPathBuf,
-        dir_entry: DirEntry,
-        traversed_sender: crossbeam_channel::Sender<HgPathBuf>,
-    ) -> IoResult<()>
-    where
-        'a: 'b,
-    {
-        let file_type = dir_entry.file_type()?;
-        let entry_option = self.dmap.get(&filename);
-
-        if filename.as_bytes() == b".hg" {
-            // Could be a directory or a symlink
-            return Ok(());
-        }
-
-        if file_type.is_dir() {
-            self.handle_traversed_dir(
-                scope,
-                files_sender,
-                old_results,
-                entry_option,
-                filename,
-                traversed_sender,
-            );
-        } else if file_type.is_file() || file_type.is_symlink() {
-            if let Some(entry) = entry_option {
-                if self.matcher.matches_everything()
-                    || self.matcher.matches(&filename)
-                {
-                    let metadata = dir_entry.metadata()?;
-                    files_sender
-                        .send((
-                            filename.to_owned(),
-                            dispatch_found(
-                                &filename,
-                                *entry,
-                                HgMetadata::from_metadata(metadata),
-                                &self.dmap.copy_map,
-                                self.options,
-                            ),
-                        ))
-                        .unwrap();
-                }
-            } else if (self.matcher.matches_everything()
-                || self.matcher.matches(&filename))
-                && !self.is_ignored(&filename)
-            {
-                if (self.options.list_ignored
-                    || self.matcher.exact_match(&filename))
-                    && self.dir_ignore(&filename)
-                {
-                    if self.options.list_ignored {
-                        files_sender
-                            .send((filename.to_owned(), Dispatch::Ignored))
-                            .unwrap();
-                    }
-                } else if self.options.list_unknown {
-                    files_sender
-                        .send((filename.to_owned(), Dispatch::Unknown))
-                        .unwrap();
-                }
-            } else if self.is_ignored(&filename) && self.options.list_ignored {
-                if self.matcher.matches(&filename) {
-                    files_sender
-                        .send((filename.to_owned(), Dispatch::Ignored))
-                        .unwrap();
-                }
-            }
-        } else if let Some(entry) = entry_option {
-            // Used to be a file or a folder, now something else.
-            if self.matcher.matches_everything()
-                || self.matcher.matches(&filename)
-            {
-                files_sender
-                    .send((filename.to_owned(), dispatch_missing(entry.state)))
-                    .unwrap();
-            }
-        }
-
-        Ok(())
-    }
-
-    /// A directory was found in the filesystem and needs to be traversed
-    fn handle_traversed_dir<'b>(
-        &'a self,
-        scope: &rayon::Scope<'b>,
-        files_sender: &'b crossbeam_channel::Sender<(HgPathBuf, Dispatch)>,
-        old_results: &'a FastHashMap<Cow<HgPath>, Dispatch>,
-        entry_option: Option<&'a DirstateEntry>,
-        directory: HgPathBuf,
-        traversed_sender: crossbeam_channel::Sender<HgPathBuf>,
-    ) where
-        'a: 'b,
-    {
-        scope.spawn(move |_| {
-            // Nested `if` until `rust-lang/rust#53668` is stable
-            if let Some(entry) = entry_option {
-                // Used to be a file, is now a folder
-                if self.matcher.matches_everything()
-                    || self.matcher.matches(&directory)
-                {
-                    files_sender
-                        .send((
-                            directory.to_owned(),
-                            dispatch_missing(entry.state),
-                        ))
-                        .unwrap();
-                }
-            }
-            // Do we need to traverse it?
-            if !self.is_ignored(&directory) || self.options.list_ignored {
-                self.traverse_dir(
-                    files_sender,
-                    directory,
-                    &old_results,
-                    traversed_sender,
-                )
-            }
-        });
-    }
-
-    /// Decides whether the directory needs to be listed, and if so handles the
-    /// entries in a separate thread.
-    fn traverse_dir(
-        &self,
-        files_sender: &crossbeam_channel::Sender<(HgPathBuf, Dispatch)>,
-        directory: impl AsRef<HgPath>,
-        old_results: &FastHashMap<Cow<HgPath>, Dispatch>,
-        traversed_sender: crossbeam_channel::Sender<HgPathBuf>,
-    ) {
-        let directory = directory.as_ref();
-
-        if self.options.collect_traversed_dirs {
-            traversed_sender
-                .send(directory.to_owned())
-                .expect("receiver should outlive sender");
-        }
-
-        let visit_entries = match self.matcher.visit_children_set(directory) {
-            VisitChildrenSet::Empty => return,
-            VisitChildrenSet::This | VisitChildrenSet::Recursive => None,
-            VisitChildrenSet::Set(set) => Some(set),
-        };
-        let buf = match hg_path_to_path_buf(directory) {
-            Ok(b) => b,
-            Err(_) => {
-                files_sender
-                    .send((directory.to_owned(), INVALID_PATH_DISPATCH))
-                    .expect("receiver should outlive sender");
-                return;
-            }
-        };
-        let dir_path = self.root_dir.join(buf);
-
-        let skip_dot_hg = !directory.as_bytes().is_empty();
-        let entries = match list_directory(dir_path, skip_dot_hg) {
-            Err(e) => {
-                files_sender
-                    .send((directory.to_owned(), dispatch_os_error(&e)))
-                    .expect("receiver should outlive sender");
-                return;
-            }
-            Ok(entries) => entries,
-        };
-
-        rayon::scope(|scope| {
-            for (filename, dir_entry) in entries {
-                if let Some(ref set) = visit_entries {
-                    if !set.contains(filename.deref()) {
-                        continue;
-                    }
-                }
-                // TODO normalize
-                let filename = if directory.is_empty() {
-                    filename.to_owned()
-                } else {
-                    directory.join(&filename)
-                };
-
-                if !old_results.contains_key(filename.deref()) {
-                    match self.handle_traversed_entry(
-                        scope,
-                        files_sender,
-                        old_results,
-                        filename,
-                        dir_entry,
-                        traversed_sender.clone(),
-                    ) {
-                        Err(e) => {
-                            files_sender
-                                .send((
-                                    directory.to_owned(),
-                                    dispatch_os_error(&e),
-                                ))
-                                .expect("receiver should outlive sender");
-                        }
-                        Ok(_) => {}
-                    }
-                }
-            }
-        })
-    }
-
-    /// Add the files in the dirstate to the results.
-    ///
-    /// This takes a mutable reference to the results to account for the
-    /// `extend` in timings
-    #[timed]
-    pub fn extend_from_dmap(&self, results: &mut Vec<DispatchedPath<'a>>) {
-        results.par_extend(
-            self.dmap
-                .par_iter()
-                .filter(|(path, _)| self.matcher.matches(path))
-                .map(move |(filename, entry)| {
-                    let filename: &HgPath = filename;
-                    let filename_as_path = match hg_path_to_path_buf(filename)
-                    {
-                        Ok(f) => f,
-                        Err(_) => {
-                            return (
-                                Cow::Borrowed(filename),
-                                INVALID_PATH_DISPATCH,
-                            )
-                        }
-                    };
-                    let meta = self
-                        .root_dir
-                        .join(filename_as_path)
-                        .symlink_metadata();
-                    match meta {
-                        Ok(m)
-                            if !(m.file_type().is_file()
-                                || m.file_type().is_symlink()) =>
-                        {
-                            (
-                                Cow::Borrowed(filename),
-                                dispatch_missing(entry.state),
-                            )
-                        }
-                        Ok(m) => (
-                            Cow::Borrowed(filename),
-                            dispatch_found(
-                                filename,
-                                *entry,
-                                HgMetadata::from_metadata(m),
-                                &self.dmap.copy_map,
-                                self.options,
-                            ),
-                        ),
-                        Err(e)
-                            if e.kind() == ErrorKind::NotFound
-                                || e.raw_os_error() == Some(20) =>
-                        {
-                            // Rust does not yet have an `ErrorKind` for
-                            // `NotADirectory` (errno 20)
-                            // It happens if the dirstate contains `foo/bar`
-                            // and foo is not a
-                            // directory
-                            (
-                                Cow::Borrowed(filename),
-                                dispatch_missing(entry.state),
-                            )
-                        }
-                        Err(e) => {
-                            (Cow::Borrowed(filename), dispatch_os_error(&e))
-                        }
-                    }
-                }),
-        );
-    }
-
-    /// Checks all files that are in the dirstate but were not found during the
-    /// working directory traversal. This means that the rest must
-    /// be either ignored, under a symlink or under a new nested repo.
-    ///
-    /// This takes a mutable reference to the results to account for the
-    /// `extend` in timings
-    #[timed]
-    pub fn handle_unknowns(&self, results: &mut Vec<DispatchedPath<'a>>) {
-        let to_visit: Vec<(&HgPath, &DirstateEntry)> =
-            if results.is_empty() && self.matcher.matches_everything() {
-                self.dmap.iter().map(|(f, e)| (f.deref(), e)).collect()
-            } else {
-                // Only convert to a hashmap if needed.
-                let old_results: FastHashMap<_, _> =
-                    results.iter().cloned().collect();
-                self.dmap
-                    .iter()
-                    .filter_map(move |(f, e)| {
-                        if !old_results.contains_key(f.deref())
-                            && self.matcher.matches(f)
-                        {
-                            Some((f.deref(), e))
-                        } else {
-                            None
-                        }
-                    })
-                    .collect()
-            };
-
-        let path_auditor = PathAuditor::new(&self.root_dir);
-
-        let new_results = to_visit.into_par_iter().filter_map(
-            |(filename, entry)| -> Option<_> {
-                // Report ignored items in the dmap as long as they are not
-                // under a symlink directory.
-                if path_auditor.check(filename) {
-                    // TODO normalize for case-insensitive filesystems
-                    let buf = match hg_path_to_path_buf(filename) {
-                        Ok(x) => x,
-                        Err(_) => {
-                            return Some((
-                                Cow::Owned(filename.to_owned()),
-                                INVALID_PATH_DISPATCH,
-                            ));
-                        }
-                    };
-                    Some((
-                        Cow::Owned(filename.to_owned()),
-                        match self.root_dir.join(&buf).symlink_metadata() {
-                            // File was just ignored, no links, and exists
-                            Ok(meta) => {
-                                let metadata = HgMetadata::from_metadata(meta);
-                                dispatch_found(
-                                    filename,
-                                    *entry,
-                                    metadata,
-                                    &self.dmap.copy_map,
-                                    self.options,
-                                )
-                            }
-                            // File doesn't exist
-                            Err(_) => dispatch_missing(entry.state),
-                        },
-                    ))
-                } else {
-                    // It's either missing or under a symlink directory which
-                    // we, in this case, report as missing.
-                    Some((
-                        Cow::Owned(filename.to_owned()),
-                        dispatch_missing(entry.state),
-                    ))
-                }
-            },
-        );
-
-        results.par_extend(new_results);
-    }
-}
-
-#[timed]
-pub fn build_response<'a>(
-    results: impl IntoIterator<Item = DispatchedPath<'a>>,
-    traversed: Vec<HgPathCow<'a>>,
-) -> DirstateStatus<'a> {
-    let mut unsure = vec![];
-    let mut modified = vec![];
-    let mut added = vec![];
-    let mut removed = vec![];
-    let mut deleted = vec![];
-    let mut clean = vec![];
-    let mut ignored = vec![];
-    let mut unknown = vec![];
-    let mut bad = vec![];
-
-    for (filename, dispatch) in results.into_iter() {
-        match dispatch {
-            Dispatch::Unknown => unknown.push(filename),
-            Dispatch::Unsure => unsure.push(filename),
-            Dispatch::Modified => modified.push(filename),
-            Dispatch::Added => added.push(filename),
-            Dispatch::Removed => removed.push(filename),
-            Dispatch::Deleted => deleted.push(filename),
-            Dispatch::Clean => clean.push(filename),
-            Dispatch::Ignored => ignored.push(filename),
-            Dispatch::None => {}
-            Dispatch::Bad(reason) => bad.push((filename, reason)),
-            Dispatch::Directory { .. } => {}
-        }
-    }
-
-    DirstateStatus {
-        modified,
-        added,
-        removed,
-        deleted,
-        clean,
-        ignored,
-        unknown,
-        bad,
-        unsure,
-        traversed,
-        dirty: false,
-    }
-}
-
-/// Get the status of files in the working directory.
-///
-/// This is the current entry-point for `hg-core` and is realistically unusable
-/// outside of a Python context because its arguments need to provide a lot of
-/// information that will not be necessary in the future.
-#[timed]
-pub fn status<'a>(
-    dmap: &'a DirstateMap,
-    matcher: &'a (dyn Matcher + Sync),
-    root_dir: PathBuf,
-    ignore_files: Vec<PathBuf>,
-    options: StatusOptions,
-) -> StatusResult<(DirstateStatus<'a>, Vec<PatternFileWarning>)> {
-    let (status, warnings) =
-        Status::new(dmap, matcher, root_dir, ignore_files, options)?;
-
-    Ok((status.run()?, warnings))
-}
--- a/rust/hg-core/src/dirstate_tree.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/dirstate_tree.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -1,5 +1,5 @@
 pub mod dirstate_map;
-pub mod dispatch;
 pub mod on_disk;
+pub mod owning;
 pub mod path_with_basename;
 pub mod status;
--- a/rust/hg-core/src/dirstate_tree/dirstate_map.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/dirstate_tree/dirstate_map.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -1,23 +1,22 @@
 use bytes_cast::BytesCast;
 use micro_timer::timed;
 use std::borrow::Cow;
-use std::convert::TryInto;
 use std::path::PathBuf;
 
 use super::on_disk;
 use super::on_disk::DirstateV2ParseError;
+use super::owning::OwningDirstateMap;
 use super::path_with_basename::WithBasename;
 use crate::dirstate::parsers::pack_entry;
 use crate::dirstate::parsers::packed_entry_size;
 use crate::dirstate::parsers::parse_dirstate_entries;
-use crate::dirstate::parsers::Timestamp;
-use crate::dirstate::MTIME_UNSET;
+use crate::dirstate::CopyMapIter;
+use crate::dirstate::StateMapIter;
+use crate::dirstate::TruncatedTimestamp;
 use crate::dirstate::SIZE_FROM_OTHER_PARENT;
 use crate::dirstate::SIZE_NON_NORMAL;
-use crate::dirstate::V1_RANGEMASK;
 use crate::matchers::Matcher;
 use crate::utils::hg_path::{HgPath, HgPathBuf};
-use crate::CopyMapIter;
 use crate::DirstateEntry;
 use crate::DirstateError;
 use crate::DirstateParents;
@@ -25,7 +24,6 @@
 use crate::EntryState;
 use crate::FastHashMap;
 use crate::PatternFileWarning;
-use crate::StateMapIter;
 use crate::StatusError;
 use crate::StatusOptions;
 
@@ -326,22 +324,17 @@
     pub(super) fn state(
         &self,
     ) -> Result<Option<EntryState>, DirstateV2ParseError> {
-        match self {
-            NodeRef::InMemory(_path, node) => {
-                Ok(node.data.as_entry().map(|entry| entry.state))
-            }
-            NodeRef::OnDisk(node) => node.state(),
-        }
+        Ok(self.entry()?.map(|e| e.state()))
     }
 
     pub(super) fn cached_directory_mtime(
         &self,
-    ) -> Option<&'tree on_disk::Timestamp> {
+    ) -> Result<Option<TruncatedTimestamp>, DirstateV2ParseError> {
         match self {
-            NodeRef::InMemory(_path, node) => match &node.data {
+            NodeRef::InMemory(_path, node) => Ok(match node.data {
                 NodeData::CachedDirectory { mtime } => Some(mtime),
                 _ => None,
-            },
+            }),
             NodeRef::OnDisk(node) => node.cached_directory_mtime(),
         }
     }
@@ -382,7 +375,7 @@
 
 pub(super) enum NodeData {
     Entry(DirstateEntry),
-    CachedDirectory { mtime: on_disk::Timestamp },
+    CachedDirectory { mtime: TruncatedTimestamp },
     None,
 }
 
@@ -445,7 +438,7 @@
         let parents = parse_dirstate_entries(
             map.on_disk,
             |path, entry, copy_source| {
-                let tracked = entry.state.is_tracked();
+                let tracked = entry.state().is_tracked();
                 let node = Self::get_or_insert_node(
                     map.on_disk,
                     &mut map.unreachable_bytes,
@@ -593,12 +586,13 @@
     fn add_or_remove_file(
         &mut self,
         path: &HgPath,
-        old_state: EntryState,
+        old_state: Option<EntryState>,
         new_entry: DirstateEntry,
     ) -> Result<(), DirstateV2ParseError> {
-        let had_entry = old_state != EntryState::Unknown;
+        let had_entry = old_state.is_some();
+        let was_tracked = old_state.map_or(false, |s| s.is_tracked());
         let tracked_count_increment =
-            match (old_state.is_tracked(), new_entry.state.is_tracked()) {
+            match (was_tracked, new_entry.state().is_tracked()) {
                 (false, true) => 1,
                 (true, false) => -1,
                 _ => 0,
@@ -695,34 +689,13 @@
                 path.as_ref(),
             )? {
                 if let NodeData::Entry(entry) = &mut node.data {
-                    entry.clear_mtime();
+                    entry.set_possibly_dirty();
                 }
             }
         }
         Ok(())
     }
 
-    /// Return a faillilble iterator of full paths of nodes that have an
-    /// `entry` for which the given `predicate` returns true.
-    ///
-    /// Fallibility means that each iterator item is a `Result`, which may
-    /// indicate a parse error of the on-disk dirstate-v2 format. Such errors
-    /// should only happen if Mercurial is buggy or a repository is corrupted.
-    fn filter_full_paths<'tree>(
-        &'tree self,
-        predicate: impl Fn(&DirstateEntry) -> bool + 'tree,
-    ) -> impl Iterator<Item = Result<&HgPath, DirstateV2ParseError>> + 'tree
-    {
-        filter_map_results(self.iter_nodes(), move |node| {
-            if let Some(entry) = node.entry()? {
-                if predicate(&entry) {
-                    return Ok(Some(node.full_path(self.on_disk)?));
-                }
-            }
-            Ok(None)
-        })
-    }
-
     fn count_dropped_path(unreachable_bytes: &mut u32, path: &Cow<HgPath>) {
         if let Cow::Borrowed(path) = path {
             *unreachable_bytes += path.len() as u32
@@ -750,78 +723,41 @@
     })
 }
 
-impl<'on_disk> super::dispatch::DirstateMapMethods for DirstateMap<'on_disk> {
-    fn clear(&mut self) {
-        self.root = Default::default();
-        self.nodes_with_entry_count = 0;
-        self.nodes_with_copy_source_count = 0;
+impl OwningDirstateMap {
+    pub fn clear(&mut self) {
+        let map = self.get_map_mut();
+        map.root = Default::default();
+        map.nodes_with_entry_count = 0;
+        map.nodes_with_copy_source_count = 0;
     }
 
-    fn set_v1(&mut self, filename: &HgPath, entry: DirstateEntry) {
-        let node =
-            self.get_or_insert(&filename).expect("no parse error in v1");
-        node.data = NodeData::Entry(entry);
-        node.children = ChildNodes::default();
-        node.copy_source = None;
-        node.descendants_with_entry_count = 0;
-        node.tracked_descendants_count = 0;
-    }
-
-    fn add_file(
+    pub fn set_entry(
         &mut self,
         filename: &HgPath,
         entry: DirstateEntry,
-        added: bool,
-        merged: bool,
-        from_p2: bool,
-        possibly_dirty: bool,
-    ) -> Result<(), DirstateError> {
-        let mut entry = entry;
-        if added {
-            assert!(!possibly_dirty);
-            assert!(!from_p2);
-            entry.state = EntryState::Added;
-            entry.size = SIZE_NON_NORMAL;
-            entry.mtime = MTIME_UNSET;
-        } else if merged {
-            assert!(!possibly_dirty);
-            assert!(!from_p2);
-            entry.state = EntryState::Merged;
-            entry.size = SIZE_FROM_OTHER_PARENT;
-            entry.mtime = MTIME_UNSET;
-        } else if from_p2 {
-            assert!(!possibly_dirty);
-            entry.state = EntryState::Normal;
-            entry.size = SIZE_FROM_OTHER_PARENT;
-            entry.mtime = MTIME_UNSET;
-        } else if possibly_dirty {
-            entry.state = EntryState::Normal;
-            entry.size = SIZE_NON_NORMAL;
-            entry.mtime = MTIME_UNSET;
-        } else {
-            entry.state = EntryState::Normal;
-            entry.size = entry.size & V1_RANGEMASK;
-            entry.mtime = entry.mtime & V1_RANGEMASK;
-        }
-
-        let old_state = match self.get(filename)? {
-            Some(e) => e.state,
-            None => EntryState::Unknown,
-        };
-
-        Ok(self.add_or_remove_file(filename, old_state, entry)?)
+    ) -> Result<(), DirstateV2ParseError> {
+        let map = self.get_map_mut();
+        map.get_or_insert(&filename)?.data = NodeData::Entry(entry);
+        Ok(())
     }
 
-    fn remove_file(
+    pub fn add_file(
+        &mut self,
+        filename: &HgPath,
+        entry: DirstateEntry,
+    ) -> Result<(), DirstateError> {
+        let old_state = self.get(filename)?.map(|e| e.state());
+        let map = self.get_map_mut();
+        Ok(map.add_or_remove_file(filename, old_state, entry)?)
+    }
+
+    pub fn remove_file(
         &mut self,
         filename: &HgPath,
         in_merge: bool,
     ) -> Result<(), DirstateError> {
         let old_entry_opt = self.get(filename)?;
-        let old_state = match old_entry_opt {
-            Some(e) => e.state,
-            None => EntryState::Unknown,
-        };
+        let old_state = old_entry_opt.map(|e| e.state());
         let mut size = 0;
         if in_merge {
             // XXX we should not be able to have 'm' state and 'FROM_P2' if not
@@ -830,10 +766,10 @@
             // would be nice.
             if let Some(old_entry) = old_entry_opt {
                 // backup the previous state
-                if old_entry.state == EntryState::Merged {
+                if old_entry.state() == EntryState::Merged {
                     size = SIZE_NON_NORMAL;
-                } else if old_entry.state == EntryState::Normal
-                    && old_entry.size == SIZE_FROM_OTHER_PARENT
+                } else if old_entry.state() == EntryState::Normal
+                    && old_entry.size() == SIZE_FROM_OTHER_PARENT
                 {
                     // other parent
                     size = SIZE_FROM_OTHER_PARENT;
@@ -843,20 +779,19 @@
         if size == 0 {
             self.copy_map_remove(filename)?;
         }
-        let entry = DirstateEntry {
-            state: EntryState::Removed,
-            mode: 0,
-            size,
-            mtime: 0,
-        };
-        Ok(self.add_or_remove_file(filename, old_state, entry)?)
+        let map = self.get_map_mut();
+        let entry = DirstateEntry::new_removed(size);
+        Ok(map.add_or_remove_file(filename, old_state, entry)?)
     }
 
-    fn drop_file(&mut self, filename: &HgPath) -> Result<bool, DirstateError> {
-        let old_state = match self.get(filename)? {
-            Some(e) => e.state,
-            None => EntryState::Unknown,
-        };
+    pub fn drop_entry_and_copy_source(
+        &mut self,
+        filename: &HgPath,
+    ) -> Result<(), DirstateError> {
+        let was_tracked = self
+            .get(filename)?
+            .map_or(false, |e| e.state().is_tracked());
+        let map = self.get_map_mut();
         struct Dropped {
             was_tracked: bool,
             had_entry: bool,
@@ -915,13 +850,14 @@
                     node.data = NodeData::None
                 }
                 if let Some(source) = &node.copy_source {
-                    DirstateMap::count_dropped_path(unreachable_bytes, source)
+                    DirstateMap::count_dropped_path(unreachable_bytes, source);
+                    node.copy_source = None
                 }
                 dropped = Dropped {
                     was_tracked: node
                         .data
                         .as_entry()
-                        .map_or(false, |entry| entry.state.is_tracked()),
+                        .map_or(false, |entry| entry.state().is_tracked()),
                     had_entry,
                     had_copy_source: node.copy_source.take().is_some(),
                 };
@@ -943,112 +879,29 @@
         }
 
         if let Some((dropped, _removed)) = recur(
-            self.on_disk,
-            &mut self.unreachable_bytes,
-            &mut self.root,
+            map.on_disk,
+            &mut map.unreachable_bytes,
+            &mut map.root,
             filename,
         )? {
             if dropped.had_entry {
-                self.nodes_with_entry_count -= 1
+                map.nodes_with_entry_count -= 1
             }
             if dropped.had_copy_source {
-                self.nodes_with_copy_source_count -= 1
+                map.nodes_with_copy_source_count -= 1
             }
-            Ok(dropped.had_entry)
         } else {
-            debug_assert!(!old_state.is_tracked());
-            Ok(false)
-        }
-    }
-
-    fn clear_ambiguous_times(
-        &mut self,
-        filenames: Vec<HgPathBuf>,
-        now: i32,
-    ) -> Result<(), DirstateV2ParseError> {
-        for filename in filenames {
-            if let Some(node) = Self::get_node_mut(
-                self.on_disk,
-                &mut self.unreachable_bytes,
-                &mut self.root,
-                &filename,
-            )? {
-                if let NodeData::Entry(entry) = &mut node.data {
-                    entry.clear_ambiguous_mtime(now);
-                }
-            }
+            debug_assert!(!was_tracked);
         }
         Ok(())
     }
 
-    fn non_normal_entries_contains(
-        &mut self,
-        key: &HgPath,
-    ) -> Result<bool, DirstateV2ParseError> {
-        Ok(if let Some(node) = self.get_node(key)? {
-            node.entry()?.map_or(false, |entry| entry.is_non_normal())
-        } else {
-            false
-        })
-    }
-
-    fn non_normal_entries_remove(&mut self, key: &HgPath) -> bool {
-        // Do nothing, this `DirstateMap` does not have a separate "non normal
-        // entries" set that need to be kept up to date.
-        if let Ok(Some(v)) = self.get(key) {
-            return v.is_non_normal();
-        }
-        false
-    }
-
-    fn non_normal_entries_add(&mut self, _key: &HgPath) {
-        // Do nothing, this `DirstateMap` does not have a separate "non normal
-        // entries" set that need to be kept up to date
-    }
-
-    fn non_normal_or_other_parent_paths(
-        &mut self,
-    ) -> Box<dyn Iterator<Item = Result<&HgPath, DirstateV2ParseError>> + '_>
-    {
-        Box::new(self.filter_full_paths(|entry| {
-            entry.is_non_normal() || entry.is_from_other_parent()
-        }))
-    }
-
-    fn set_non_normal_other_parent_entries(&mut self, _force: bool) {
-        // Do nothing, this `DirstateMap` does not have a separate "non normal
-        // entries" and "from other parent" sets that need to be recomputed
-    }
-
-    fn iter_non_normal_paths(
-        &mut self,
-    ) -> Box<
-        dyn Iterator<Item = Result<&HgPath, DirstateV2ParseError>> + Send + '_,
-    > {
-        self.iter_non_normal_paths_panic()
-    }
-
-    fn iter_non_normal_paths_panic(
-        &self,
-    ) -> Box<
-        dyn Iterator<Item = Result<&HgPath, DirstateV2ParseError>> + Send + '_,
-    > {
-        Box::new(self.filter_full_paths(|entry| entry.is_non_normal()))
-    }
-
-    fn iter_other_parent_paths(
-        &mut self,
-    ) -> Box<
-        dyn Iterator<Item = Result<&HgPath, DirstateV2ParseError>> + Send + '_,
-    > {
-        Box::new(self.filter_full_paths(|entry| entry.is_from_other_parent()))
-    }
-
-    fn has_tracked_dir(
+    pub fn has_tracked_dir(
         &mut self,
         directory: &HgPath,
     ) -> Result<bool, DirstateError> {
-        if let Some(node) = self.get_node(directory)? {
+        let map = self.get_map_mut();
+        if let Some(node) = map.get_node(directory)? {
             // A node without a `DirstateEntry` was created to hold child
             // nodes, and is therefore a directory.
             let state = node.state()?;
@@ -1058,8 +911,12 @@
         }
     }
 
-    fn has_dir(&mut self, directory: &HgPath) -> Result<bool, DirstateError> {
-        if let Some(node) = self.get_node(directory)? {
+    pub fn has_dir(
+        &mut self,
+        directory: &HgPath,
+    ) -> Result<bool, DirstateError> {
+        let map = self.get_map_mut();
+        if let Some(node) = map.get_node(directory)? {
             // A node without a `DirstateEntry` was created to hold child
             // nodes, and is therefore a directory.
             let state = node.state()?;
@@ -1070,43 +927,43 @@
     }
 
     #[timed]
-    fn pack_v1(
+    pub fn pack_v1(
         &mut self,
         parents: DirstateParents,
-        now: Timestamp,
+        now: TruncatedTimestamp,
     ) -> Result<Vec<u8>, DirstateError> {
-        let now: i32 = now.0.try_into().expect("time overflow");
+        let map = self.get_map_mut();
         let mut ambiguous_mtimes = Vec::new();
         // Optizimation (to be measured?): pre-compute size to avoid `Vec`
         // reallocations
         let mut size = parents.as_bytes().len();
-        for node in self.iter_nodes() {
+        for node in map.iter_nodes() {
             let node = node?;
             if let Some(entry) = node.entry()? {
                 size += packed_entry_size(
-                    node.full_path(self.on_disk)?,
-                    node.copy_source(self.on_disk)?,
+                    node.full_path(map.on_disk)?,
+                    node.copy_source(map.on_disk)?,
                 );
-                if entry.mtime_is_ambiguous(now) {
+                if entry.need_delay(now) {
                     ambiguous_mtimes.push(
-                        node.full_path_borrowed(self.on_disk)?
+                        node.full_path_borrowed(map.on_disk)?
                             .detach_from_tree(),
                     )
                 }
             }
         }
-        self.clear_known_ambiguous_mtimes(&ambiguous_mtimes)?;
+        map.clear_known_ambiguous_mtimes(&ambiguous_mtimes)?;
 
         let mut packed = Vec::with_capacity(size);
         packed.extend(parents.as_bytes());
 
-        for node in self.iter_nodes() {
+        for node in map.iter_nodes() {
             let node = node?;
             if let Some(entry) = node.entry()? {
                 pack_entry(
-                    node.full_path(self.on_disk)?,
+                    node.full_path(map.on_disk)?,
                     &entry,
-                    node.copy_source(self.on_disk)?,
+                    node.copy_source(map.on_disk)?,
                     &mut packed,
                 );
             }
@@ -1116,23 +973,22 @@
 
     /// Returns new data and metadata together with whether that data should be
     /// appended to the existing data file whose content is at
-    /// `self.on_disk` (true), instead of written to a new data file
+    /// `map.on_disk` (true), instead of written to a new data file
     /// (false).
     #[timed]
-    fn pack_v2(
+    pub fn pack_v2(
         &mut self,
-        now: Timestamp,
+        now: TruncatedTimestamp,
         can_append: bool,
     ) -> Result<(Vec<u8>, Vec<u8>, bool), DirstateError> {
-        // TODO: how do we want to handle this in 2038?
-        let now: i32 = now.0.try_into().expect("time overflow");
+        let map = self.get_map_mut();
         let mut paths = Vec::new();
-        for node in self.iter_nodes() {
+        for node in map.iter_nodes() {
             let node = node?;
             if let Some(entry) = node.entry()? {
-                if entry.mtime_is_ambiguous(now) {
+                if entry.need_delay(now) {
                     paths.push(
-                        node.full_path_borrowed(self.on_disk)?
+                        node.full_path_borrowed(map.on_disk)?
                             .detach_from_tree(),
                     )
                 }
@@ -1140,12 +996,12 @@
         }
         // Borrow of `self` ends here since we collect cloned paths
 
-        self.clear_known_ambiguous_mtimes(&paths)?;
+        map.clear_known_ambiguous_mtimes(&paths)?;
 
-        on_disk::write(self, can_append)
+        on_disk::write(map, can_append)
     }
 
-    fn status<'a>(
+    pub fn status<'a>(
         &'a mut self,
         matcher: &'a (dyn Matcher + Sync),
         root_dir: PathBuf,
@@ -1153,119 +1009,129 @@
         options: StatusOptions,
     ) -> Result<(DirstateStatus<'a>, Vec<PatternFileWarning>), StatusError>
     {
-        super::status::status(self, matcher, root_dir, ignore_files, options)
+        let map = self.get_map_mut();
+        super::status::status(map, matcher, root_dir, ignore_files, options)
     }
 
-    fn copy_map_len(&self) -> usize {
-        self.nodes_with_copy_source_count as usize
+    pub fn copy_map_len(&self) -> usize {
+        let map = self.get_map();
+        map.nodes_with_copy_source_count as usize
     }
 
-    fn copy_map_iter(&self) -> CopyMapIter<'_> {
-        Box::new(filter_map_results(self.iter_nodes(), move |node| {
-            Ok(if let Some(source) = node.copy_source(self.on_disk)? {
-                Some((node.full_path(self.on_disk)?, source))
+    pub fn copy_map_iter(&self) -> CopyMapIter<'_> {
+        let map = self.get_map();
+        Box::new(filter_map_results(map.iter_nodes(), move |node| {
+            Ok(if let Some(source) = node.copy_source(map.on_disk)? {
+                Some((node.full_path(map.on_disk)?, source))
             } else {
                 None
             })
         }))
     }
 
-    fn copy_map_contains_key(
+    pub fn copy_map_contains_key(
         &self,
         key: &HgPath,
     ) -> Result<bool, DirstateV2ParseError> {
-        Ok(if let Some(node) = self.get_node(key)? {
+        let map = self.get_map();
+        Ok(if let Some(node) = map.get_node(key)? {
             node.has_copy_source()
         } else {
             false
         })
     }
 
-    fn copy_map_get(
+    pub fn copy_map_get(
         &self,
         key: &HgPath,
     ) -> Result<Option<&HgPath>, DirstateV2ParseError> {
-        if let Some(node) = self.get_node(key)? {
-            if let Some(source) = node.copy_source(self.on_disk)? {
+        let map = self.get_map();
+        if let Some(node) = map.get_node(key)? {
+            if let Some(source) = node.copy_source(map.on_disk)? {
                 return Ok(Some(source));
             }
         }
         Ok(None)
     }
 
-    fn copy_map_remove(
+    pub fn copy_map_remove(
         &mut self,
         key: &HgPath,
     ) -> Result<Option<HgPathBuf>, DirstateV2ParseError> {
-        let count = &mut self.nodes_with_copy_source_count;
-        let unreachable_bytes = &mut self.unreachable_bytes;
-        Ok(Self::get_node_mut(
-            self.on_disk,
+        let map = self.get_map_mut();
+        let count = &mut map.nodes_with_copy_source_count;
+        let unreachable_bytes = &mut map.unreachable_bytes;
+        Ok(DirstateMap::get_node_mut(
+            map.on_disk,
             unreachable_bytes,
-            &mut self.root,
+            &mut map.root,
             key,
         )?
         .and_then(|node| {
             if let Some(source) = &node.copy_source {
                 *count -= 1;
-                Self::count_dropped_path(unreachable_bytes, source);
+                DirstateMap::count_dropped_path(unreachable_bytes, source);
             }
             node.copy_source.take().map(Cow::into_owned)
         }))
     }
 
-    fn copy_map_insert(
+    pub fn copy_map_insert(
         &mut self,
         key: HgPathBuf,
         value: HgPathBuf,
     ) -> Result<Option<HgPathBuf>, DirstateV2ParseError> {
-        let node = Self::get_or_insert_node(
-            self.on_disk,
-            &mut self.unreachable_bytes,
-            &mut self.root,
+        let map = self.get_map_mut();
+        let node = DirstateMap::get_or_insert_node(
+            map.on_disk,
+            &mut map.unreachable_bytes,
+            &mut map.root,
             &key,
             WithBasename::to_cow_owned,
             |_ancestor| {},
         )?;
         if node.copy_source.is_none() {
-            self.nodes_with_copy_source_count += 1
+            map.nodes_with_copy_source_count += 1
         }
         Ok(node.copy_source.replace(value.into()).map(Cow::into_owned))
     }
 
-    fn len(&self) -> usize {
-        self.nodes_with_entry_count as usize
+    pub fn len(&self) -> usize {
+        let map = self.get_map();
+        map.nodes_with_entry_count as usize
     }
 
-    fn contains_key(
+    pub fn contains_key(
         &self,
         key: &HgPath,
     ) -> Result<bool, DirstateV2ParseError> {
         Ok(self.get(key)?.is_some())
     }
 
-    fn get(
+    pub fn get(
         &self,
         key: &HgPath,
     ) -> Result<Option<DirstateEntry>, DirstateV2ParseError> {
-        Ok(if let Some(node) = self.get_node(key)? {
+        let map = self.get_map();
+        Ok(if let Some(node) = map.get_node(key)? {
             node.entry()?
         } else {
             None
         })
     }
 
-    fn iter(&self) -> StateMapIter<'_> {
-        Box::new(filter_map_results(self.iter_nodes(), move |node| {
+    pub fn iter(&self) -> StateMapIter<'_> {
+        let map = self.get_map();
+        Box::new(filter_map_results(map.iter_nodes(), move |node| {
             Ok(if let Some(entry) = node.entry()? {
-                Some((node.full_path(self.on_disk)?, entry))
+                Some((node.full_path(map.on_disk)?, entry))
             } else {
                 None
             })
         }))
     }
 
-    fn iter_tracked_dirs(
+    pub fn iter_tracked_dirs(
         &mut self,
     ) -> Result<
         Box<
@@ -1275,9 +1141,10 @@
         >,
         DirstateError,
     > {
-        let on_disk = self.on_disk;
+        let map = self.get_map_mut();
+        let on_disk = map.on_disk;
         Ok(Box::new(filter_map_results(
-            self.iter_nodes(),
+            map.iter_nodes(),
             move |node| {
                 Ok(if node.tracked_descendants_count() > 0 {
                     Some(node.full_path(on_disk)?)
@@ -1288,8 +1155,9 @@
         )))
     }
 
-    fn debug_iter(
+    pub fn debug_iter(
         &self,
+        all: bool,
     ) -> Box<
         dyn Iterator<
                 Item = Result<
@@ -1299,16 +1167,18 @@
             > + Send
             + '_,
     > {
-        Box::new(self.iter_nodes().map(move |node| {
-            let node = node?;
+        let map = self.get_map();
+        Box::new(filter_map_results(map.iter_nodes(), move |node| {
             let debug_tuple = if let Some(entry) = node.entry()? {
                 entry.debug_tuple()
-            } else if let Some(mtime) = node.cached_directory_mtime() {
-                (b' ', 0, -1, mtime.seconds() as i32)
+            } else if !all {
+                return Ok(None);
+            } else if let Some(mtime) = node.cached_directory_mtime()? {
+                (b' ', 0, -1, mtime.truncated_seconds() as i32)
             } else {
                 (b' ', 0, -1, -1)
             };
-            Ok((node.full_path(self.on_disk)?, debug_tuple))
+            Ok(Some((node.full_path(map.on_disk)?, debug_tuple)))
         }))
     }
 }
--- a/rust/hg-core/src/dirstate_tree/dispatch.rs	Thu Oct 21 14:03:33 2021 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,556 +0,0 @@
-use std::path::PathBuf;
-
-use crate::dirstate::parsers::Timestamp;
-use crate::dirstate_tree::on_disk::DirstateV2ParseError;
-use crate::matchers::Matcher;
-use crate::utils::hg_path::{HgPath, HgPathBuf};
-use crate::CopyMapIter;
-use crate::DirstateEntry;
-use crate::DirstateError;
-use crate::DirstateMap;
-use crate::DirstateParents;
-use crate::DirstateStatus;
-use crate::PatternFileWarning;
-use crate::StateMapIter;
-use crate::StatusError;
-use crate::StatusOptions;
-
-/// `rust/hg-cpython/src/dirstate/dirstate_map.rs` implements in Rust a
-/// `DirstateMap` Python class that wraps `Box<dyn DirstateMapMethods + Send>`,
-/// a trait object of this trait. Except for constructors, this trait defines
-/// all APIs that the class needs to interact with its inner dirstate map.
-///
-/// A trait object is used to support two different concrete types:
-///
-/// * `rust/hg-core/src/dirstate/dirstate_map.rs` defines the "flat dirstate
-///   map" which is based on a few large `HgPath`-keyed `HashMap` and `HashSet`
-///   fields.
-/// * `rust/hg-core/src/dirstate_tree/dirstate_map.rs` defines the "tree
-///   dirstate map" based on a tree data struture with nodes for directories
-///   containing child nodes for their files and sub-directories. This tree
-///   enables a more efficient algorithm for `hg status`, but its details are
-///   abstracted in this trait.
-///
-/// The dirstate map associates paths of files in the working directory to
-/// various information about the state of those files.
-pub trait DirstateMapMethods {
-    /// Remove information about all files in this map
-    fn clear(&mut self);
-
-    fn set_v1(&mut self, filename: &HgPath, entry: DirstateEntry);
-
-    /// Add or change the information associated to a given file.
-    ///
-    /// `old_state` is the state in the entry that `get` would have returned
-    /// before this call, or `EntryState::Unknown` if there was no such entry.
-    ///
-    /// `entry.state` should never be `EntryState::Unknown`.
-    fn add_file(
-        &mut self,
-        filename: &HgPath,
-        entry: DirstateEntry,
-        added: bool,
-        merged: bool,
-        from_p2: bool,
-        possibly_dirty: bool,
-    ) -> Result<(), DirstateError>;
-
-    /// Mark a file as "removed" (as in `hg rm`).
-    ///
-    /// `old_state` is the state in the entry that `get` would have returned
-    /// before this call, or `EntryState::Unknown` if there was no such entry.
-    ///
-    /// `size` is not actually a size but the 0 or -1 or -2 value that would be
-    /// put in the size field in the dirstate-v1 format.
-    fn remove_file(
-        &mut self,
-        filename: &HgPath,
-        in_merge: bool,
-    ) -> Result<(), DirstateError>;
-
-    /// Drop information about this file from the map if any, and return
-    /// whether there was any.
-    ///
-    /// `get` will now return `None` for this filename.
-    ///
-    /// `old_state` is the state in the entry that `get` would have returned
-    /// before this call, or `EntryState::Unknown` if there was no such entry.
-    fn drop_file(&mut self, filename: &HgPath) -> Result<bool, DirstateError>;
-
-    /// Among given files, mark the stored `mtime` as ambiguous if there is one
-    /// (if `state == EntryState::Normal`) equal to the given current Unix
-    /// timestamp.
-    fn clear_ambiguous_times(
-        &mut self,
-        filenames: Vec<HgPathBuf>,
-        now: i32,
-    ) -> Result<(), DirstateV2ParseError>;
-
-    /// Return whether the map has an "non-normal" entry for the given
-    /// filename. That is, any entry with a `state` other than
-    /// `EntryState::Normal` or with an ambiguous `mtime`.
-    fn non_normal_entries_contains(
-        &mut self,
-        key: &HgPath,
-    ) -> Result<bool, DirstateV2ParseError>;
-
-    /// Mark the given path as "normal" file. This is only relevant in the flat
-    /// dirstate map where there is a separate `HashSet` that needs to be kept
-    /// up to date.
-    /// Returns whether the key was present in the set.
-    fn non_normal_entries_remove(&mut self, key: &HgPath) -> bool;
-
-    /// Mark the given path as "non-normal" file.
-    /// This is only relevant in the flat dirstate map where there is a
-    /// separate `HashSet` that needs to be kept up to date.
-    fn non_normal_entries_add(&mut self, key: &HgPath);
-
-    /// Return an iterator of paths whose respective entry are either
-    /// "non-normal" (see `non_normal_entries_contains`) or "from other
-    /// parent".
-    ///
-    /// If that information is cached, create the cache as needed.
-    ///
-    /// "From other parent" is defined as `state == Normal && size == -2`.
-    ///
-    /// Because parse errors can happen during iteration, the iterated items
-    /// are `Result`s.
-    fn non_normal_or_other_parent_paths(
-        &mut self,
-    ) -> Box<dyn Iterator<Item = Result<&HgPath, DirstateV2ParseError>> + '_>;
-
-    /// Create the cache for `non_normal_or_other_parent_paths` if needed.
-    ///
-    /// If `force` is true, the cache is re-created even if it already exists.
-    fn set_non_normal_other_parent_entries(&mut self, force: bool);
-
-    /// Return an iterator of paths whose respective entry are "non-normal"
-    /// (see `non_normal_entries_contains`).
-    ///
-    /// If that information is cached, create the cache as needed.
-    ///
-    /// Because parse errors can happen during iteration, the iterated items
-    /// are `Result`s.
-    fn iter_non_normal_paths(
-        &mut self,
-    ) -> Box<
-        dyn Iterator<Item = Result<&HgPath, DirstateV2ParseError>> + Send + '_,
-    >;
-
-    /// Same as `iter_non_normal_paths`, but takes `&self` instead of `&mut
-    /// self`.
-    ///
-    /// Panics if a cache is necessary but does not exist yet.
-    fn iter_non_normal_paths_panic(
-        &self,
-    ) -> Box<
-        dyn Iterator<Item = Result<&HgPath, DirstateV2ParseError>> + Send + '_,
-    >;
-
-    /// Return an iterator of paths whose respective entry are "from other
-    /// parent".
-    ///
-    /// If that information is cached, create the cache as needed.
-    ///
-    /// "From other parent" is defined as `state == Normal && size == -2`.
-    ///
-    /// Because parse errors can happen during iteration, the iterated items
-    /// are `Result`s.
-    fn iter_other_parent_paths(
-        &mut self,
-    ) -> Box<
-        dyn Iterator<Item = Result<&HgPath, DirstateV2ParseError>> + Send + '_,
-    >;
-
-    /// Returns whether the sub-tree rooted at the given directory contains any
-    /// tracked file.
-    ///
-    /// A file is tracked if it has a `state` other than `EntryState::Removed`.
-    fn has_tracked_dir(
-        &mut self,
-        directory: &HgPath,
-    ) -> Result<bool, DirstateError>;
-
-    /// Returns whether the sub-tree rooted at the given directory contains any
-    /// file with a dirstate entry.
-    fn has_dir(&mut self, directory: &HgPath) -> Result<bool, DirstateError>;
-
-    /// Clear mtimes that are ambigous with `now` (similar to
-    /// `clear_ambiguous_times` but for all files in the dirstate map), and
-    /// serialize bytes to write the `.hg/dirstate` file to disk in dirstate-v1
-    /// format.
-    fn pack_v1(
-        &mut self,
-        parents: DirstateParents,
-        now: Timestamp,
-    ) -> Result<Vec<u8>, DirstateError>;
-
-    /// Clear mtimes that are ambigous with `now` (similar to
-    /// `clear_ambiguous_times` but for all files in the dirstate map), and
-    /// serialize bytes to write a dirstate data file to disk in dirstate-v2
-    /// format.
-    ///
-    /// Returns new data and metadata together with whether that data should be
-    /// appended to the existing data file whose content is at
-    /// `self.on_disk` (true), instead of written to a new data file
-    /// (false).
-    ///
-    /// Note: this is only supported by the tree dirstate map.
-    fn pack_v2(
-        &mut self,
-        now: Timestamp,
-        can_append: bool,
-    ) -> Result<(Vec<u8>, Vec<u8>, bool), DirstateError>;
-
-    /// Run the status algorithm.
-    ///
-    /// This is not sematically a method of the dirstate map, but a different
-    /// algorithm is used for the flat v.s. tree dirstate map so having it in
-    /// this trait enables the same dynamic dispatch as with other methods.
-    fn status<'a>(
-        &'a mut self,
-        matcher: &'a (dyn Matcher + Sync),
-        root_dir: PathBuf,
-        ignore_files: Vec<PathBuf>,
-        options: StatusOptions,
-    ) -> Result<(DirstateStatus<'a>, Vec<PatternFileWarning>), StatusError>;
-
-    /// Returns how many files in the dirstate map have a recorded copy source.
-    fn copy_map_len(&self) -> usize;
-
-    /// Returns an iterator of `(path, copy_source)` for all files that have a
-    /// copy source.
-    fn copy_map_iter(&self) -> CopyMapIter<'_>;
-
-    /// Returns whether the givef file has a copy source.
-    fn copy_map_contains_key(
-        &self,
-        key: &HgPath,
-    ) -> Result<bool, DirstateV2ParseError>;
-
-    /// Returns the copy source for the given file.
-    fn copy_map_get(
-        &self,
-        key: &HgPath,
-    ) -> Result<Option<&HgPath>, DirstateV2ParseError>;
-
-    /// Removes the recorded copy source if any for the given file, and returns
-    /// it.
-    fn copy_map_remove(
-        &mut self,
-        key: &HgPath,
-    ) -> Result<Option<HgPathBuf>, DirstateV2ParseError>;
-
-    /// Set the given `value` copy source for the given `key` file.
-    fn copy_map_insert(
-        &mut self,
-        key: HgPathBuf,
-        value: HgPathBuf,
-    ) -> Result<Option<HgPathBuf>, DirstateV2ParseError>;
-
-    /// Returns the number of files that have an entry.
-    fn len(&self) -> usize;
-
-    /// Returns whether the given file has an entry.
-    fn contains_key(&self, key: &HgPath)
-        -> Result<bool, DirstateV2ParseError>;
-
-    /// Returns the entry, if any, for the given file.
-    fn get(
-        &self,
-        key: &HgPath,
-    ) -> Result<Option<DirstateEntry>, DirstateV2ParseError>;
-
-    /// Returns a `(path, entry)` iterator of files that have an entry.
-    ///
-    /// Because parse errors can happen during iteration, the iterated items
-    /// are `Result`s.
-    fn iter(&self) -> StateMapIter<'_>;
-
-    /// Returns an iterator of tracked directories.
-    ///
-    /// This is the paths for which `has_tracked_dir` would return true.
-    /// Or, in other words, the union of ancestor paths of all paths that have
-    /// an associated entry in a "tracked" state in this dirstate map.
-    ///
-    /// Because parse errors can happen during iteration, the iterated items
-    /// are `Result`s.
-    fn iter_tracked_dirs(
-        &mut self,
-    ) -> Result<
-        Box<
-            dyn Iterator<Item = Result<&HgPath, DirstateV2ParseError>>
-                + Send
-                + '_,
-        >,
-        DirstateError,
-    >;
-
-    /// Return an iterator of `(path, (state, mode, size, mtime))` for every
-    /// node stored in this dirstate map, for the purpose of the `hg
-    /// debugdirstate` command.
-    ///
-    /// For nodes that don’t have an entry, `state` is the ASCII space.
-    /// An `mtime` may still be present. It is used to optimize `status`.
-    ///
-    /// Because parse errors can happen during iteration, the iterated items
-    /// are `Result`s.
-    fn debug_iter(
-        &self,
-    ) -> Box<
-        dyn Iterator<
-                Item = Result<
-                    (&HgPath, (u8, i32, i32, i32)),
-                    DirstateV2ParseError,
-                >,
-            > + Send
-            + '_,
-    >;
-}
-
-impl DirstateMapMethods for DirstateMap {
-    fn clear(&mut self) {
-        self.clear()
-    }
-
-    /// Used to set a value directory.
-    ///
-    /// XXX Is temporary during a refactor of V1 dirstate and will disappear
-    /// shortly.
-    fn set_v1(&mut self, filename: &HgPath, entry: DirstateEntry) {
-        self.set_v1_inner(&filename, entry)
-    }
-
-    fn add_file(
-        &mut self,
-        filename: &HgPath,
-        entry: DirstateEntry,
-        added: bool,
-        merged: bool,
-        from_p2: bool,
-        possibly_dirty: bool,
-    ) -> Result<(), DirstateError> {
-        self.add_file(filename, entry, added, merged, from_p2, possibly_dirty)
-    }
-
-    fn remove_file(
-        &mut self,
-        filename: &HgPath,
-        in_merge: bool,
-    ) -> Result<(), DirstateError> {
-        self.remove_file(filename, in_merge)
-    }
-
-    fn drop_file(&mut self, filename: &HgPath) -> Result<bool, DirstateError> {
-        self.drop_file(filename)
-    }
-
-    fn clear_ambiguous_times(
-        &mut self,
-        filenames: Vec<HgPathBuf>,
-        now: i32,
-    ) -> Result<(), DirstateV2ParseError> {
-        Ok(self.clear_ambiguous_times(filenames, now))
-    }
-
-    fn non_normal_entries_contains(
-        &mut self,
-        key: &HgPath,
-    ) -> Result<bool, DirstateV2ParseError> {
-        let (non_normal, _other_parent) =
-            self.get_non_normal_other_parent_entries();
-        Ok(non_normal.contains(key))
-    }
-
-    fn non_normal_entries_remove(&mut self, key: &HgPath) -> bool {
-        self.non_normal_entries_remove(key)
-    }
-
-    fn non_normal_entries_add(&mut self, key: &HgPath) {
-        self.non_normal_entries_add(key)
-    }
-
-    fn non_normal_or_other_parent_paths(
-        &mut self,
-    ) -> Box<dyn Iterator<Item = Result<&HgPath, DirstateV2ParseError>> + '_>
-    {
-        let (non_normal, other_parent) =
-            self.get_non_normal_other_parent_entries();
-        Box::new(non_normal.union(other_parent).map(|p| Ok(&**p)))
-    }
-
-    fn set_non_normal_other_parent_entries(&mut self, force: bool) {
-        self.set_non_normal_other_parent_entries(force)
-    }
-
-    fn iter_non_normal_paths(
-        &mut self,
-    ) -> Box<
-        dyn Iterator<Item = Result<&HgPath, DirstateV2ParseError>> + Send + '_,
-    > {
-        let (non_normal, _other_parent) =
-            self.get_non_normal_other_parent_entries();
-        Box::new(non_normal.iter().map(|p| Ok(&**p)))
-    }
-
-    fn iter_non_normal_paths_panic(
-        &self,
-    ) -> Box<
-        dyn Iterator<Item = Result<&HgPath, DirstateV2ParseError>> + Send + '_,
-    > {
-        let (non_normal, _other_parent) =
-            self.get_non_normal_other_parent_entries_panic();
-        Box::new(non_normal.iter().map(|p| Ok(&**p)))
-    }
-
-    fn iter_other_parent_paths(
-        &mut self,
-    ) -> Box<
-        dyn Iterator<Item = Result<&HgPath, DirstateV2ParseError>> + Send + '_,
-    > {
-        let (_non_normal, other_parent) =
-            self.get_non_normal_other_parent_entries();
-        Box::new(other_parent.iter().map(|p| Ok(&**p)))
-    }
-
-    fn has_tracked_dir(
-        &mut self,
-        directory: &HgPath,
-    ) -> Result<bool, DirstateError> {
-        self.has_tracked_dir(directory)
-    }
-
-    fn has_dir(&mut self, directory: &HgPath) -> Result<bool, DirstateError> {
-        self.has_dir(directory)
-    }
-
-    fn pack_v1(
-        &mut self,
-        parents: DirstateParents,
-        now: Timestamp,
-    ) -> Result<Vec<u8>, DirstateError> {
-        self.pack(parents, now)
-    }
-
-    fn pack_v2(
-        &mut self,
-        _now: Timestamp,
-        _can_append: bool,
-    ) -> Result<(Vec<u8>, Vec<u8>, bool), DirstateError> {
-        panic!(
-            "should have used dirstate_tree::DirstateMap to use the v2 format"
-        )
-    }
-
-    fn status<'a>(
-        &'a mut self,
-        matcher: &'a (dyn Matcher + Sync),
-        root_dir: PathBuf,
-        ignore_files: Vec<PathBuf>,
-        options: StatusOptions,
-    ) -> Result<(DirstateStatus<'a>, Vec<PatternFileWarning>), StatusError>
-    {
-        crate::status(self, matcher, root_dir, ignore_files, options)
-    }
-
-    fn copy_map_len(&self) -> usize {
-        self.copy_map.len()
-    }
-
-    fn copy_map_iter(&self) -> CopyMapIter<'_> {
-        Box::new(
-            self.copy_map
-                .iter()
-                .map(|(key, value)| Ok((&**key, &**value))),
-        )
-    }
-
-    fn copy_map_contains_key(
-        &self,
-        key: &HgPath,
-    ) -> Result<bool, DirstateV2ParseError> {
-        Ok(self.copy_map.contains_key(key))
-    }
-
-    fn copy_map_get(
-        &self,
-        key: &HgPath,
-    ) -> Result<Option<&HgPath>, DirstateV2ParseError> {
-        Ok(self.copy_map.get(key).map(|p| &**p))
-    }
-
-    fn copy_map_remove(
-        &mut self,
-        key: &HgPath,
-    ) -> Result<Option<HgPathBuf>, DirstateV2ParseError> {
-        Ok(self.copy_map.remove(key))
-    }
-
-    fn copy_map_insert(
-        &mut self,
-        key: HgPathBuf,
-        value: HgPathBuf,
-    ) -> Result<Option<HgPathBuf>, DirstateV2ParseError> {
-        Ok(self.copy_map.insert(key, value))
-    }
-
-    fn len(&self) -> usize {
-        (&**self).len()
-    }
-
-    fn contains_key(
-        &self,
-        key: &HgPath,
-    ) -> Result<bool, DirstateV2ParseError> {
-        Ok((&**self).contains_key(key))
-    }
-
-    fn get(
-        &self,
-        key: &HgPath,
-    ) -> Result<Option<DirstateEntry>, DirstateV2ParseError> {
-        Ok((&**self).get(key).cloned())
-    }
-
-    fn iter(&self) -> StateMapIter<'_> {
-        Box::new((&**self).iter().map(|(key, value)| Ok((&**key, *value))))
-    }
-
-    fn iter_tracked_dirs(
-        &mut self,
-    ) -> Result<
-        Box<
-            dyn Iterator<Item = Result<&HgPath, DirstateV2ParseError>>
-                + Send
-                + '_,
-        >,
-        DirstateError,
-    > {
-        self.set_all_dirs()?;
-        Ok(Box::new(
-            self.all_dirs
-                .as_ref()
-                .unwrap()
-                .iter()
-                .map(|path| Ok(&**path)),
-        ))
-    }
-
-    fn debug_iter(
-        &self,
-    ) -> Box<
-        dyn Iterator<
-                Item = Result<
-                    (&HgPath, (u8, i32, i32, i32)),
-                    DirstateV2ParseError,
-                >,
-            > + Send
-            + '_,
-    > {
-        Box::new(
-            (&**self)
-                .iter()
-                .map(|(path, entry)| Ok((&**path, entry.debug_tuple()))),
-        )
-    }
-}
--- a/rust/hg-core/src/dirstate_tree/on_disk.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/dirstate_tree/on_disk.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -1,23 +1,8 @@
 //! The "version 2" disk representation of the dirstate
 //!
-//! # File format
-//!
-//! In dirstate-v2 format, the `.hg/dirstate` file is a "docket that starts
-//! with a fixed-sized header whose layout is defined by the `DocketHeader`
-//! struct, followed by the data file identifier.
-//!
-//! A separate `.hg/dirstate.{uuid}.d` file contains most of the data. That
-//! file may be longer than the size given in the docket, but not shorter. Only
-//! the start of the data file up to the given size is considered. The
-//! fixed-size "root" of the dirstate tree whose layout is defined by the
-//! `Root` struct is found at the end of that slice of data.
-//!
-//! Its `root_nodes` field contains the slice (offset and length) to
-//! the nodes representing the files and directories at the root of the
-//! repository. Each node is also fixed-size, defined by the `Node` struct.
-//! Nodes in turn contain slices to variable-size paths, and to their own child
-//! nodes (if any) for nested files and directories.
+//! See `mercurial/helptext/internals/dirstate-v2.txt`
 
+use crate::dirstate::TruncatedTimestamp;
 use crate::dirstate_tree::dirstate_map::{self, DirstateMap, NodeRef};
 use crate::dirstate_tree::path_with_basename::WithBasename;
 use crate::errors::HgError;
@@ -25,13 +10,12 @@
 use crate::DirstateEntry;
 use crate::DirstateError;
 use crate::DirstateParents;
-use crate::EntryState;
-use bytes_cast::unaligned::{I32Be, I64Be, U16Be, U32Be};
+use bitflags::bitflags;
+use bytes_cast::unaligned::{U16Be, U32Be};
 use bytes_cast::BytesCast;
 use format_bytes::format_bytes;
 use std::borrow::Cow;
 use std::convert::{TryFrom, TryInto};
-use std::time::{Duration, SystemTime, UNIX_EPOCH};
 
 /// Added at the start of `.hg/dirstate` when the "v2" format is used.
 /// This a redundant sanity check more than an actual "magic number" since
@@ -47,16 +31,16 @@
 pub(super) const IGNORE_PATTERNS_HASH_LEN: usize = 20;
 pub(super) type IgnorePatternsHash = [u8; IGNORE_PATTERNS_HASH_LEN];
 
-/// Must match the constant of the same name in
-/// `mercurial/dirstateutils/docket.py`
+/// Must match constants of the same names in `mercurial/dirstateutils/v2.py`
 const TREE_METADATA_SIZE: usize = 44;
+const NODE_SIZE: usize = 44;
 
 /// Make sure that size-affecting changes are made knowingly
 #[allow(unused)]
 fn static_assert_size_of() {
     let _ = std::mem::transmute::<TreeMetadata, [u8; TREE_METADATA_SIZE]>;
     let _ = std::mem::transmute::<DocketHeader, [u8; TREE_METADATA_SIZE + 81]>;
-    let _ = std::mem::transmute::<Node, [u8; 43]>;
+    let _ = std::mem::transmute::<Node, [u8; NODE_SIZE]>;
 }
 
 // Must match `HEADER` in `mercurial/dirstateutils/docket.py`
@@ -67,11 +51,11 @@
     parent_1: [u8; STORED_NODE_ID_BYTES],
     parent_2: [u8; STORED_NODE_ID_BYTES],
 
+    metadata: TreeMetadata,
+
     /// Counted in bytes
     data_size: Size,
 
-    metadata: TreeMetadata,
-
     uuid_size: u8,
 }
 
@@ -80,44 +64,24 @@
     uuid: &'on_disk [u8],
 }
 
+/// Fields are documented in the *Tree metadata in the docket file*
+/// section of `mercurial/helptext/internals/dirstate-v2.txt`
 #[derive(BytesCast)]
 #[repr(C)]
 struct TreeMetadata {
     root_nodes: ChildNodes,
     nodes_with_entry_count: Size,
     nodes_with_copy_source_count: Size,
-
-    /// How many bytes of this data file are not used anymore
     unreachable_bytes: Size,
-
-    /// Current version always sets these bytes to zero when creating or
-    /// updating a dirstate. Future versions could assign some bits to signal
-    /// for example "the version that last wrote/updated this dirstate did so
-    /// in such and such way that can be relied on by versions that know to."
     unused: [u8; 4],
 
-    /// If non-zero, a hash of ignore files that were used for some previous
-    /// run of the `status` algorithm.
-    ///
-    /// We define:
-    ///
-    /// * "Root" ignore files are `.hgignore` at the root of the repository if
-    ///   it exists, and files from `ui.ignore.*` config. This set of files is
-    ///   then sorted by the string representation of their path.
-    /// * The "expanded contents" of an ignore files is the byte string made
-    ///   by concatenating its contents with the "expanded contents" of other
-    ///   files included with `include:` or `subinclude:` files, in inclusion
-    ///   order. This definition is recursive, as included files can
-    ///   themselves include more files.
-    ///
-    /// This hash is defined as the SHA-1 of the concatenation (in sorted
-    /// order) of the "expanded contents" of each "root" ignore file.
-    /// (Note that computing this does not require actually concatenating byte
-    /// strings into contiguous memory, instead SHA-1 hashing can be done
-    /// incrementally.)
+    /// See *Optional hash of ignore patterns* section of
+    /// `mercurial/helptext/internals/dirstate-v2.txt`
     ignore_patterns_hash: IgnorePatternsHash,
 }
 
+/// Fields are documented in the *The data file format*
+/// section of `mercurial/helptext/internals/dirstate-v2.txt`
 #[derive(BytesCast)]
 #[repr(C)]
 pub(super) struct Node {
@@ -130,59 +94,38 @@
     children: ChildNodes,
     pub(super) descendants_with_entry_count: Size,
     pub(super) tracked_descendants_count: Size,
-
-    /// Depending on the value of `state`:
-    ///
-    /// * A null byte: `data` is not used.
-    ///
-    /// * A `n`, `a`, `r`, or `m` ASCII byte: `state` and `data` together
-    ///   represent a dirstate entry like in the v1 format.
-    ///
-    /// * A `d` ASCII byte: the bytes of `data` should instead be interpreted
-    ///   as the `Timestamp` for the mtime of a cached directory.
-    ///
-    ///   The presence of this state means that at some point, this path in
-    ///   the working directory was observed:
-    ///
-    ///   - To be a directory
-    ///   - With the modification time as given by `Timestamp`
-    ///   - That timestamp was already strictly in the past when observed,
-    ///     meaning that later changes cannot happen in the same clock tick
-    ///     and must cause a different modification time (unless the system
-    ///     clock jumps back and we get unlucky, which is not impossible but
-    ///     but deemed unlikely enough).
-    ///   - All direct children of this directory (as returned by
-    ///     `std::fs::read_dir`) either have a corresponding dirstate node, or
-    ///     are ignored by ignore patterns whose hash is in
-    ///     `TreeMetadata::ignore_patterns_hash`.
-    ///
-    ///   This means that if `std::fs::symlink_metadata` later reports the
-    ///   same modification time and ignored patterns haven’t changed, a run
-    ///   of status that is not listing ignored   files can skip calling
-    ///   `std::fs::read_dir` again for this directory,   iterate child
-    ///   dirstate nodes instead.
-    state: u8,
-    data: Entry,
+    flags: U16Be,
+    size: U32Be,
+    mtime: PackedTruncatedTimestamp,
 }
 
-#[derive(BytesCast, Copy, Clone)]
-#[repr(C)]
-struct Entry {
-    mode: I32Be,
-    mtime: I32Be,
-    size: I32Be,
+bitflags! {
+    #[repr(C)]
+    struct Flags: u16 {
+        const WDIR_TRACKED = 1 << 0;
+        const P1_TRACKED = 1 << 1;
+        const P2_INFO = 1 << 2;
+        const MODE_EXEC_PERM = 1 << 3;
+        const MODE_IS_SYMLINK = 1 << 4;
+        const HAS_FALLBACK_EXEC = 1 <<  5;
+        const FALLBACK_EXEC = 1 <<  6;
+        const HAS_FALLBACK_SYMLINK = 1 <<  7;
+        const FALLBACK_SYMLINK = 1 <<  8;
+        const EXPECTED_STATE_IS_MODIFIED = 1 << 9;
+        const HAS_MODE_AND_SIZE = 1 <<10;
+        const HAS_MTIME = 1 <<11;
+        const MTIME_SECOND_AMBIGUOUS = 1 << 12;
+        const DIRECTORY = 1 <<13;
+        const ALL_UNKNOWN_RECORDED = 1 <<14;
+        const ALL_IGNORED_RECORDED = 1 <<15;
+    }
 }
 
 /// Duration since the Unix epoch
-#[derive(BytesCast, Copy, Clone, PartialEq)]
+#[derive(BytesCast, Copy, Clone)]
 #[repr(C)]
-pub(super) struct Timestamp {
-    seconds: I64Be,
-
-    /// In `0 .. 1_000_000_000`.
-    ///
-    /// This timestamp is later or earlier than `(seconds, 0)` by this many
-    /// nanoseconds, if `seconds` is non-negative or negative, respectively.
+struct PackedTruncatedTimestamp {
+    truncated_seconds: U32Be,
     nanoseconds: U32Be,
 }
 
@@ -265,7 +208,7 @@
     }
 
     pub fn data_filename(&self) -> String {
-        String::from_utf8(format_bytes!(b"dirstate.{}.d", self.uuid)).unwrap()
+        String::from_utf8(format_bytes!(b"dirstate.{}", self.uuid)).unwrap()
     }
 }
 
@@ -361,62 +304,112 @@
         })
     }
 
+    fn flags(&self) -> Flags {
+        Flags::from_bits_truncate(self.flags.get())
+    }
+
+    fn has_entry(&self) -> bool {
+        self.flags().intersects(
+            Flags::WDIR_TRACKED | Flags::P1_TRACKED | Flags::P2_INFO,
+        )
+    }
+
     pub(super) fn node_data(
         &self,
     ) -> Result<dirstate_map::NodeData, DirstateV2ParseError> {
-        let entry = |state| {
-            dirstate_map::NodeData::Entry(self.entry_with_given_state(state))
-        };
+        if self.has_entry() {
+            Ok(dirstate_map::NodeData::Entry(self.assume_entry()?))
+        } else if let Some(mtime) = self.cached_directory_mtime()? {
+            Ok(dirstate_map::NodeData::CachedDirectory { mtime })
+        } else {
+            Ok(dirstate_map::NodeData::None)
+        }
+    }
 
-        match self.state {
-            b'\0' => Ok(dirstate_map::NodeData::None),
-            b'd' => Ok(dirstate_map::NodeData::CachedDirectory {
-                mtime: *self.data.as_timestamp(),
-            }),
-            b'n' => Ok(entry(EntryState::Normal)),
-            b'a' => Ok(entry(EntryState::Added)),
-            b'r' => Ok(entry(EntryState::Removed)),
-            b'm' => Ok(entry(EntryState::Merged)),
-            _ => Err(DirstateV2ParseError),
+    pub(super) fn cached_directory_mtime(
+        &self,
+    ) -> Result<Option<TruncatedTimestamp>, DirstateV2ParseError> {
+        // For now we do not have code to handle the absence of
+        // ALL_UNKNOWN_RECORDED, so we ignore the mtime if the flag is
+        // unset.
+        if self.flags().contains(Flags::DIRECTORY)
+            && self.flags().contains(Flags::HAS_MTIME)
+            && self.flags().contains(Flags::ALL_UNKNOWN_RECORDED)
+        {
+            Ok(Some(self.mtime.try_into()?))
+        } else {
+            Ok(None)
         }
     }
 
-    pub(super) fn cached_directory_mtime(&self) -> Option<&Timestamp> {
-        if self.state == b'd' {
-            Some(self.data.as_timestamp())
+    fn synthesize_unix_mode(&self) -> u32 {
+        let file_type = if self.flags().contains(Flags::MODE_IS_SYMLINK) {
+            libc::S_IFLNK
         } else {
-            None
-        }
+            libc::S_IFREG
+        };
+        let permisions = if self.flags().contains(Flags::MODE_EXEC_PERM) {
+            0o755
+        } else {
+            0o644
+        };
+        file_type | permisions
     }
 
-    pub(super) fn state(
-        &self,
-    ) -> Result<Option<EntryState>, DirstateV2ParseError> {
-        match self.state {
-            b'\0' | b'd' => Ok(None),
-            b'n' => Ok(Some(EntryState::Normal)),
-            b'a' => Ok(Some(EntryState::Added)),
-            b'r' => Ok(Some(EntryState::Removed)),
-            b'm' => Ok(Some(EntryState::Merged)),
-            _ => Err(DirstateV2ParseError),
-        }
-    }
-
-    fn entry_with_given_state(&self, state: EntryState) -> DirstateEntry {
-        DirstateEntry {
-            state,
-            mode: self.data.mode.get(),
-            mtime: self.data.mtime.get(),
-            size: self.data.size.get(),
-        }
+    fn assume_entry(&self) -> Result<DirstateEntry, DirstateV2ParseError> {
+        // TODO: convert through raw bits instead?
+        let wdir_tracked = self.flags().contains(Flags::WDIR_TRACKED);
+        let p1_tracked = self.flags().contains(Flags::P1_TRACKED);
+        let p2_info = self.flags().contains(Flags::P2_INFO);
+        let mode_size = if self.flags().contains(Flags::HAS_MODE_AND_SIZE)
+            && !self.flags().contains(Flags::EXPECTED_STATE_IS_MODIFIED)
+        {
+            Some((self.synthesize_unix_mode(), self.size.into()))
+        } else {
+            None
+        };
+        let mtime = if self.flags().contains(Flags::HAS_MTIME)
+            && !self.flags().contains(Flags::DIRECTORY)
+            && !self.flags().contains(Flags::EXPECTED_STATE_IS_MODIFIED)
+            // The current code is not able to do the more subtle comparison that the
+            // MTIME_SECOND_AMBIGUOUS requires. So we ignore the mtime
+            && !self.flags().contains(Flags::MTIME_SECOND_AMBIGUOUS)
+        {
+            Some(self.mtime.try_into()?)
+        } else {
+            None
+        };
+        let fallback_exec = if self.flags().contains(Flags::HAS_FALLBACK_EXEC)
+        {
+            Some(self.flags().contains(Flags::FALLBACK_EXEC))
+        } else {
+            None
+        };
+        let fallback_symlink =
+            if self.flags().contains(Flags::HAS_FALLBACK_SYMLINK) {
+                Some(self.flags().contains(Flags::FALLBACK_SYMLINK))
+            } else {
+                None
+            };
+        Ok(DirstateEntry::from_v2_data(
+            wdir_tracked,
+            p1_tracked,
+            p2_info,
+            mode_size,
+            mtime,
+            fallback_exec,
+            fallback_symlink,
+        ))
     }
 
     pub(super) fn entry(
         &self,
     ) -> Result<Option<DirstateEntry>, DirstateV2ParseError> {
-        Ok(self
-            .state()?
-            .map(|state| self.entry_with_given_state(state)))
+        if self.has_entry() {
+            Ok(Some(self.assume_entry()?))
+        } else {
+            Ok(None)
+        }
     }
 
     pub(super) fn children<'on_disk>(
@@ -442,57 +435,53 @@
             tracked_descendants_count: self.tracked_descendants_count.get(),
         })
     }
-}
 
-impl Entry {
-    fn from_timestamp(timestamp: Timestamp) -> Self {
-        // Safety: both types implement the `ByteCast` trait, so we could
-        // safely use `as_bytes` and `from_bytes` to do this conversion. Using
-        // `transmute` instead makes the compiler check that the two types
-        // have the same size, which eliminates the error case of
-        // `from_bytes`.
-        unsafe { std::mem::transmute::<Timestamp, Entry>(timestamp) }
-    }
-
-    fn as_timestamp(&self) -> &Timestamp {
-        // Safety: same as above in `from_timestamp`
-        unsafe { &*(self as *const Entry as *const Timestamp) }
-    }
-}
-
-impl Timestamp {
-    pub fn seconds(&self) -> i64 {
-        self.seconds.get()
-    }
-}
-
-impl From<SystemTime> for Timestamp {
-    fn from(system_time: SystemTime) -> Self {
-        let (secs, nanos) = match system_time.duration_since(UNIX_EPOCH) {
-            Ok(duration) => {
-                (duration.as_secs() as i64, duration.subsec_nanos())
+    fn from_dirstate_entry(
+        entry: &DirstateEntry,
+    ) -> (Flags, U32Be, PackedTruncatedTimestamp) {
+        let (
+            wdir_tracked,
+            p1_tracked,
+            p2_info,
+            mode_size_opt,
+            mtime_opt,
+            fallback_exec,
+            fallback_symlink,
+        ) = entry.v2_data();
+        // TODO: convert throug raw flag bits instead?
+        let mut flags = Flags::empty();
+        flags.set(Flags::WDIR_TRACKED, wdir_tracked);
+        flags.set(Flags::P1_TRACKED, p1_tracked);
+        flags.set(Flags::P2_INFO, p2_info);
+        let size = if let Some((m, s)) = mode_size_opt {
+            let exec_perm = m & libc::S_IXUSR != 0;
+            let is_symlink = m & libc::S_IFMT == libc::S_IFLNK;
+            flags.set(Flags::MODE_EXEC_PERM, exec_perm);
+            flags.set(Flags::MODE_IS_SYMLINK, is_symlink);
+            flags.insert(Flags::HAS_MODE_AND_SIZE);
+            s.into()
+        } else {
+            0.into()
+        };
+        let mtime = if let Some(m) = mtime_opt {
+            flags.insert(Flags::HAS_MTIME);
+            m.into()
+        } else {
+            PackedTruncatedTimestamp::null()
+        };
+        if let Some(f_exec) = fallback_exec {
+            flags.insert(Flags::HAS_FALLBACK_EXEC);
+            if f_exec {
+                flags.insert(Flags::FALLBACK_EXEC);
             }
-            Err(error) => {
-                let negative = error.duration();
-                (-(negative.as_secs() as i64), negative.subsec_nanos())
-            }
-        };
-        Timestamp {
-            seconds: secs.into(),
-            nanoseconds: nanos.into(),
         }
-    }
-}
-
-impl From<&'_ Timestamp> for SystemTime {
-    fn from(timestamp: &'_ Timestamp) -> Self {
-        let secs = timestamp.seconds.get();
-        let nanos = timestamp.nanoseconds.get();
-        if secs >= 0 {
-            UNIX_EPOCH + Duration::new(secs as u64, nanos)
-        } else {
-            UNIX_EPOCH - Duration::new((-secs) as u64, nanos)
+        if let Some(f_symlink) = fallback_symlink {
+            flags.insert(Flags::HAS_FALLBACK_SYMLINK);
+            if f_symlink {
+                flags.insert(Flags::FALLBACK_SYMLINK);
+            }
         }
+        (flags, size, mtime)
     }
 }
 
@@ -543,8 +532,8 @@
         f: &mut impl FnMut(&'on_disk HgPath),
     ) -> Result<(), DirstateV2ParseError> {
         for node in read_nodes(on_disk, nodes)? {
-            if let Some(state) = node.state()? {
-                if state.is_tracked() {
+            if let Some(entry) = node.entry()? {
+                if entry.state().is_tracked() {
                     f(node.full_path(on_disk)?)
                 }
             }
@@ -638,25 +627,31 @@
             };
             on_disk_nodes.push(match node {
                 NodeRef::InMemory(path, node) => {
-                    let (state, data) = match &node.data {
-                        dirstate_map::NodeData::Entry(entry) => (
-                            entry.state.into(),
-                            Entry {
-                                mode: entry.mode.into(),
-                                mtime: entry.mtime.into(),
-                                size: entry.size.into(),
-                            },
+                    let (flags, size, mtime) = match &node.data {
+                        dirstate_map::NodeData::Entry(entry) => {
+                            Node::from_dirstate_entry(entry)
+                        }
+                        dirstate_map::NodeData::CachedDirectory { mtime } => (
+                            // we currently never set a mtime if unknown file
+                            // are present.
+                            // So if we have a mtime for a directory, we know
+                            // they are no unknown
+                            // files and we
+                            // blindly set ALL_UNKNOWN_RECORDED.
+                            //
+                            // We never set ALL_IGNORED_RECORDED since we
+                            // don't track that case
+                            // currently.
+                            Flags::DIRECTORY
+                                | Flags::HAS_MTIME
+                                | Flags::ALL_UNKNOWN_RECORDED,
+                            0.into(),
+                            (*mtime).into(),
                         ),
-                        dirstate_map::NodeData::CachedDirectory { mtime } => {
-                            (b'd', Entry::from_timestamp(*mtime))
-                        }
                         dirstate_map::NodeData::None => (
-                            b'\0',
-                            Entry {
-                                mode: 0.into(),
-                                mtime: 0.into(),
-                                size: 0.into(),
-                            },
+                            Flags::DIRECTORY,
+                            0.into(),
+                            PackedTruncatedTimestamp::null(),
                         ),
                     };
                     Node {
@@ -673,8 +668,9 @@
                         tracked_descendants_count: node
                             .tracked_descendants_count
                             .into(),
-                        state,
-                        data,
+                        flags: flags.bits().into(),
+                        size,
+                        mtime,
                     }
                 }
                 NodeRef::OnDisk(node) => Node {
@@ -758,3 +754,33 @@
         .expect("dirstate-v2 path length overflow")
         .into()
 }
+
+impl From<TruncatedTimestamp> for PackedTruncatedTimestamp {
+    fn from(timestamp: TruncatedTimestamp) -> Self {
+        Self {
+            truncated_seconds: timestamp.truncated_seconds().into(),
+            nanoseconds: timestamp.nanoseconds().into(),
+        }
+    }
+}
+
+impl TryFrom<PackedTruncatedTimestamp> for TruncatedTimestamp {
+    type Error = DirstateV2ParseError;
+
+    fn try_from(
+        timestamp: PackedTruncatedTimestamp,
+    ) -> Result<Self, Self::Error> {
+        Self::from_already_truncated(
+            timestamp.truncated_seconds.get(),
+            timestamp.nanoseconds.get(),
+        )
+    }
+}
+impl PackedTruncatedTimestamp {
+    fn null() -> Self {
+        Self {
+            truncated_seconds: 0.into(),
+            nanoseconds: 0.into(),
+        }
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/rust/hg-core/src/dirstate_tree/owning.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -0,0 +1,105 @@
+use super::dirstate_map::DirstateMap;
+use stable_deref_trait::StableDeref;
+use std::ops::Deref;
+
+/// Keep a `DirstateMap<'on_disk>` next to the `on_disk` buffer that it
+/// borrows.
+///
+/// This is similar to [`OwningRef`] which is more limited because it
+/// represents exactly one `&T` reference next to the value it borrows, as
+/// opposed to a struct that may contain an arbitrary number of references in
+/// arbitrarily-nested data structures.
+///
+/// [`OwningRef`]: https://docs.rs/owning_ref/0.4.1/owning_ref/struct.OwningRef.html
+pub struct OwningDirstateMap {
+    /// Owned handle to a bytes buffer with a stable address.
+    ///
+    /// See <https://docs.rs/owning_ref/0.4.1/owning_ref/trait.StableAddress.html>.
+    on_disk: Box<dyn Deref<Target = [u8]> + Send>,
+
+    /// Pointer for `Box<DirstateMap<'on_disk>>`, typed-erased because the
+    /// language cannot represent a lifetime referencing a sibling field.
+    /// This is not quite a self-referencial struct (moving this struct is not
+    /// a problem as it doesn’t change the address of the bytes buffer owned
+    /// by `PyBytes`) but touches similar borrow-checker limitations.
+    ptr: *mut (),
+}
+
+impl OwningDirstateMap {
+    pub fn new_empty<OnDisk>(on_disk: OnDisk) -> Self
+    where
+        OnDisk: Deref<Target = [u8]> + StableDeref + Send + 'static,
+    {
+        let on_disk = Box::new(on_disk);
+        let bytes: &'_ [u8] = &on_disk;
+        let map = DirstateMap::empty(bytes);
+
+        // Like in `bytes` above, this `'_` lifetime parameter borrows from
+        // the bytes buffer owned by `on_disk`.
+        let ptr: *mut DirstateMap<'_> = Box::into_raw(Box::new(map));
+
+        // Erase the pointed type entirely in order to erase the lifetime.
+        let ptr: *mut () = ptr.cast();
+
+        Self { on_disk, ptr }
+    }
+
+    pub fn get_pair_mut<'a>(
+        &'a mut self,
+    ) -> (&'a [u8], &'a mut DirstateMap<'a>) {
+        // SAFETY: We cast the type-erased pointer back to the same type it had
+        // in `new`, except with a different lifetime parameter. This time we
+        // connect the lifetime to that of `self`. This cast is valid because
+        // `self` owns the same `PyBytes` whose buffer `DirstateMap`
+        // references. That buffer has a stable memory address because the byte
+        // string value of a `PyBytes` is immutable.
+        let ptr: *mut DirstateMap<'a> = self.ptr.cast();
+        // SAFETY: we dereference that pointer, connecting the lifetime of the
+        // new   `&mut` to that of `self`. This is valid because the
+        // raw pointer is   to a boxed value, and `self` owns that box.
+        (&self.on_disk, unsafe { &mut *ptr })
+    }
+
+    pub fn get_map_mut<'a>(&'a mut self) -> &'a mut DirstateMap<'a> {
+        self.get_pair_mut().1
+    }
+
+    pub fn get_map<'a>(&'a self) -> &'a DirstateMap<'a> {
+        // SAFETY: same reasoning as in `get_mut` above.
+        let ptr: *mut DirstateMap<'a> = self.ptr.cast();
+        unsafe { &*ptr }
+    }
+
+    pub fn on_disk<'a>(&'a self) -> &'a [u8] {
+        &self.on_disk
+    }
+}
+
+impl Drop for OwningDirstateMap {
+    fn drop(&mut self) {
+        // Silence a "field is never read" warning, and demonstrate that this
+        // value is still alive.
+        let _ = &self.on_disk;
+        // SAFETY: this cast is the same as in `get_mut`, and is valid for the
+        // same reason. `self.on_disk` still exists at this point, drop glue
+        // will drop it implicitly after this `drop` method returns.
+        let ptr: *mut DirstateMap<'_> = self.ptr.cast();
+        // SAFETY: `Box::from_raw` takes ownership of the box away from `self`.
+        // This is fine because drop glue does nothig for `*mut ()` and we’re
+        // in `drop`, so `get` and `get_mut` cannot be called again.
+        unsafe { drop(Box::from_raw(ptr)) }
+    }
+}
+
+fn _static_assert_is_send<T: Send>() {}
+
+fn _static_assert_fields_are_send() {
+    _static_assert_is_send::<Box<DirstateMap<'_>>>();
+}
+
+// SAFETY: we don’t get this impl implicitly because `*mut (): !Send` because
+// thread-safety of raw pointers is unknown in the general case. However this
+// particular raw pointer represents a `Box<DirstateMap<'on_disk>>` that we
+// own. Since that `Box` is `Send` as shown in above, it is sound to mark
+// this struct as `Send` too.
+unsafe impl Send for OwningDirstateMap {}
--- a/rust/hg-core/src/dirstate_tree/status.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/dirstate_tree/status.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -1,3 +1,4 @@
+use crate::dirstate::entry::TruncatedTimestamp;
 use crate::dirstate::status::IgnoreFnType;
 use crate::dirstate_tree::dirstate_map::BorrowedPath;
 use crate::dirstate_tree::dirstate_map::ChildNodesRef;
@@ -5,7 +6,6 @@
 use crate::dirstate_tree::dirstate_map::NodeData;
 use crate::dirstate_tree::dirstate_map::NodeRef;
 use crate::dirstate_tree::on_disk::DirstateV2ParseError;
-use crate::dirstate_tree::on_disk::Timestamp;
 use crate::matchers::get_ignore_function;
 use crate::matchers::Matcher;
 use crate::utils::files::get_bytes_from_os_string;
@@ -126,7 +126,8 @@
     matcher: &'a (dyn Matcher + Sync),
     ignore_fn: IgnoreFnType<'a>,
     outcome: Mutex<DirstateStatus<'on_disk>>,
-    new_cachable_directories: Mutex<Vec<(Cow<'on_disk, HgPath>, Timestamp)>>,
+    new_cachable_directories:
+        Mutex<Vec<(Cow<'on_disk, HgPath>, TruncatedTimestamp)>>,
     outated_cached_directories: Mutex<Vec<Cow<'on_disk, HgPath>>>,
 
     /// Whether ignore files like `.hgignore` have changed since the previous
@@ -165,7 +166,7 @@
         dirstate_node: &NodeRef<'tree, 'on_disk>,
     ) -> Result<(), DirstateV2ParseError> {
         if self.ignore_patterns_have_changed == Some(true)
-            && dirstate_node.cached_directory_mtime().is_some()
+            && dirstate_node.cached_directory_mtime()?.is_some()
         {
             self.outated_cached_directories.lock().unwrap().push(
                 dirstate_node
@@ -182,7 +183,7 @@
     fn can_skip_fs_readdir(
         &self,
         directory_metadata: Option<&std::fs::Metadata>,
-        cached_directory_mtime: Option<&Timestamp>,
+        cached_directory_mtime: Option<TruncatedTimestamp>,
     ) -> bool {
         if !self.options.list_unknown && !self.options.list_ignored {
             // All states that we care about listing have corresponding
@@ -198,13 +199,14 @@
                 // by a previous run of the `status` algorithm which found this
                 // directory eligible for `read_dir` caching.
                 if let Some(meta) = directory_metadata {
-                    if let Ok(current_mtime) = meta.modified() {
-                        if current_mtime == cached_mtime.into() {
-                            // The mtime of that directory has not changed
-                            // since then, which means that the results of
-                            // `read_dir` should also be unchanged.
-                            return true;
-                        }
+                    if cached_mtime
+                        .likely_equal_to_mtime_of(meta)
+                        .unwrap_or(false)
+                    {
+                        // The mtime of that directory has not changed
+                        // since then, which means that the results of
+                        // `read_dir` should also be unchanged.
+                        return true;
                     }
                 }
             }
@@ -221,7 +223,7 @@
         directory_hg_path: &BorrowedPath<'tree, 'on_disk>,
         directory_fs_path: &Path,
         directory_metadata: Option<&std::fs::Metadata>,
-        cached_directory_mtime: Option<&Timestamp>,
+        cached_directory_mtime: Option<TruncatedTimestamp>,
         is_at_repo_root: bool,
     ) -> Result<bool, DirstateV2ParseError> {
         if self.can_skip_fs_readdir(directory_metadata, cached_directory_mtime)
@@ -362,7 +364,7 @@
                     hg_path,
                     fs_path,
                     Some(fs_metadata),
-                    dirstate_node.cached_directory_mtime(),
+                    dirstate_node.cached_directory_mtime()?,
                     is_at_repo_root,
                 )?;
             self.maybe_save_directory_mtime(
@@ -394,9 +396,6 @@
                             .push(hg_path.detach_from_tree()),
                         EntryState::Normal => self
                             .handle_normal_file(&dirstate_node, fs_metadata)?,
-                        // This variant is not used in DirstateMap
-                        // nodes
-                        EntryState::Unknown => unreachable!(),
                     }
                 } else {
                     // `node.entry.is_none()` indicates a "directory"
@@ -468,16 +467,22 @@
                     //
                     // We deem this scenario (unlike the previous one) to be
                     // unlikely enough in practice.
-                    let timestamp = directory_mtime.into();
-                    let cached = dirstate_node.cached_directory_mtime();
-                    if cached != Some(&timestamp) {
+                    let truncated = TruncatedTimestamp::from(directory_mtime);
+                    let is_up_to_date = if let Some(cached) =
+                        dirstate_node.cached_directory_mtime()?
+                    {
+                        cached.likely_equal(truncated)
+                    } else {
+                        false
+                    };
+                    if !is_up_to_date {
                         let hg_path = dirstate_node
                             .full_path_borrowed(self.dmap.on_disk)?
                             .detach_from_tree();
                         self.new_cachable_directories
                             .lock()
                             .unwrap()
-                            .push((hg_path, timestamp))
+                            .push((hg_path, truncated))
                     }
                 }
             }
@@ -496,9 +501,6 @@
         fn truncate_u64(value: u64) -> i32 {
             (value & 0x7FFF_FFFF) as i32
         }
-        fn truncate_i64(value: i64) -> i32 {
-            (value & 0x7FFF_FFFF) as i32
-        }
 
         let entry = dirstate_node
             .entry()?
@@ -506,11 +508,9 @@
         let hg_path = &dirstate_node.full_path_borrowed(self.dmap.on_disk)?;
         let mode_changed =
             || self.options.check_exec && entry.mode_changed(fs_metadata);
-        let size_changed = entry.size != truncate_u64(fs_metadata.len());
-        if entry.size >= 0
-            && size_changed
-            && fs_metadata.file_type().is_symlink()
-        {
+        let size = entry.size();
+        let size_changed = size != truncate_u64(fs_metadata.len());
+        if size >= 0 && size_changed && fs_metadata.file_type().is_symlink() {
             // issue6456: Size returned may be longer due to encryption
             // on EXT-4 fscrypt. TODO maybe only do it on EXT4?
             self.outcome
@@ -520,7 +520,7 @@
                 .push(hg_path.detach_from_tree())
         } else if dirstate_node.has_copy_source()
             || entry.is_from_other_parent()
-            || (entry.size >= 0 && (size_changed || mode_changed()))
+            || (size >= 0 && (size_changed || mode_changed()))
         {
             self.outcome
                 .lock()
@@ -528,10 +528,17 @@
                 .modified
                 .push(hg_path.detach_from_tree())
         } else {
-            let mtime = mtime_seconds(fs_metadata);
-            if truncate_i64(mtime) != entry.mtime
-                || mtime == self.options.last_normal_time
-            {
+            let mtime_looks_clean;
+            if let Some(dirstate_mtime) = entry.truncated_mtime() {
+                let fs_mtime = TruncatedTimestamp::for_mtime_of(fs_metadata)
+                    .expect("OS/libc does not support mtime?");
+                mtime_looks_clean = fs_mtime.likely_equal(dirstate_mtime)
+                    && !fs_mtime.likely_equal(self.options.last_normal_time)
+            } else {
+                // No mtime in the dirstate entry
+                mtime_looks_clean = false
+            };
+            if !mtime_looks_clean {
                 self.outcome
                     .lock()
                     .unwrap()
@@ -687,15 +694,6 @@
     }
 }
 
-#[cfg(unix)] // TODO
-fn mtime_seconds(metadata: &std::fs::Metadata) -> i64 {
-    // Going through `Metadata::modified()` would be portable, but would take
-    // care to construct a `SystemTime` value with sub-second precision just
-    // for us to throw that away here.
-    use std::os::unix::fs::MetadataExt;
-    metadata.mtime()
-}
-
 struct DirEntry {
     base_name: HgPathBuf,
     full_path: PathBuf,
--- a/rust/hg-core/src/lib.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/lib.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -16,14 +16,11 @@
 pub mod testing; // unconditionally built, for use from integration tests
 pub use dirstate::{
     dirs_multiset::{DirsMultiset, DirsMultisetIter},
-    dirstate_map::DirstateMap,
-    parsers::{pack_dirstate, parse_dirstate, PARENT_SIZE},
     status::{
-        status, BadMatch, BadType, DirstateStatus, HgPathCow, StatusError,
+        BadMatch, BadType, DirstateStatus, HgPathCow, StatusError,
         StatusOptions,
     },
-    CopyMap, CopyMapIter, DirstateEntry, DirstateParents, EntryState,
-    StateMap, StateMapIter,
+    DirstateEntry, DirstateParents, EntryState,
 };
 pub mod copy_tracing;
 mod filepatterns;
@@ -36,6 +33,7 @@
 pub mod operations;
 pub mod revset;
 pub mod utils;
+pub mod vfs;
 
 use crate::utils::hg_path::{HgPathBuf, HgPathError};
 pub use filepatterns::{
--- a/rust/hg-core/src/logging.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/logging.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -1,5 +1,5 @@
 use crate::errors::{HgError, HgResultExt, IoErrorContext, IoResultExt};
-use crate::repo::Vfs;
+use crate::vfs::Vfs;
 use std::io::Write;
 
 /// An utility to append to a log file with the given name, and optionally
--- a/rust/hg-core/src/operations/cat.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/operations/cat.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -5,31 +5,70 @@
 // This software may be used and distributed according to the terms of the
 // GNU General Public License version 2 or any later version.
 
-use std::path::PathBuf;
-
 use crate::repo::Repo;
-use crate::revlog::changelog::Changelog;
-use crate::revlog::manifest::Manifest;
-use crate::revlog::path_encode::path_encode;
-use crate::revlog::revlog::Revlog;
 use crate::revlog::revlog::RevlogError;
 use crate::revlog::Node;
-use crate::utils::files::get_path_from_bytes;
-use crate::utils::hg_path::{HgPath, HgPathBuf};
+
+use crate::utils::hg_path::HgPath;
 
-pub struct CatOutput {
+use itertools::put_back;
+use itertools::PutBack;
+use std::cmp::Ordering;
+
+pub struct CatOutput<'a> {
     /// Whether any file in the manifest matched the paths given as CLI
     /// arguments
     pub found_any: bool,
     /// The contents of matching files, in manifest order
-    pub concatenated: Vec<u8>,
+    pub results: Vec<(&'a HgPath, Vec<u8>)>,
     /// Which of the CLI arguments did not match any manifest file
-    pub missing: Vec<HgPathBuf>,
+    pub missing: Vec<&'a HgPath>,
     /// The node ID that the given revset was resolved to
     pub node: Node,
 }
 
-const METADATA_DELIMITER: [u8; 2] = [b'\x01', b'\n'];
+// Find an item in an iterator over a sorted collection.
+fn find_item<'a, 'b, 'c, D, I: Iterator<Item = (&'a HgPath, D)>>(
+    i: &mut PutBack<I>,
+    needle: &'b HgPath,
+) -> Option<D> {
+    loop {
+        match i.next() {
+            None => return None,
+            Some(val) => match needle.as_bytes().cmp(val.0.as_bytes()) {
+                Ordering::Less => {
+                    i.put_back(val);
+                    return None;
+                }
+                Ordering::Greater => continue,
+                Ordering::Equal => return Some(val.1),
+            },
+        }
+    }
+}
+
+fn find_files_in_manifest<
+    'manifest,
+    'query,
+    Data,
+    Manifest: Iterator<Item = (&'manifest HgPath, Data)>,
+    Query: Iterator<Item = &'query HgPath>,
+>(
+    manifest: Manifest,
+    query: Query,
+) -> (Vec<(&'query HgPath, Data)>, Vec<&'query HgPath>) {
+    let mut manifest = put_back(manifest);
+    let mut res = vec![];
+    let mut missing = vec![];
+
+    for file in query {
+        match find_item(&mut manifest, file) {
+            None => missing.push(file),
+            Some(item) => res.push((file, item)),
+        }
+    }
+    return (res, missing);
+}
 
 /// Output the given revision of files
 ///
@@ -39,67 +78,38 @@
 pub fn cat<'a>(
     repo: &Repo,
     revset: &str,
-    files: &'a [HgPathBuf],
-) -> Result<CatOutput, RevlogError> {
+    mut files: Vec<&'a HgPath>,
+) -> Result<CatOutput<'a>, RevlogError> {
     let rev = crate::revset::resolve_single(revset, repo)?;
-    let changelog = Changelog::open(repo)?;
-    let manifest = Manifest::open(repo)?;
-    let changelog_entry = changelog.get_rev(rev)?;
-    let node = *changelog
+    let manifest = repo.manifest_for_rev(rev)?;
+    let node = *repo
+        .changelog()?
         .node_from_rev(rev)
-        .expect("should succeed when changelog.get_rev did");
-    let manifest_node =
-        Node::from_hex_for_repo(&changelog_entry.manifest_node()?)?;
-    let manifest_entry = manifest.get_node(manifest_node.into())?;
-    let mut bytes = vec![];
-    let mut matched = vec![false; files.len()];
+        .expect("should succeed when repo.manifest did");
+    let mut results: Vec<(&'a HgPath, Vec<u8>)> = vec![];
     let mut found_any = false;
 
-    for (manifest_file, node_bytes) in manifest_entry.files_with_nodes() {
-        for (cat_file, is_matched) in files.iter().zip(&mut matched) {
-            if cat_file.as_bytes() == manifest_file.as_bytes() {
-                *is_matched = true;
-                found_any = true;
-                let index_path = store_path(manifest_file, b".i");
-                let data_path = store_path(manifest_file, b".d");
+    files.sort_unstable();
+
+    let (found, missing) = find_files_in_manifest(
+        manifest.files_with_nodes(),
+        files.into_iter().map(|f| f.as_ref()),
+    );
 
-                let file_log =
-                    Revlog::open(repo, &index_path, Some(&data_path))?;
-                let file_node = Node::from_hex_for_repo(node_bytes)?;
-                let file_rev = file_log.get_node_rev(file_node.into())?;
-                let data = file_log.get_rev_data(file_rev)?;
-                if data.starts_with(&METADATA_DELIMITER) {
-                    let end_delimiter_position = data
-                        [METADATA_DELIMITER.len()..]
-                        .windows(METADATA_DELIMITER.len())
-                        .position(|bytes| bytes == METADATA_DELIMITER);
-                    if let Some(position) = end_delimiter_position {
-                        let offset = METADATA_DELIMITER.len() * 2;
-                        bytes.extend(data[position + offset..].iter());
-                    }
-                } else {
-                    bytes.extend(data);
-                }
-            }
-        }
+    for (file_path, node_bytes) in found {
+        found_any = true;
+        let file_log = repo.filelog(file_path)?;
+        let file_node = Node::from_hex_for_repo(node_bytes)?;
+        results.push((
+            file_path,
+            file_log.data_for_node(file_node)?.into_data()?,
+        ));
     }
 
-    let missing: Vec<_> = files
-        .iter()
-        .zip(&matched)
-        .filter(|pair| !*pair.1)
-        .map(|pair| pair.0.clone())
-        .collect();
     Ok(CatOutput {
         found_any,
-        concatenated: bytes,
+        results,
         missing,
         node,
     })
 }
-
-fn store_path(hg_path: &HgPath, suffix: &[u8]) -> PathBuf {
-    let encoded_bytes =
-        path_encode(&[b"data/", hg_path.as_bytes(), suffix].concat());
-    get_path_from_bytes(&encoded_bytes).into()
-}
--- a/rust/hg-core/src/operations/dirstate_status.rs	Thu Oct 21 14:03:33 2021 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,71 +0,0 @@
-// dirstate_status.rs
-//
-// Copyright 2019, Raphaël Gomès <rgomes@octobus.net>
-//
-// This software may be used and distributed according to the terms of the
-// GNU General Public License version 2 or any later version.
-
-use crate::dirstate::status::{build_response, Dispatch, Status};
-use crate::matchers::Matcher;
-use crate::{DirstateStatus, StatusError};
-
-impl<'a, M: ?Sized + Matcher + Sync> Status<'a, M> {
-    pub(crate) fn run(&self) -> Result<DirstateStatus<'a>, StatusError> {
-        let (traversed_sender, traversed_receiver) =
-            crossbeam_channel::unbounded();
-
-        // Step 1: check the files explicitly mentioned by the user
-        let (work, mut results) = self.walk_explicit(traversed_sender.clone());
-
-        if !work.is_empty() {
-            // Hashmaps are quite a bit slower to build than vecs, so only
-            // build it if needed.
-            let old_results = results.iter().cloned().collect();
-
-            // Step 2: recursively check the working directory for changes if
-            // needed
-            for (dir, dispatch) in work {
-                match dispatch {
-                    Dispatch::Directory { was_file } => {
-                        if was_file {
-                            results.push((dir.to_owned(), Dispatch::Removed));
-                        }
-                        if self.options.list_ignored
-                            || self.options.list_unknown
-                                && !self.dir_ignore(&dir)
-                        {
-                            self.traverse(
-                                &dir,
-                                &old_results,
-                                &mut results,
-                                traversed_sender.clone(),
-                            );
-                        }
-                    }
-                    _ => {
-                        unreachable!("There can only be directories in `work`")
-                    }
-                }
-            }
-        }
-
-        if !self.matcher.is_exact() {
-            if self.options.list_unknown {
-                self.handle_unknowns(&mut results);
-            } else {
-                // TODO this is incorrect, see issue6335
-                // This requires a fix in both Python and Rust that can happen
-                // with other pending changes to `status`.
-                self.extend_from_dmap(&mut results);
-            }
-        }
-
-        drop(traversed_sender);
-        let traversed = traversed_receiver
-            .into_iter()
-            .map(std::borrow::Cow::Owned)
-            .collect();
-
-        Ok(build_response(results, traversed))
-    }
-}
--- a/rust/hg-core/src/operations/list_tracked_files.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/operations/list_tracked_files.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -9,9 +9,7 @@
 use crate::dirstate_tree::on_disk::{for_each_tracked_path, read_docket};
 use crate::errors::HgError;
 use crate::repo::Repo;
-use crate::revlog::changelog::Changelog;
-use crate::revlog::manifest::{Manifest, ManifestEntry};
-use crate::revlog::node::Node;
+use crate::revlog::manifest::Manifest;
 use crate::revlog::revlog::RevlogError;
 use crate::utils::hg_path::HgPath;
 use crate::DirstateError;
@@ -53,7 +51,7 @@
                 let _parents = parse_dirstate_entries(
                     &self.content,
                     |path, entry, _copy_source| {
-                        if entry.state.is_tracked() {
+                        if entry.state().is_tracked() {
                             files.push(path)
                         }
                         Ok(())
@@ -72,16 +70,10 @@
     revset: &str,
 ) -> Result<FilesForRev, RevlogError> {
     let rev = crate::revset::resolve_single(revset, repo)?;
-    let changelog = Changelog::open(repo)?;
-    let manifest = Manifest::open(repo)?;
-    let changelog_entry = changelog.get_rev(rev)?;
-    let manifest_node =
-        Node::from_hex_for_repo(&changelog_entry.manifest_node()?)?;
-    let manifest_entry = manifest.get_node(manifest_node.into())?;
-    Ok(FilesForRev(manifest_entry))
+    Ok(FilesForRev(repo.manifest_for_rev(rev)?))
 }
 
-pub struct FilesForRev(ManifestEntry);
+pub struct FilesForRev(Manifest);
 
 impl FilesForRev {
     pub fn iter(&self) -> impl Iterator<Item = &HgPath> {
--- a/rust/hg-core/src/operations/mod.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/operations/mod.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -4,7 +4,6 @@
 
 mod cat;
 mod debugdata;
-mod dirstate_status;
 mod list_tracked_files;
 pub use cat::{cat, CatOutput};
 pub use debugdata::{debug_data, DebugDataKind};
--- a/rust/hg-core/src/repo.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/repo.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -1,12 +1,22 @@
+use crate::changelog::Changelog;
 use crate::config::{Config, ConfigError, ConfigParseError};
-use crate::errors::{HgError, IoErrorContext, IoResultExt};
+use crate::dirstate::DirstateParents;
+use crate::dirstate_tree::dirstate_map::DirstateMap;
+use crate::dirstate_tree::owning::OwningDirstateMap;
+use crate::errors::HgError;
+use crate::errors::HgResultExt;
 use crate::exit_codes;
-use crate::requirements;
+use crate::manifest::{Manifest, Manifestlog};
+use crate::revlog::filelog::Filelog;
+use crate::revlog::revlog::RevlogError;
 use crate::utils::files::get_path_from_bytes;
+use crate::utils::hg_path::HgPath;
 use crate::utils::SliceExt;
-use memmap::{Mmap, MmapOptions};
+use crate::vfs::{is_dir, is_file, Vfs};
+use crate::{requirements, NodePrefix};
+use crate::{DirstateError, Revision};
+use std::cell::{Cell, Ref, RefCell, RefMut};
 use std::collections::HashSet;
-use std::io::ErrorKind;
 use std::path::{Path, PathBuf};
 
 /// A repository on disk
@@ -16,6 +26,11 @@
     store: PathBuf,
     requirements: HashSet<String>,
     config: Config,
+    // None means not known/initialized yet
+    dirstate_parents: Cell<Option<DirstateParents>>,
+    dirstate_map: LazyCell<OwningDirstateMap, DirstateError>,
+    changelog: LazyCell<Changelog, HgError>,
+    manifestlog: LazyCell<Manifestlog, HgError>,
 }
 
 #[derive(Debug, derive_more::From)]
@@ -38,12 +53,6 @@
     }
 }
 
-/// Filesystem access abstraction for the contents of a given "base" diretory
-#[derive(Clone, Copy)]
-pub struct Vfs<'a> {
-    pub(crate) base: &'a Path,
-}
-
 impl Repo {
     /// tries to find nearest repository root in current working directory or
     /// its ancestors
@@ -127,7 +136,8 @@
         } else {
             let bytes = hg_vfs.read("sharedpath")?;
             let mut shared_path =
-                get_path_from_bytes(bytes.trim_end_newlines()).to_owned();
+                get_path_from_bytes(bytes.trim_end_matches(|b| b == b'\n'))
+                    .to_owned();
             if relative {
                 shared_path = dot_hg.join(shared_path)
             }
@@ -192,6 +202,10 @@
             store: store_path,
             dot_hg,
             config: repo_config,
+            dirstate_parents: Cell::new(None),
+            dirstate_map: LazyCell::new(Self::new_dirstate_map),
+            changelog: LazyCell::new(Changelog::open),
+            manifestlog: LazyCell::new(Manifestlog::open),
         };
 
         requirements::check(&repo)?;
@@ -234,82 +248,162 @@
             .contains(requirements::DIRSTATE_V2_REQUIREMENT)
     }
 
-    pub fn dirstate_parents(
-        &self,
-    ) -> Result<crate::dirstate::DirstateParents, HgError> {
-        let dirstate = self.hg_vfs().mmap_open("dirstate")?;
-        if dirstate.is_empty() {
-            return Ok(crate::dirstate::DirstateParents::NULL);
+    fn dirstate_file_contents(&self) -> Result<Vec<u8>, HgError> {
+        Ok(self
+            .hg_vfs()
+            .read("dirstate")
+            .io_not_found_as_none()?
+            .unwrap_or(Vec::new()))
+    }
+
+    pub fn dirstate_parents(&self) -> Result<DirstateParents, HgError> {
+        if let Some(parents) = self.dirstate_parents.get() {
+            return Ok(parents);
         }
-        let parents = if self.has_dirstate_v2() {
+        let dirstate = self.dirstate_file_contents()?;
+        let parents = if dirstate.is_empty() {
+            DirstateParents::NULL
+        } else if self.has_dirstate_v2() {
             crate::dirstate_tree::on_disk::read_docket(&dirstate)?.parents()
         } else {
             crate::dirstate::parsers::parse_dirstate_parents(&dirstate)?
                 .clone()
         };
+        self.dirstate_parents.set(Some(parents));
         Ok(parents)
     }
+
+    fn new_dirstate_map(&self) -> Result<OwningDirstateMap, DirstateError> {
+        let dirstate_file_contents = self.dirstate_file_contents()?;
+        if dirstate_file_contents.is_empty() {
+            self.dirstate_parents.set(Some(DirstateParents::NULL));
+            Ok(OwningDirstateMap::new_empty(Vec::new()))
+        } else if self.has_dirstate_v2() {
+            let docket = crate::dirstate_tree::on_disk::read_docket(
+                &dirstate_file_contents,
+            )?;
+            self.dirstate_parents.set(Some(docket.parents()));
+            let data_size = docket.data_size();
+            let metadata = docket.tree_metadata();
+            let mut map = if let Some(data_mmap) = self
+                .hg_vfs()
+                .mmap_open(docket.data_filename())
+                .io_not_found_as_none()?
+            {
+                OwningDirstateMap::new_empty(data_mmap)
+            } else {
+                OwningDirstateMap::new_empty(Vec::new())
+            };
+            let (on_disk, placeholder) = map.get_pair_mut();
+            *placeholder = DirstateMap::new_v2(on_disk, data_size, metadata)?;
+            Ok(map)
+        } else {
+            let mut map = OwningDirstateMap::new_empty(dirstate_file_contents);
+            let (on_disk, placeholder) = map.get_pair_mut();
+            let (inner, parents) = DirstateMap::new_v1(on_disk)?;
+            self.dirstate_parents
+                .set(Some(parents.unwrap_or(DirstateParents::NULL)));
+            *placeholder = inner;
+            Ok(map)
+        }
+    }
+
+    pub fn dirstate_map(
+        &self,
+    ) -> Result<Ref<OwningDirstateMap>, DirstateError> {
+        self.dirstate_map.get_or_init(self)
+    }
+
+    pub fn dirstate_map_mut(
+        &self,
+    ) -> Result<RefMut<OwningDirstateMap>, DirstateError> {
+        self.dirstate_map.get_mut_or_init(self)
+    }
+
+    pub fn changelog(&self) -> Result<Ref<Changelog>, HgError> {
+        self.changelog.get_or_init(self)
+    }
+
+    pub fn changelog_mut(&self) -> Result<RefMut<Changelog>, HgError> {
+        self.changelog.get_mut_or_init(self)
+    }
+
+    pub fn manifestlog(&self) -> Result<Ref<Manifestlog>, HgError> {
+        self.manifestlog.get_or_init(self)
+    }
+
+    pub fn manifestlog_mut(&self) -> Result<RefMut<Manifestlog>, HgError> {
+        self.manifestlog.get_mut_or_init(self)
+    }
+
+    /// Returns the manifest of the *changeset* with the given node ID
+    pub fn manifest_for_node(
+        &self,
+        node: impl Into<NodePrefix>,
+    ) -> Result<Manifest, RevlogError> {
+        self.manifestlog()?.data_for_node(
+            self.changelog()?
+                .data_for_node(node.into())?
+                .manifest_node()?
+                .into(),
+        )
+    }
+
+    /// Returns the manifest of the *changeset* with the given revision number
+    pub fn manifest_for_rev(
+        &self,
+        revision: Revision,
+    ) -> Result<Manifest, RevlogError> {
+        self.manifestlog()?.data_for_node(
+            self.changelog()?
+                .data_for_rev(revision)?
+                .manifest_node()?
+                .into(),
+        )
+    }
+
+    pub fn filelog(&self, path: &HgPath) -> Result<Filelog, HgError> {
+        Filelog::open(self, path)
+    }
 }
 
-impl Vfs<'_> {
-    pub fn join(&self, relative_path: impl AsRef<Path>) -> PathBuf {
-        self.base.join(relative_path)
-    }
+/// Lazily-initialized component of `Repo` with interior mutability
+///
+/// This differs from `OnceCell` in that the value can still be "deinitialized"
+/// later by setting its inner `Option` to `None`.
+struct LazyCell<T, E> {
+    value: RefCell<Option<T>>,
+    // `Fn`s that don’t capture environment are zero-size, so this box does
+    // not allocate:
+    init: Box<dyn Fn(&Repo) -> Result<T, E>>,
+}
 
-    pub fn read(
-        &self,
-        relative_path: impl AsRef<Path>,
-    ) -> Result<Vec<u8>, HgError> {
-        let path = self.join(relative_path);
-        std::fs::read(&path).when_reading_file(&path)
-    }
-
-    pub fn mmap_open(
-        &self,
-        relative_path: impl AsRef<Path>,
-    ) -> Result<Mmap, HgError> {
-        let path = self.base.join(relative_path);
-        let file = std::fs::File::open(&path).when_reading_file(&path)?;
-        // TODO: what are the safety requirements here?
-        let mmap = unsafe { MmapOptions::new().map(&file) }
-            .when_reading_file(&path)?;
-        Ok(mmap)
+impl<T, E> LazyCell<T, E> {
+    fn new(init: impl Fn(&Repo) -> Result<T, E> + 'static) -> Self {
+        Self {
+            value: RefCell::new(None),
+            init: Box::new(init),
+        }
     }
 
-    pub fn rename(
-        &self,
-        relative_from: impl AsRef<Path>,
-        relative_to: impl AsRef<Path>,
-    ) -> Result<(), HgError> {
-        let from = self.join(relative_from);
-        let to = self.join(relative_to);
-        std::fs::rename(&from, &to)
-            .with_context(|| IoErrorContext::RenamingFile { from, to })
+    fn get_or_init(&self, repo: &Repo) -> Result<Ref<T>, E> {
+        let mut borrowed = self.value.borrow();
+        if borrowed.is_none() {
+            drop(borrowed);
+            // Only use `borrow_mut` if it is really needed to avoid panic in
+            // case there is another outstanding borrow but mutation is not
+            // needed.
+            *self.value.borrow_mut() = Some((self.init)(repo)?);
+            borrowed = self.value.borrow()
+        }
+        Ok(Ref::map(borrowed, |option| option.as_ref().unwrap()))
+    }
+
+    pub fn get_mut_or_init(&self, repo: &Repo) -> Result<RefMut<T>, E> {
+        let mut borrowed = self.value.borrow_mut();
+        if borrowed.is_none() {
+            *borrowed = Some((self.init)(repo)?);
+        }
+        Ok(RefMut::map(borrowed, |option| option.as_mut().unwrap()))
     }
 }
-
-fn fs_metadata(
-    path: impl AsRef<Path>,
-) -> Result<Option<std::fs::Metadata>, HgError> {
-    let path = path.as_ref();
-    match std::fs::metadata(path) {
-        Ok(meta) => Ok(Some(meta)),
-        Err(error) => match error.kind() {
-            // TODO: when we require a Rust version where `NotADirectory` is
-            // stable, invert this logic and return None for it and `NotFound`
-            // and propagate any other error.
-            ErrorKind::PermissionDenied => Err(error).with_context(|| {
-                IoErrorContext::ReadingMetadata(path.to_owned())
-            }),
-            _ => Ok(None),
-        },
-    }
-}
-
-fn is_dir(path: impl AsRef<Path>) -> Result<bool, HgError> {
-    Ok(fs_metadata(path)?.map_or(false, |meta| meta.is_dir()))
-}
-
-fn is_file(path: impl AsRef<Path>) -> Result<bool, HgError> {
-    Ok(fs_metadata(path)?.map_or(false, |meta| meta.is_file()))
-}
--- a/rust/hg-core/src/requirements.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/requirements.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -1,6 +1,7 @@
 use crate::errors::{HgError, HgResultExt};
-use crate::repo::{Repo, Vfs};
+use crate::repo::Repo;
 use crate::utils::join_display;
+use crate::vfs::Vfs;
 use std::collections::HashSet;
 
 fn parse(bytes: &[u8]) -> Result<HashSet<String>, HgError> {
--- a/rust/hg-core/src/revlog.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/revlog.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -11,6 +11,7 @@
 pub mod path_encode;
 pub use node::{FromHexError, Node, NodePrefix};
 pub mod changelog;
+pub mod filelog;
 pub mod index;
 pub mod manifest;
 pub mod patch;
--- a/rust/hg-core/src/revlog/changelog.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/revlog/changelog.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -1,5 +1,6 @@
 use crate::errors::HgError;
 use crate::repo::Repo;
+use crate::revlog::node::NULL_NODE;
 use crate::revlog::revlog::{Revlog, RevlogError};
 use crate::revlog::Revision;
 use crate::revlog::{Node, NodePrefix};
@@ -12,22 +13,22 @@
 
 impl Changelog {
     /// Open the `changelog` of a repository given by its root.
-    pub fn open(repo: &Repo) -> Result<Self, RevlogError> {
+    pub fn open(repo: &Repo) -> Result<Self, HgError> {
         let revlog = Revlog::open(repo, "00changelog.i", None)?;
         Ok(Self { revlog })
     }
 
-    /// Return the `ChangelogEntry` a given node id.
-    pub fn get_node(
+    /// Return the `ChangelogEntry` for the given node ID.
+    pub fn data_for_node(
         &self,
         node: NodePrefix,
     ) -> Result<ChangelogEntry, RevlogError> {
-        let rev = self.revlog.get_node_rev(node)?;
-        self.get_rev(rev)
+        let rev = self.revlog.rev_from_node(node)?;
+        self.data_for_rev(rev)
     }
 
-    /// Return the `ChangelogEntry` of a given node revision.
-    pub fn get_rev(
+    /// Return the `ChangelogEntry` of the given revision number.
+    pub fn data_for_rev(
         &self,
         rev: Revision,
     ) -> Result<ChangelogEntry, RevlogError> {
@@ -36,7 +37,7 @@
     }
 
     pub fn node_from_rev(&self, rev: Revision) -> Option<&Node> {
-        Some(self.revlog.index.get_entry(rev)?.hash())
+        self.revlog.node_from_rev(rev)
     }
 }
 
@@ -57,9 +58,10 @@
 
     /// Return the node id of the `manifest` referenced by this `changelog`
     /// entry.
-    pub fn manifest_node(&self) -> Result<&[u8], RevlogError> {
-        self.lines()
-            .next()
-            .ok_or_else(|| HgError::corrupted("empty changelog entry").into())
+    pub fn manifest_node(&self) -> Result<Node, HgError> {
+        match self.lines().next() {
+            None => Ok(NULL_NODE),
+            Some(x) => Node::from_hex_for_repo(x),
+        }
     }
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/rust/hg-core/src/revlog/filelog.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -0,0 +1,88 @@
+use crate::errors::HgError;
+use crate::repo::Repo;
+use crate::revlog::path_encode::path_encode;
+use crate::revlog::revlog::{Revlog, RevlogError};
+use crate::revlog::NodePrefix;
+use crate::revlog::Revision;
+use crate::utils::files::get_path_from_bytes;
+use crate::utils::hg_path::HgPath;
+use crate::utils::SliceExt;
+use std::path::PathBuf;
+
+/// A specialized `Revlog` to work with file data logs.
+pub struct Filelog {
+    /// The generic `revlog` format.
+    revlog: Revlog,
+}
+
+impl Filelog {
+    pub fn open(repo: &Repo, file_path: &HgPath) -> Result<Self, HgError> {
+        let index_path = store_path(file_path, b".i");
+        let data_path = store_path(file_path, b".d");
+        let revlog = Revlog::open(repo, index_path, Some(&data_path))?;
+        Ok(Self { revlog })
+    }
+
+    /// The given node ID is that of the file as found in a manifest, not of a
+    /// changeset.
+    pub fn data_for_node(
+        &self,
+        file_node: impl Into<NodePrefix>,
+    ) -> Result<FilelogEntry, RevlogError> {
+        let file_rev = self.revlog.rev_from_node(file_node.into())?;
+        self.data_for_rev(file_rev)
+    }
+
+    /// The given revision is that of the file as found in a manifest, not of a
+    /// changeset.
+    pub fn data_for_rev(
+        &self,
+        file_rev: Revision,
+    ) -> Result<FilelogEntry, RevlogError> {
+        let data: Vec<u8> = self.revlog.get_rev_data(file_rev)?;
+        Ok(FilelogEntry(data.into()))
+    }
+}
+
+fn store_path(hg_path: &HgPath, suffix: &[u8]) -> PathBuf {
+    let encoded_bytes =
+        path_encode(&[b"data/", hg_path.as_bytes(), suffix].concat());
+    get_path_from_bytes(&encoded_bytes).into()
+}
+
+pub struct FilelogEntry(Vec<u8>);
+
+impl FilelogEntry {
+    /// Split into metadata and data
+    pub fn split(&self) -> Result<(Option<&[u8]>, &[u8]), HgError> {
+        const DELIMITER: &[u8; 2] = &[b'\x01', b'\n'];
+
+        if let Some(rest) = self.0.drop_prefix(DELIMITER) {
+            if let Some((metadata, data)) = rest.split_2_by_slice(DELIMITER) {
+                Ok((Some(metadata), data))
+            } else {
+                Err(HgError::corrupted(
+                    "Missing metadata end delimiter in filelog entry",
+                ))
+            }
+        } else {
+            Ok((None, &self.0))
+        }
+    }
+
+    /// Returns the file contents at this revision, stripped of any metadata
+    pub fn data(&self) -> Result<&[u8], HgError> {
+        let (_metadata, data) = self.split()?;
+        Ok(data)
+    }
+
+    /// Consume the entry, and convert it into data, discarding any metadata,
+    /// if present.
+    pub fn into_data(self) -> Result<Vec<u8>, HgError> {
+        if let (Some(_metadata), data) = self.split()? {
+            Ok(data.to_owned())
+        } else {
+            Ok(self.0)
+        }
+    }
+}
--- a/rust/hg-core/src/revlog/index.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/revlog/index.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -5,7 +5,6 @@
 
 use crate::errors::HgError;
 use crate::revlog::node::Node;
-use crate::revlog::revlog::RevlogError;
 use crate::revlog::{Revision, NULL_REVISION};
 
 pub const INDEX_ENTRY_SIZE: usize = 64;
@@ -23,7 +22,7 @@
     /// Calculate the start of each entry when is_inline is true.
     pub fn new(
         bytes: Box<dyn Deref<Target = [u8]> + Send>,
-    ) -> Result<Self, RevlogError> {
+    ) -> Result<Self, HgError> {
         if is_inline(&bytes) {
             let mut offset: usize = 0;
             let mut offsets = Vec::new();
@@ -58,7 +57,7 @@
 
     /// Value of the inline flag.
     pub fn is_inline(&self) -> bool {
-        is_inline(&self.bytes)
+        self.offsets.is_some()
     }
 
     /// Return a slice of bytes if `revlog` is inline. Panic if not.
@@ -209,6 +208,9 @@
 
 /// Value of the inline flag.
 pub fn is_inline(index_bytes: &[u8]) -> bool {
+    if index_bytes.len() < 4 {
+        return true;
+    }
     match &index_bytes[0..=1] {
         [0, 0] | [0, 2] => false,
         _ => true,
--- a/rust/hg-core/src/revlog/manifest.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/revlog/manifest.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -1,48 +1,60 @@
+use crate::errors::HgError;
 use crate::repo::Repo;
 use crate::revlog::revlog::{Revlog, RevlogError};
-use crate::revlog::NodePrefix;
 use crate::revlog::Revision;
+use crate::revlog::{Node, NodePrefix};
 use crate::utils::hg_path::HgPath;
 
 /// A specialized `Revlog` to work with `manifest` data format.
-pub struct Manifest {
+pub struct Manifestlog {
     /// The generic `revlog` format.
     revlog: Revlog,
 }
 
-impl Manifest {
+impl Manifestlog {
     /// Open the `manifest` of a repository given by its root.
-    pub fn open(repo: &Repo) -> Result<Self, RevlogError> {
+    pub fn open(repo: &Repo) -> Result<Self, HgError> {
         let revlog = Revlog::open(repo, "00manifest.i", None)?;
         Ok(Self { revlog })
     }
 
-    /// Return the `ManifestEntry` of a given node id.
-    pub fn get_node(
+    /// Return the `Manifest` for the given node ID.
+    ///
+    /// Note: this is a node ID in the manifestlog, typically found through
+    /// `ChangelogEntry::manifest_node`. It is *not* the node ID of any
+    /// changeset.
+    ///
+    /// See also `Repo::manifest_for_node`
+    pub fn data_for_node(
         &self,
         node: NodePrefix,
-    ) -> Result<ManifestEntry, RevlogError> {
-        let rev = self.revlog.get_node_rev(node)?;
-        self.get_rev(rev)
+    ) -> Result<Manifest, RevlogError> {
+        let rev = self.revlog.rev_from_node(node)?;
+        self.data_for_rev(rev)
     }
 
-    /// Return the `ManifestEntry` of a given node revision.
-    pub fn get_rev(
+    /// Return the `Manifest` of a given revision number.
+    ///
+    /// Note: this is a revision number in the manifestlog, *not* of any
+    /// changeset.
+    ///
+    /// See also `Repo::manifest_for_rev`
+    pub fn data_for_rev(
         &self,
         rev: Revision,
-    ) -> Result<ManifestEntry, RevlogError> {
+    ) -> Result<Manifest, RevlogError> {
         let bytes = self.revlog.get_rev_data(rev)?;
-        Ok(ManifestEntry { bytes })
+        Ok(Manifest { bytes })
     }
 }
 
-/// `Manifest` entry which knows how to interpret the `manifest` data bytes.
+/// `Manifestlog` entry which knows how to interpret the `manifest` data bytes.
 #[derive(Debug)]
-pub struct ManifestEntry {
+pub struct Manifest {
     bytes: Vec<u8>,
 }
 
-impl ManifestEntry {
+impl Manifest {
     /// Return an iterator over the lines of the entry.
     pub fn lines(&self) -> impl Iterator<Item = &[u8]> {
         self.bytes
@@ -73,4 +85,17 @@
             (HgPath::new(&line[..pos]), &line[hash_start..hash_end])
         })
     }
+
+    /// If the given path is in this manifest, return its filelog node ID
+    pub fn find_file(&self, path: &HgPath) -> Result<Option<Node>, HgError> {
+        // TODO: use binary search instead of linear scan. This may involve
+        // building (and caching) an index of the byte indicex of each manifest
+        // line.
+        for (manifest_path, node) in self.files_with_nodes() {
+            if manifest_path == path {
+                return Ok(Some(Node::from_hex_for_repo(node)?));
+            }
+        }
+        Ok(None)
+    }
 }
--- a/rust/hg-core/src/revlog/nodemap_docket.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/revlog/nodemap_docket.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -1,10 +1,9 @@
 use crate::errors::{HgError, HgResultExt};
 use crate::requirements;
 use bytes_cast::{unaligned, BytesCast};
-use memmap::Mmap;
+use memmap2::Mmap;
 use std::path::{Path, PathBuf};
 
-use super::revlog::RevlogError;
 use crate::repo::Repo;
 use crate::utils::strip_suffix;
 
@@ -38,7 +37,7 @@
     pub fn read_from_file(
         repo: &Repo,
         index_path: &Path,
-    ) -> Result<Option<(Self, Mmap)>, RevlogError> {
+    ) -> Result<Option<(Self, Mmap)>, HgError> {
         if !repo
             .requirements()
             .contains(requirements::NODEMAP_REQUIREMENT)
@@ -65,10 +64,9 @@
         };
 
         /// Treat any error as a parse error
-        fn parse<T, E>(result: Result<T, E>) -> Result<T, RevlogError> {
-            result.map_err(|_| {
-                HgError::corrupted("nodemap docket parse error").into()
-            })
+        fn parse<T, E>(result: Result<T, E>) -> Result<T, HgError> {
+            result
+                .map_err(|_| HgError::corrupted("nodemap docket parse error"))
         }
 
         let (header, rest) = parse(DocketHeader::from_bytes(input))?;
@@ -94,7 +92,7 @@
             if mmap.len() >= data_length {
                 Ok(Some((docket, mmap)))
             } else {
-                Err(HgError::corrupted("persistent nodemap too short").into())
+                Err(HgError::corrupted("persistent nodemap too short"))
             }
         } else {
             // Even if .hg/requires opted in, some revlogs are deemed small
--- a/rust/hg-core/src/revlog/revlog.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/revlog/revlog.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -18,6 +18,7 @@
 use crate::errors::HgError;
 use crate::repo::Repo;
 use crate::revlog::Revision;
+use crate::{Node, NULL_REVISION};
 
 #[derive(derive_more::From)]
 pub enum RevlogError {
@@ -50,7 +51,7 @@
     /// When index and data are not interleaved: bytes of the revlog index.
     /// When index and data are interleaved: bytes of the revlog index and
     /// data.
-    pub(crate) index: Index,
+    index: Index,
     /// When index and data are not interleaved: bytes of the revlog data
     data_bytes: Option<Box<dyn Deref<Target = [u8]> + Send>>,
     /// When present on disk: the persistent nodemap for this revlog
@@ -67,17 +68,24 @@
         repo: &Repo,
         index_path: impl AsRef<Path>,
         data_path: Option<&Path>,
-    ) -> Result<Self, RevlogError> {
+    ) -> Result<Self, HgError> {
         let index_path = index_path.as_ref();
-        let index_mmap = repo.store_vfs().mmap_open(&index_path)?;
+        let index = {
+            match repo.store_vfs().mmap_open_opt(&index_path)? {
+                None => Index::new(Box::new(vec![])),
+                Some(index_mmap) => {
+                    let version = get_version(&index_mmap)?;
+                    if version != 1 {
+                        // A proper new version should have had a repo/store
+                        // requirement.
+                        return Err(HgError::corrupted("corrupted revlog"));
+                    }
 
-        let version = get_version(&index_mmap);
-        if version != 1 {
-            // A proper new version should have had a repo/store requirement.
-            return Err(RevlogError::corrupted());
-        }
-
-        let index = Index::new(Box::new(index_mmap))?;
+                    let index = Index::new(Box::new(index_mmap))?;
+                    Ok(index)
+                }
+            }
+        }?;
 
         let default_data_path = index_path.with_extension("d");
 
@@ -92,14 +100,18 @@
                 Some(Box::new(data_mmap))
             };
 
-        let nodemap = NodeMapDocket::read_from_file(repo, index_path)?.map(
-            |(docket, data)| {
-                nodemap::NodeTree::load_bytes(
-                    Box::new(data),
-                    docket.data_length,
-                )
-            },
-        );
+        let nodemap = if index.is_inline() {
+            None
+        } else {
+            NodeMapDocket::read_from_file(repo, index_path)?.map(
+                |(docket, data)| {
+                    nodemap::NodeTree::load_bytes(
+                        Box::new(data),
+                        docket.data_length,
+                    )
+                },
+            )
+        };
 
         Ok(Revlog {
             index,
@@ -118,12 +130,26 @@
         self.index.is_empty()
     }
 
-    /// Return the full data associated to a node.
+    /// Returns the node ID for the given revision number, if it exists in this
+    /// revlog
+    pub fn node_from_rev(&self, rev: Revision) -> Option<&Node> {
+        if rev == NULL_REVISION {
+            return Some(&NULL_NODE);
+        }
+        Some(self.index.get_entry(rev)?.hash())
+    }
+
+    /// Return the revision number for the given node ID, if it exists in this
+    /// revlog
     #[timed]
-    pub fn get_node_rev(
+    pub fn rev_from_node(
         &self,
         node: NodePrefix,
     ) -> Result<Revision, RevlogError> {
+        if node.is_prefix_of(&NULL_NODE) {
+            return Ok(NULL_REVISION);
+        }
+
         if let Some(nodemap) = &self.nodemap {
             return nodemap
                 .find_bin(&self.index, node)?
@@ -167,6 +193,9 @@
     /// snapshot to rebuild the final data.
     #[timed]
     pub fn get_rev_data(&self, rev: Revision) -> Result<Vec<u8>, RevlogError> {
+        if rev == NULL_REVISION {
+            return Ok(vec![]);
+        };
         // Todo return -> Cow
         let mut entry = self.get_entry(rev)?;
         let mut delta_chain = vec![];
@@ -292,6 +321,10 @@
 }
 
 impl<'a> RevlogEntry<'a> {
+    pub fn revision(&self) -> Revision {
+        self.rev
+    }
+
     /// Extract the data contained in the entry.
     pub fn data(&self) -> Result<Cow<'_, [u8]>, RevlogError> {
         if self.bytes.is_empty() {
@@ -355,8 +388,16 @@
 }
 
 /// Format version of the revlog.
-pub fn get_version(index_bytes: &[u8]) -> u16 {
-    BigEndian::read_u16(&index_bytes[2..=3])
+pub fn get_version(index_bytes: &[u8]) -> Result<u16, HgError> {
+    if index_bytes.len() == 0 {
+        return Ok(1);
+    };
+    if index_bytes.len() < 4 {
+        return Err(HgError::corrupted(
+            "corrupted revlog: can't read the index format header",
+        ));
+    };
+    Ok(BigEndian::read_u16(&index_bytes[2..=3]))
 }
 
 /// Calculate the hash of a revision given its data and its parents.
@@ -391,6 +432,6 @@
             .with_version(1)
             .build();
 
-        assert_eq!(get_version(&bytes), 1)
+        assert_eq!(get_version(&bytes).map_err(|_err| ()), Ok(1))
     }
 }
--- a/rust/hg-core/src/revset.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/revset.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -4,7 +4,6 @@
 
 use crate::errors::HgError;
 use crate::repo::Repo;
-use crate::revlog::changelog::Changelog;
 use crate::revlog::revlog::{Revlog, RevlogError};
 use crate::revlog::NodePrefix;
 use crate::revlog::{Revision, NULL_REVISION, WORKING_DIRECTORY_HEX};
@@ -17,23 +16,25 @@
     input: &str,
     repo: &Repo,
 ) -> Result<Revision, RevlogError> {
-    let changelog = Changelog::open(repo)?;
+    let changelog = repo.changelog()?;
 
-    match resolve_rev_number_or_hex_prefix(input, &changelog.revlog) {
-        Err(RevlogError::InvalidRevision) => {} // Try other syntax
-        result => return result,
+    match input {
+        "." => {
+            let p1 = repo.dirstate_parents()?.p1;
+            return Ok(changelog.revlog.rev_from_node(p1.into())?);
+        }
+        "null" => return Ok(NULL_REVISION),
+        _ => {}
     }
 
-    if input == "null" {
-        return Ok(NULL_REVISION);
+    match resolve_rev_number_or_hex_prefix(input, &changelog.revlog) {
+        Err(RevlogError::InvalidRevision) => {
+            // TODO: support for the rest of the language here.
+            let msg = format!("cannot parse revset '{}'", input);
+            Err(HgError::unsupported(msg).into())
+        }
+        result => return result,
     }
-
-    // TODO: support for the rest of the language here.
-
-    Err(
-        HgError::unsupported(format!("cannot parse revset '{}'", input))
-            .into(),
-    )
 }
 
 /// Resolve the small subset of the language suitable for revlogs other than
@@ -46,8 +47,14 @@
     input: &str,
     revlog: &Revlog,
 ) -> Result<Revision, RevlogError> {
+    // The Python equivalent of this is part of `revsymbol` in
+    // `mercurial/scmutil.py`
+
     if let Ok(integer) = input.parse::<i32>() {
-        if integer >= 0 && revlog.has_rev(integer) {
+        if integer.to_string() == input
+            && integer >= 0
+            && revlog.has_rev(integer)
+        {
             return Ok(integer);
         }
     }
@@ -56,7 +63,7 @@
         {
             return Err(RevlogError::WDirUnsupported);
         }
-        return revlog.get_node_rev(prefix);
+        return revlog.rev_from_node(prefix);
     }
     Err(RevlogError::InvalidRevision)
 }
--- a/rust/hg-core/src/utils.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/utils.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -67,36 +67,35 @@
 }
 
 pub trait SliceExt {
-    fn trim_end_newlines(&self) -> &Self;
     fn trim_end(&self) -> &Self;
     fn trim_start(&self) -> &Self;
+    fn trim_end_matches(&self, f: impl FnMut(u8) -> bool) -> &Self;
+    fn trim_start_matches(&self, f: impl FnMut(u8) -> bool) -> &Self;
     fn trim(&self) -> &Self;
     fn drop_prefix(&self, needle: &Self) -> Option<&Self>;
     fn split_2(&self, separator: u8) -> Option<(&[u8], &[u8])>;
-}
-
-#[allow(clippy::trivially_copy_pass_by_ref)]
-fn is_not_whitespace(c: &u8) -> bool {
-    !(*c as char).is_whitespace()
+    fn split_2_by_slice(&self, separator: &[u8]) -> Option<(&[u8], &[u8])>;
 }
 
 impl SliceExt for [u8] {
-    fn trim_end_newlines(&self) -> &[u8] {
-        if let Some(last) = self.iter().rposition(|&byte| byte != b'\n') {
+    fn trim_end(&self) -> &[u8] {
+        self.trim_end_matches(|byte| byte.is_ascii_whitespace())
+    }
+
+    fn trim_start(&self) -> &[u8] {
+        self.trim_start_matches(|byte| byte.is_ascii_whitespace())
+    }
+
+    fn trim_end_matches(&self, mut f: impl FnMut(u8) -> bool) -> &Self {
+        if let Some(last) = self.iter().rposition(|&byte| !f(byte)) {
             &self[..=last]
         } else {
             &[]
         }
     }
-    fn trim_end(&self) -> &[u8] {
-        if let Some(last) = self.iter().rposition(is_not_whitespace) {
-            &self[..=last]
-        } else {
-            &[]
-        }
-    }
-    fn trim_start(&self) -> &[u8] {
-        if let Some(first) = self.iter().position(is_not_whitespace) {
+
+    fn trim_start_matches(&self, mut f: impl FnMut(u8) -> bool) -> &Self {
+        if let Some(first) = self.iter().position(|&byte| !f(byte)) {
             &self[first..]
         } else {
             &[]
@@ -136,6 +135,14 @@
         let b = iter.next()?;
         Some((a, b))
     }
+
+    fn split_2_by_slice(&self, separator: &[u8]) -> Option<(&[u8], &[u8])> {
+        if let Some(pos) = find_slice_in_slice(self, separator) {
+            Some((&self[..pos], &self[pos + separator.len()..]))
+        } else {
+            None
+        }
+    }
 }
 
 pub trait Escaped {
--- a/rust/hg-core/src/utils/files.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-core/src/utils/files.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -18,7 +18,6 @@
 use same_file::is_same_file;
 use std::borrow::{Cow, ToOwned};
 use std::ffi::{OsStr, OsString};
-use std::fs::Metadata;
 use std::iter::FusedIterator;
 use std::ops::Deref;
 use std::path::{Path, PathBuf};
@@ -181,38 +180,6 @@
     hfs_ignore_clean(&bytes.to_ascii_lowercase())
 }
 
-#[derive(Eq, PartialEq, Ord, PartialOrd, Copy, Clone)]
-pub struct HgMetadata {
-    pub st_dev: u64,
-    pub st_mode: u32,
-    pub st_nlink: u64,
-    pub st_size: u64,
-    pub st_mtime: i64,
-    pub st_ctime: i64,
-}
-
-// TODO support other plaforms
-#[cfg(unix)]
-impl HgMetadata {
-    pub fn from_metadata(metadata: Metadata) -> Self {
-        use std::os::unix::fs::MetadataExt;
-        Self {
-            st_dev: metadata.dev(),
-            st_mode: metadata.mode(),
-            st_nlink: metadata.nlink(),
-            st_size: metadata.size(),
-            st_mtime: metadata.mtime(),
-            st_ctime: metadata.ctime(),
-        }
-    }
-
-    pub fn is_symlink(&self) -> bool {
-        // This is way too manual, but `HgMetadata` will go away in the
-        // near-future dirstate rewrite anyway.
-        self.st_mode & 0170000 == 0120000
-    }
-}
-
 /// Returns the canonical path of `name`, given `cwd` and `root`
 pub fn canonical_path(
     root: impl AsRef<Path>,
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/rust/hg-core/src/vfs.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -0,0 +1,100 @@
+use crate::errors::{HgError, IoErrorContext, IoResultExt};
+use memmap2::{Mmap, MmapOptions};
+use std::io::ErrorKind;
+use std::path::{Path, PathBuf};
+
+/// Filesystem access abstraction for the contents of a given "base" diretory
+#[derive(Clone, Copy)]
+pub struct Vfs<'a> {
+    pub(crate) base: &'a Path,
+}
+
+struct FileNotFound(std::io::Error, PathBuf);
+
+impl Vfs<'_> {
+    pub fn join(&self, relative_path: impl AsRef<Path>) -> PathBuf {
+        self.base.join(relative_path)
+    }
+
+    pub fn read(
+        &self,
+        relative_path: impl AsRef<Path>,
+    ) -> Result<Vec<u8>, HgError> {
+        let path = self.join(relative_path);
+        std::fs::read(&path).when_reading_file(&path)
+    }
+
+    fn mmap_open_gen(
+        &self,
+        relative_path: impl AsRef<Path>,
+    ) -> Result<Result<Mmap, FileNotFound>, HgError> {
+        let path = self.join(relative_path);
+        let file = match std::fs::File::open(&path) {
+            Err(err) => {
+                if let ErrorKind::NotFound = err.kind() {
+                    return Ok(Err(FileNotFound(err, path)));
+                };
+                return (Err(err)).when_reading_file(&path);
+            }
+            Ok(file) => file,
+        };
+        // TODO: what are the safety requirements here?
+        let mmap = unsafe { MmapOptions::new().map(&file) }
+            .when_reading_file(&path)?;
+        Ok(Ok(mmap))
+    }
+
+    pub fn mmap_open_opt(
+        &self,
+        relative_path: impl AsRef<Path>,
+    ) -> Result<Option<Mmap>, HgError> {
+        self.mmap_open_gen(relative_path).map(|res| res.ok())
+    }
+
+    pub fn mmap_open(
+        &self,
+        relative_path: impl AsRef<Path>,
+    ) -> Result<Mmap, HgError> {
+        match self.mmap_open_gen(relative_path)? {
+            Err(FileNotFound(err, path)) => Err(err).when_reading_file(&path),
+            Ok(res) => Ok(res),
+        }
+    }
+
+    pub fn rename(
+        &self,
+        relative_from: impl AsRef<Path>,
+        relative_to: impl AsRef<Path>,
+    ) -> Result<(), HgError> {
+        let from = self.join(relative_from);
+        let to = self.join(relative_to);
+        std::fs::rename(&from, &to)
+            .with_context(|| IoErrorContext::RenamingFile { from, to })
+    }
+}
+
+fn fs_metadata(
+    path: impl AsRef<Path>,
+) -> Result<Option<std::fs::Metadata>, HgError> {
+    let path = path.as_ref();
+    match std::fs::metadata(path) {
+        Ok(meta) => Ok(Some(meta)),
+        Err(error) => match error.kind() {
+            // TODO: when we require a Rust version where `NotADirectory` is
+            // stable, invert this logic and return None for it and `NotFound`
+            // and propagate any other error.
+            ErrorKind::PermissionDenied => Err(error).with_context(|| {
+                IoErrorContext::ReadingMetadata(path.to_owned())
+            }),
+            _ => Ok(None),
+        },
+    }
+}
+
+pub(crate) fn is_dir(path: impl AsRef<Path>) -> Result<bool, HgError> {
+    Ok(fs_metadata(path)?.map_or(false, |meta| meta.is_dir()))
+}
+
+pub(crate) fn is_file(path: impl AsRef<Path>) -> Result<bool, HgError> {
+    Ok(fs_metadata(path)?.map_or(false, |meta| meta.is_file()))
+}
--- a/rust/hg-cpython/Cargo.toml	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-cpython/Cargo.toml	Thu Oct 21 17:25:41 2021 +0200
@@ -9,7 +9,7 @@
 crate-type = ["cdylib"]
 
 [features]
-default = ["python27"]
+default = ["python3"]
 
 # Features to build an extension module:
 python27 = ["cpython/python27-sys", "cpython/extension-module-2-7"]
@@ -21,12 +21,10 @@
 python3-bin = ["cpython/python3-sys"]
 
 [dependencies]
+cpython = { version = "0.7.0", default-features = false }
 crossbeam-channel = "0.4"
 hg-core = { path = "../hg-core"}
-libc = '*'
+libc = "0.2"
 log = "0.4.8"
 env_logger = "0.7.1"
-
-[dependencies.cpython]
-version = "0.6.0"
-default-features = false
+stable_deref_trait = "1.2.0"
--- a/rust/hg-cpython/src/copy_tracing.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-cpython/src/copy_tracing.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -13,58 +13,7 @@
 use hg::copy_tracing::CombineChangesetCopies;
 use hg::Revision;
 
-use self::pybytes_with_data::PyBytesWithData;
-
-// Module to encapsulate private fields
-mod pybytes_with_data {
-    use cpython::{PyBytes, Python};
-
-    /// Safe abstraction over a `PyBytes` together with the `&[u8]` slice
-    /// that borrows it.
-    ///
-    /// Calling `PyBytes::data` requires a GIL marker but we want to access the
-    /// data in a thread that (ideally) does not need to acquire the GIL.
-    /// This type allows separating the call an the use.
-    pub(super) struct PyBytesWithData {
-        #[allow(unused)]
-        keep_alive: PyBytes,
-
-        /// Borrows the buffer inside `self.keep_alive`,
-        /// but the borrow-checker cannot express self-referential structs.
-        data: *const [u8],
-    }
-
-    fn require_send<T: Send>() {}
-
-    #[allow(unused)]
-    fn static_assert_pybytes_is_send() {
-        require_send::<PyBytes>;
-    }
-
-    // Safety: PyBytes is Send. Raw pointers are not by default,
-    // but here sending one to another thread is fine since we ensure it stays
-    // valid.
-    unsafe impl Send for PyBytesWithData {}
-
-    impl PyBytesWithData {
-        pub fn new(py: Python, bytes: PyBytes) -> Self {
-            Self {
-                data: bytes.data(py),
-                keep_alive: bytes,
-            }
-        }
-
-        pub fn data(&self) -> &[u8] {
-            // Safety: the raw pointer is valid as long as the PyBytes is still
-            // alive, and the returned slice borrows `self`.
-            unsafe { &*self.data }
-        }
-
-        pub fn unwrap(self) -> PyBytes {
-            self.keep_alive
-        }
-    }
-}
+use crate::pybytes_deref::PyBytesDeref;
 
 /// Combines copies information contained into revision `revs` to build a copy
 /// map.
@@ -123,7 +72,7 @@
         //
         // TODO: tweak the bound?
         let (rev_info_sender, rev_info_receiver) =
-            crossbeam_channel::bounded::<RevInfo<PyBytesWithData>>(1000);
+            crossbeam_channel::bounded::<RevInfo<PyBytesDeref>>(1000);
 
         // This channel (going the other way around) however is unbounded.
         // If they were both bounded, there might potentially be deadlocks
@@ -143,7 +92,7 @@
                 CombineChangesetCopies::new(children_count);
             for (rev, p1, p2, opt_bytes) in rev_info_receiver {
                 let files = match &opt_bytes {
-                    Some(raw) => ChangedFiles::new(raw.data()),
+                    Some(raw) => ChangedFiles::new(raw.as_ref()),
                     // Python None was extracted to Option::None,
                     // meaning there was no copy data.
                     None => ChangedFiles::new_empty(),
@@ -169,7 +118,7 @@
 
         for rev_info in revs_info {
             let (rev, p1, p2, opt_bytes) = rev_info?;
-            let opt_bytes = opt_bytes.map(|b| PyBytesWithData::new(py, b));
+            let opt_bytes = opt_bytes.map(|b| PyBytesDeref::new(py, b));
 
             // We’d prefer to avoid the child thread calling into Python code,
             // but this avoids a potential deadlock on the GIL if it does:
--- a/rust/hg-cpython/src/dirstate.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-cpython/src/dirstate.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -12,101 +12,17 @@
 mod copymap;
 mod dirs_multiset;
 mod dirstate_map;
-mod dispatch;
-mod non_normal_entries;
-mod owning;
+mod item;
 mod status;
+use self::item::DirstateItem;
 use crate::{
     dirstate::{
         dirs_multiset::Dirs, dirstate_map::DirstateMap, status::status_wrapper,
     },
     exceptions,
 };
-use cpython::{
-    exc, PyBytes, PyDict, PyErr, PyList, PyModule, PyObject, PyResult,
-    PySequence, Python,
-};
+use cpython::{PyBytes, PyDict, PyList, PyModule, PyObject, PyResult, Python};
 use hg::dirstate_tree::on_disk::V2_FORMAT_MARKER;
-use hg::{utils::hg_path::HgPathBuf, DirstateEntry, EntryState, StateMap};
-use libc::{c_char, c_int};
-use std::convert::TryFrom;
-
-// C code uses a custom `dirstate_tuple` type, checks in multiple instances
-// for this type, and raises a Python `Exception` if the check does not pass.
-// Because this type differs only in name from the regular Python tuple, it
-// would be a good idea in the near future to remove it entirely to allow
-// for a pure Python tuple of the same effective structure to be used,
-// rendering this type and the capsule below useless.
-py_capsule_fn!(
-    from mercurial.cext.parsers import make_dirstate_item_CAPI
-        as make_dirstate_item_capi
-        signature (
-            state: c_char,
-            mode: c_int,
-            size: c_int,
-            mtime: c_int,
-        ) -> *mut RawPyObject
-);
-
-pub fn make_dirstate_item(
-    py: Python,
-    entry: &DirstateEntry,
-) -> PyResult<PyObject> {
-    let &DirstateEntry {
-        state,
-        mode,
-        size,
-        mtime,
-    } = entry;
-    // Explicitly go through u8 first, then cast to platform-specific `c_char`
-    // because Into<u8> has a specific implementation while `as c_char` would
-    // just do a naive enum cast.
-    let state_code: u8 = state.into();
-    make_dirstate_item_raw(py, state_code, mode, size, mtime)
-}
-
-pub fn make_dirstate_item_raw(
-    py: Python,
-    state: u8,
-    mode: i32,
-    size: i32,
-    mtime: i32,
-) -> PyResult<PyObject> {
-    let make = make_dirstate_item_capi::retrieve(py)?;
-    let maybe_obj = unsafe {
-        let ptr = make(state as c_char, mode, size, mtime);
-        PyObject::from_owned_ptr_opt(py, ptr)
-    };
-    maybe_obj.ok_or_else(|| PyErr::fetch(py))
-}
-
-pub fn extract_dirstate(py: Python, dmap: &PyDict) -> Result<StateMap, PyErr> {
-    dmap.items(py)
-        .iter()
-        .map(|(filename, stats)| {
-            let stats = stats.extract::<PySequence>(py)?;
-            let state = stats.get_item(py, 0)?.extract::<PyBytes>(py)?;
-            let state =
-                EntryState::try_from(state.data(py)[0]).map_err(|e| {
-                    PyErr::new::<exc::ValueError, _>(py, e.to_string())
-                })?;
-            let mode = stats.get_item(py, 1)?.extract(py)?;
-            let size = stats.get_item(py, 2)?.extract(py)?;
-            let mtime = stats.get_item(py, 3)?.extract(py)?;
-            let filename = filename.extract::<PyBytes>(py)?;
-            let filename = filename.data(py);
-            Ok((
-                HgPathBuf::from(filename.to_owned()),
-                DirstateEntry {
-                    state,
-                    mode,
-                    size,
-                    mtime,
-                },
-            ))
-        })
-        .collect()
-}
 
 /// Create the module, with `__package__` given from parent
 pub fn init_module(py: Python, package: &str) -> PyResult<PyModule> {
@@ -125,6 +41,7 @@
     )?;
     m.add_class::<Dirs>(py)?;
     m.add_class::<DirstateMap>(py)?;
+    m.add_class::<DirstateItem>(py)?;
     m.add(py, "V2_FORMAT_MARKER", PyBytes::new(py, V2_FORMAT_MARKER))?;
     m.add(
         py,
@@ -137,7 +54,7 @@
                 matcher: PyObject,
                 ignorefiles: PyList,
                 check_exec: bool,
-                last_normal_time: i64,
+                last_normal_time: (u32, u32),
                 list_clean: bool,
                 list_ignored: bool,
                 list_unknown: bool,
--- a/rust/hg-cpython/src/dirstate/copymap.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-cpython/src/dirstate/copymap.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -15,9 +15,9 @@
 
 use crate::dirstate::dirstate_map::v2_error;
 use crate::dirstate::dirstate_map::DirstateMap;
+use hg::dirstate::CopyMapIter;
 use hg::dirstate_tree::on_disk::DirstateV2ParseError;
 use hg::utils::hg_path::HgPath;
-use hg::CopyMapIter;
 
 py_class!(pub class CopyMap |py| {
     data dirstate_map: DirstateMap;
--- a/rust/hg-cpython/src/dirstate/dirs_multiset.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-cpython/src/dirstate/dirs_multiset.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -9,19 +9,15 @@
 //! `hg-core` package.
 
 use std::cell::RefCell;
-use std::convert::TryInto;
 
 use cpython::{
     exc, ObjectProtocol, PyBytes, PyClone, PyDict, PyErr, PyObject, PyResult,
     Python, UnsafePyLeaked,
 };
 
-use crate::dirstate::extract_dirstate;
 use hg::{
-    errors::HgError,
     utils::hg_path::{HgPath, HgPathBuf},
-    DirsMultiset, DirsMultisetIter, DirstateError, DirstateMapError,
-    EntryState,
+    DirsMultiset, DirsMultisetIter, DirstateMapError,
 };
 
 py_class!(pub class Dirs |py| {
@@ -32,25 +28,11 @@
     def __new__(
         _cls,
         map: PyObject,
-        skip: Option<PyObject> = None
     ) -> PyResult<Self> {
-        let mut skip_state: Option<EntryState> = None;
-        if let Some(skip) = skip {
-            skip_state = Some(
-                skip.extract::<PyBytes>(py)?.data(py)[0]
-                    .try_into()
-                    .map_err(|e: HgError| {
-                        PyErr::new::<exc::ValueError, _>(py, e.to_string())
-                    })?,
-            );
-        }
-        let inner = if let Ok(map) = map.cast_as::<PyDict>(py) {
-            let dirstate = extract_dirstate(py, &map)?;
-            let dirstate = dirstate.iter().map(|(k, v)| Ok((k, *v)));
-            DirsMultiset::from_dirstate(dirstate, skip_state)
-                .map_err(|e: DirstateError| {
-                    PyErr::new::<exc::ValueError, _>(py, e.to_string())
-                })?
+        let inner = if map.cast_as::<PyDict>(py).is_ok() {
+            let err = "pathutil.dirs() with a dict should only be used by the Python dirstatemap \
+                and should not be used when Rust is enabled";
+            return Err(PyErr::new::<exc::TypeError, _>(py, err.to_string()))
         } else {
             let map: Result<Vec<HgPathBuf>, PyErr> = map
                 .iter(py)?
--- a/rust/hg-cpython/src/dirstate/dirstate_map.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-cpython/src/dirstate/dirstate_map.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -12,32 +12,24 @@
 use std::convert::TryInto;
 
 use cpython::{
-    exc, ObjectProtocol, PyBool, PyBytes, PyClone, PyDict, PyErr, PyList,
-    PyObject, PyResult, PySet, PyString, Python, PythonObject, ToPyObject,
-    UnsafePyLeaked,
+    exc, PyBool, PyBytes, PyClone, PyDict, PyErr, PyList, PyNone, PyObject,
+    PyResult, Python, PythonObject, ToPyObject, UnsafePyLeaked,
 };
 
 use crate::{
     dirstate::copymap::{CopyMap, CopyMapItemsIterator, CopyMapKeysIterator},
-    dirstate::make_dirstate_item,
-    dirstate::make_dirstate_item_raw,
-    dirstate::non_normal_entries::{
-        NonNormalEntries, NonNormalEntriesIterator,
-    },
-    dirstate::owning::OwningDirstateMap,
-    parsers::dirstate_parents_to_pytuple,
+    dirstate::item::{timestamp, DirstateItem},
+    pybytes_deref::PyBytesDeref,
 };
 use hg::{
-    dirstate::parsers::Timestamp,
-    dirstate::MTIME_UNSET,
-    dirstate::SIZE_NON_NORMAL,
-    dirstate_tree::dispatch::DirstateMapMethods,
+    dirstate::StateMapIter,
+    dirstate_tree::dirstate_map::DirstateMap as TreeDirstateMap,
     dirstate_tree::on_disk::DirstateV2ParseError,
+    dirstate_tree::owning::OwningDirstateMap,
     revlog::Node,
     utils::files::normalize_case,
     utils::hg_path::{HgPath, HgPathBuf},
-    DirstateEntry, DirstateError, DirstateMap as RustDirstateMap,
-    DirstateParents, EntryState, StateMapIter,
+    DirstateEntry, DirstateError, DirstateParents, EntryState,
 };
 
 // TODO
@@ -53,26 +45,26 @@
 //     All attributes also have to have a separate refcount data attribute for
 //     leaks, with all methods that go along for reference sharing.
 py_class!(pub class DirstateMap |py| {
-    @shared data inner: Box<dyn DirstateMapMethods + Send>;
+    @shared data inner: OwningDirstateMap;
 
     /// Returns a `(dirstate_map, parents)` tuple
     @staticmethod
     def new_v1(
-        use_dirstate_tree: bool,
         on_disk: PyBytes,
     ) -> PyResult<PyObject> {
-        let (inner, parents) = if use_dirstate_tree {
-            let (map, parents) = OwningDirstateMap::new_v1(py, on_disk)
-                .map_err(|e| dirstate_error(py, e))?;
-            (Box::new(map) as _, parents)
-        } else {
-            let bytes = on_disk.data(py);
-            let mut map = RustDirstateMap::default();
-            let parents = map.read(bytes).map_err(|e| dirstate_error(py, e))?;
-            (Box::new(map) as _, parents)
-        };
-        let map = Self::create_instance(py, inner)?;
-        let parents = parents.map(|p| dirstate_parents_to_pytuple(py, &p));
+        let on_disk = PyBytesDeref::new(py, on_disk);
+        let mut map = OwningDirstateMap::new_empty(on_disk);
+        let (on_disk, map_placeholder) = map.get_pair_mut();
+
+        let (actual_map, parents) = TreeDirstateMap::new_v1(on_disk)
+            .map_err(|e| dirstate_error(py, e))?;
+        *map_placeholder = actual_map;
+        let map = Self::create_instance(py, map)?;
+        let parents = parents.map(|p| {
+            let p1 = PyBytes::new(py, p.p1.as_bytes());
+            let p2 = PyBytes::new(py, p.p2.as_bytes());
+            (p1, p2)
+        });
         Ok((map, parents).to_py_object(py).into_object())
     }
 
@@ -86,10 +78,13 @@
         let dirstate_error = |e: DirstateError| {
             PyErr::new::<exc::OSError, _>(py, format!("Dirstate error: {:?}", e))
         };
-        let inner = OwningDirstateMap::new_v2(
-            py, on_disk, data_size, tree_metadata,
+        let on_disk = PyBytesDeref::new(py, on_disk);
+        let mut map = OwningDirstateMap::new_empty(on_disk);
+        let (on_disk, map_placeholder) = map.get_pair_mut();
+        *map_placeholder = TreeDirstateMap::new_v2(
+            on_disk, data_size, tree_metadata.data(py),
         ).map_err(dirstate_error)?;
-        let map = Self::create_instance(py, Box::new(inner))?;
+        let map = Self::create_instance(py, map)?;
         Ok(map.into_object())
     }
 
@@ -111,79 +106,38 @@
             .map_err(|e| v2_error(py, e))?
         {
             Some(entry) => {
-                Ok(Some(make_dirstate_item(py, &entry)?))
+                Ok(Some(DirstateItem::new_as_pyobject(py, entry)?))
             },
             None => Ok(default)
         }
     }
 
-    def set_v1(&self, path: PyObject, item: PyObject) -> PyResult<PyObject> {
+    def set_dirstate_item(
+        &self,
+        path: PyObject,
+        item: DirstateItem
+    ) -> PyResult<PyObject> {
         let f = path.extract::<PyBytes>(py)?;
         let filename = HgPath::new(f.data(py));
-        let state = item.getattr(py, "state")?.extract::<PyBytes>(py)?;
-        let state = state.data(py)[0];
-        let entry = DirstateEntry {
-            state: state.try_into().expect("state is always valid"),
-            mtime: item.getattr(py, "mtime")?.extract(py)?,
-            size: item.getattr(py, "size")?.extract(py)?,
-            mode: item.getattr(py, "mode")?.extract(py)?,
-        };
-        self.inner(py).borrow_mut().set_v1(filename, entry);
+        self.inner(py)
+            .borrow_mut()
+            .set_entry(filename, item.get_entry(py))
+            .map_err(|e| v2_error(py, e))?;
         Ok(py.None())
     }
 
     def addfile(
         &self,
-        f: PyObject,
-        mode: PyObject,
-        size: PyObject,
-        mtime: PyObject,
-        added: PyObject,
-        merged: PyObject,
-        from_p2: PyObject,
-        possibly_dirty: PyObject,
-    ) -> PyResult<PyObject> {
-        let f = f.extract::<PyBytes>(py)?;
+        f: PyBytes,
+        item: DirstateItem,
+    ) -> PyResult<PyNone> {
         let filename = HgPath::new(f.data(py));
-        let mode = if mode.is_none(py) {
-            // fallback default value
-            0
-        } else {
-            mode.extract(py)?
-        };
-        let size = if size.is_none(py) {
-            // fallback default value
-            SIZE_NON_NORMAL
-        } else {
-            size.extract(py)?
-        };
-        let mtime = if mtime.is_none(py) {
-            // fallback default value
-            MTIME_UNSET
-        } else {
-            mtime.extract(py)?
-        };
-        let entry = DirstateEntry {
-            // XXX Arbitrary default value since the value is determined later
-            state: EntryState::Normal,
-            mode: mode,
-            size: size,
-            mtime: mtime,
-        };
-        let added = added.extract::<PyBool>(py)?.is_true();
-        let merged = merged.extract::<PyBool>(py)?.is_true();
-        let from_p2 = from_p2.extract::<PyBool>(py)?.is_true();
-        let possibly_dirty = possibly_dirty.extract::<PyBool>(py)?.is_true();
-        self.inner(py).borrow_mut().add_file(
-            filename,
-            entry,
-            added,
-            merged,
-            from_p2,
-            possibly_dirty
-        ).and(Ok(py.None())).or_else(|e: DirstateError| {
-            Err(PyErr::new::<exc::ValueError, _>(py, e.to_string()))
-        })
+        let entry = item.get_entry(py);
+        self.inner(py)
+            .borrow_mut()
+            .add_file(filename, entry)
+            .map_err(|e |dirstate_error(py, e))?;
+        Ok(PyNone)
     }
 
     def removefile(
@@ -205,135 +159,15 @@
         Ok(py.None())
     }
 
-    def dropfile(
-        &self,
-        f: PyObject,
-    ) -> PyResult<PyBool> {
-        self.inner(py).borrow_mut()
-            .drop_file(
-                HgPath::new(f.extract::<PyBytes>(py)?.data(py)),
-            )
-            .and_then(|b| Ok(b.to_py_object(py)))
-            .or_else(|e| {
-                Err(PyErr::new::<exc::OSError, _>(
-                    py,
-                    format!("Dirstate error: {}", e.to_string()),
-                ))
-            })
-    }
-
-    def clearambiguoustimes(
+    def drop_item_and_copy_source(
         &self,
-        files: PyObject,
-        now: PyObject
-    ) -> PyResult<PyObject> {
-        let files: PyResult<Vec<HgPathBuf>> = files
-            .iter(py)?
-            .map(|filename| {
-                Ok(HgPathBuf::from_bytes(
-                    filename?.extract::<PyBytes>(py)?.data(py),
-                ))
-            })
-            .collect();
-        self.inner(py)
-            .borrow_mut()
-            .clear_ambiguous_times(files?, now.extract(py)?)
-            .map_err(|e| v2_error(py, e))?;
-        Ok(py.None())
-    }
-
-    def other_parent_entries(&self) -> PyResult<PyObject> {
-        let mut inner_shared = self.inner(py).borrow_mut();
-        let set = PySet::empty(py)?;
-        for path in inner_shared.iter_other_parent_paths() {
-            let path = path.map_err(|e| v2_error(py, e))?;
-            set.add(py, PyBytes::new(py, path.as_bytes()))?;
-        }
-        Ok(set.into_object())
-    }
-
-    def non_normal_entries(&self) -> PyResult<NonNormalEntries> {
-        NonNormalEntries::from_inner(py, self.clone_ref(py))
-    }
-
-    def non_normal_entries_contains(&self, key: PyObject) -> PyResult<bool> {
-        let key = key.extract::<PyBytes>(py)?;
+        f: PyBytes,
+    ) -> PyResult<PyNone> {
         self.inner(py)
             .borrow_mut()
-            .non_normal_entries_contains(HgPath::new(key.data(py)))
-            .map_err(|e| v2_error(py, e))
-    }
-
-    def non_normal_entries_display(&self) -> PyResult<PyString> {
-        let mut inner = self.inner(py).borrow_mut();
-        let paths = inner
-            .iter_non_normal_paths()
-            .collect::<Result<Vec<_>, _>>()
-            .map_err(|e| v2_error(py, e))?;
-        let formatted = format!("NonNormalEntries: {}", hg::utils::join_display(paths, ", "));
-        Ok(PyString::new(py, &formatted))
-    }
-
-    def non_normal_entries_remove(&self, key: PyObject) -> PyResult<PyObject> {
-        let key = key.extract::<PyBytes>(py)?;
-        let key = key.data(py);
-        let was_present = self
-            .inner(py)
-            .borrow_mut()
-            .non_normal_entries_remove(HgPath::new(key));
-        if !was_present {
-            let msg = String::from_utf8_lossy(key);
-            Err(PyErr::new::<exc::KeyError, _>(py, msg))
-        } else {
-            Ok(py.None())
-        }
-    }
-
-    def non_normal_entries_discard(&self, key: PyObject) -> PyResult<PyObject>
-    {
-        let key = key.extract::<PyBytes>(py)?;
-        self
-            .inner(py)
-            .borrow_mut()
-            .non_normal_entries_remove(HgPath::new(key.data(py)));
-        Ok(py.None())
-    }
-
-    def non_normal_entries_add(&self, key: PyObject) -> PyResult<PyObject> {
-        let key = key.extract::<PyBytes>(py)?;
-        self
-            .inner(py)
-            .borrow_mut()
-            .non_normal_entries_add(HgPath::new(key.data(py)));
-        Ok(py.None())
-    }
-
-    def non_normal_or_other_parent_paths(&self) -> PyResult<PyList> {
-        let mut inner = self.inner(py).borrow_mut();
-
-        let ret = PyList::new(py, &[]);
-        for filename in inner.non_normal_or_other_parent_paths() {
-            let filename = filename.map_err(|e| v2_error(py, e))?;
-            let as_pystring = PyBytes::new(py, filename.as_bytes());
-            ret.append(py, as_pystring.into_object());
-        }
-        Ok(ret)
-    }
-
-    def non_normal_entries_iter(&self) -> PyResult<NonNormalEntriesIterator> {
-        // Make sure the sets are defined before we no longer have a mutable
-        // reference to the dmap.
-        self.inner(py)
-            .borrow_mut()
-            .set_non_normal_other_parent_entries(false);
-
-        let leaked_ref = self.inner(py).leak_immutable();
-
-        NonNormalEntriesIterator::from_inner(py, unsafe {
-            leaked_ref.map(py, |o| {
-                o.iter_non_normal_paths_panic()
-            })
-        })
+            .drop_entry_and_copy_source(HgPath::new(f.data(py)))
+            .map_err(|e |dirstate_error(py, e))?;
+        Ok(PyNone)
     }
 
     def hastrackeddir(&self, d: PyObject) -> PyResult<PyBool> {
@@ -360,9 +194,9 @@
         &self,
         p1: PyObject,
         p2: PyObject,
-        now: PyObject
+        now: (u32, u32)
     ) -> PyResult<PyBytes> {
-        let now = Timestamp(now.extract(py)?);
+        let now = timestamp(py, now)?;
 
         let mut inner = self.inner(py).borrow_mut();
         let parents = DirstateParents {
@@ -384,10 +218,10 @@
     /// instead of written to a new data file (False).
     def write_v2(
         &self,
-        now: PyObject,
+        now: (u32, u32),
         can_append: bool,
     ) -> PyResult<PyObject> {
-        let now = Timestamp(now.extract(py)?);
+        let now = timestamp(py, now)?;
 
         let mut inner = self.inner(py).borrow_mut();
         let result = inner.pack_v2(now, can_append);
@@ -409,7 +243,7 @@
         let dict = PyDict::new(py);
         for item in self.inner(py).borrow_mut().iter() {
             let (path, entry) = item.map_err(|e| v2_error(py, e))?;
-            if entry.state != EntryState::Removed {
+            if entry.state() != EntryState::Removed {
                 let key = normalize_case(path);
                 let value = path;
                 dict.set_item(
@@ -444,7 +278,7 @@
             .map_err(|e| v2_error(py, e))?
         {
             Some(entry) => {
-                Ok(make_dirstate_item(py, &entry)?)
+                Ok(DirstateItem::new_as_pyobject(py, entry)?)
             },
             None => Err(PyErr::new::<exc::KeyError, _>(
                 py,
@@ -566,7 +400,9 @@
             .copy_map_remove(HgPath::new(key.data(py)))
             .map_err(|e| v2_error(py, e))?
         {
-            Some(_) => Ok(None),
+            Some(copy) => Ok(Some(
+                PyBytes::new(py, copy.as_bytes()).into_object(),
+            )),
             None => Ok(default),
         }
     }
@@ -599,14 +435,14 @@
         Ok(dirs)
     }
 
-    def debug_iter(&self) -> PyResult<PyList> {
+    def debug_iter(&self, all: bool) -> PyResult<PyList> {
         let dirs = PyList::new(py, &[]);
-        for item in self.inner(py).borrow().debug_iter() {
+        for item in self.inner(py).borrow().debug_iter(all) {
             let (path, (state, mode, size, mtime)) =
                 item.map_err(|e| v2_error(py, e))?;
             let path = PyBytes::new(py, path.as_bytes());
-            let item = make_dirstate_item_raw(py, state, mode, size, mtime)?;
-            dirs.append(py, (path, item).to_py_object(py).into_object())
+            let item = (path, state, mode, size, mtime);
+            dirs.append(py, item.to_py_object(py).into_object())
         }
         Ok(dirs)
     }
@@ -616,7 +452,7 @@
     pub fn get_inner_mut<'a>(
         &'a self,
         py: Python<'a>,
-    ) -> RefMut<'a, Box<dyn DirstateMapMethods + Send>> {
+    ) -> RefMut<'a, OwningDirstateMap> {
         self.inner(py).borrow_mut()
     }
     fn translate_key(
@@ -633,7 +469,7 @@
         let (f, entry) = res.map_err(|e| v2_error(py, e))?;
         Ok(Some((
             PyBytes::new(py, f.as_bytes()),
-            make_dirstate_item(py, &entry)?,
+            DirstateItem::new_as_pyobject(py, entry)?,
         )))
     }
 }
--- a/rust/hg-cpython/src/dirstate/dispatch.rs	Thu Oct 21 14:03:33 2021 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,240 +0,0 @@
-use crate::dirstate::owning::OwningDirstateMap;
-use hg::dirstate::parsers::Timestamp;
-use hg::dirstate_tree::dispatch::DirstateMapMethods;
-use hg::dirstate_tree::on_disk::DirstateV2ParseError;
-use hg::matchers::Matcher;
-use hg::utils::hg_path::{HgPath, HgPathBuf};
-use hg::CopyMapIter;
-use hg::DirstateEntry;
-use hg::DirstateError;
-use hg::DirstateParents;
-use hg::DirstateStatus;
-use hg::PatternFileWarning;
-use hg::StateMapIter;
-use hg::StatusError;
-use hg::StatusOptions;
-use std::path::PathBuf;
-
-impl DirstateMapMethods for OwningDirstateMap {
-    fn clear(&mut self) {
-        self.get_mut().clear()
-    }
-
-    fn set_v1(&mut self, filename: &HgPath, entry: DirstateEntry) {
-        self.get_mut().set_v1(filename, entry)
-    }
-
-    fn add_file(
-        &mut self,
-        filename: &HgPath,
-        entry: DirstateEntry,
-        added: bool,
-        merged: bool,
-        from_p2: bool,
-        possibly_dirty: bool,
-    ) -> Result<(), DirstateError> {
-        self.get_mut().add_file(
-            filename,
-            entry,
-            added,
-            merged,
-            from_p2,
-            possibly_dirty,
-        )
-    }
-
-    fn remove_file(
-        &mut self,
-        filename: &HgPath,
-        in_merge: bool,
-    ) -> Result<(), DirstateError> {
-        self.get_mut().remove_file(filename, in_merge)
-    }
-
-    fn drop_file(&mut self, filename: &HgPath) -> Result<bool, DirstateError> {
-        self.get_mut().drop_file(filename)
-    }
-
-    fn clear_ambiguous_times(
-        &mut self,
-        filenames: Vec<HgPathBuf>,
-        now: i32,
-    ) -> Result<(), DirstateV2ParseError> {
-        self.get_mut().clear_ambiguous_times(filenames, now)
-    }
-
-    fn non_normal_entries_contains(
-        &mut self,
-        key: &HgPath,
-    ) -> Result<bool, DirstateV2ParseError> {
-        self.get_mut().non_normal_entries_contains(key)
-    }
-
-    fn non_normal_entries_remove(&mut self, key: &HgPath) -> bool {
-        self.get_mut().non_normal_entries_remove(key)
-    }
-
-    fn non_normal_entries_add(&mut self, key: &HgPath) {
-        self.get_mut().non_normal_entries_add(key)
-    }
-
-    fn non_normal_or_other_parent_paths(
-        &mut self,
-    ) -> Box<dyn Iterator<Item = Result<&HgPath, DirstateV2ParseError>> + '_>
-    {
-        self.get_mut().non_normal_or_other_parent_paths()
-    }
-
-    fn set_non_normal_other_parent_entries(&mut self, force: bool) {
-        self.get_mut().set_non_normal_other_parent_entries(force)
-    }
-
-    fn iter_non_normal_paths(
-        &mut self,
-    ) -> Box<
-        dyn Iterator<Item = Result<&HgPath, DirstateV2ParseError>> + Send + '_,
-    > {
-        self.get_mut().iter_non_normal_paths()
-    }
-
-    fn iter_non_normal_paths_panic(
-        &self,
-    ) -> Box<
-        dyn Iterator<Item = Result<&HgPath, DirstateV2ParseError>> + Send + '_,
-    > {
-        self.get().iter_non_normal_paths_panic()
-    }
-
-    fn iter_other_parent_paths(
-        &mut self,
-    ) -> Box<
-        dyn Iterator<Item = Result<&HgPath, DirstateV2ParseError>> + Send + '_,
-    > {
-        self.get_mut().iter_other_parent_paths()
-    }
-
-    fn has_tracked_dir(
-        &mut self,
-        directory: &HgPath,
-    ) -> Result<bool, DirstateError> {
-        self.get_mut().has_tracked_dir(directory)
-    }
-
-    fn has_dir(&mut self, directory: &HgPath) -> Result<bool, DirstateError> {
-        self.get_mut().has_dir(directory)
-    }
-
-    fn pack_v1(
-        &mut self,
-        parents: DirstateParents,
-        now: Timestamp,
-    ) -> Result<Vec<u8>, DirstateError> {
-        self.get_mut().pack_v1(parents, now)
-    }
-
-    fn pack_v2(
-        &mut self,
-        now: Timestamp,
-        can_append: bool,
-    ) -> Result<(Vec<u8>, Vec<u8>, bool), DirstateError> {
-        self.get_mut().pack_v2(now, can_append)
-    }
-
-    fn status<'a>(
-        &'a mut self,
-        matcher: &'a (dyn Matcher + Sync),
-        root_dir: PathBuf,
-        ignore_files: Vec<PathBuf>,
-        options: StatusOptions,
-    ) -> Result<(DirstateStatus<'a>, Vec<PatternFileWarning>), StatusError>
-    {
-        self.get_mut()
-            .status(matcher, root_dir, ignore_files, options)
-    }
-
-    fn copy_map_len(&self) -> usize {
-        self.get().copy_map_len()
-    }
-
-    fn copy_map_iter(&self) -> CopyMapIter<'_> {
-        self.get().copy_map_iter()
-    }
-
-    fn copy_map_contains_key(
-        &self,
-        key: &HgPath,
-    ) -> Result<bool, DirstateV2ParseError> {
-        self.get().copy_map_contains_key(key)
-    }
-
-    fn copy_map_get(
-        &self,
-        key: &HgPath,
-    ) -> Result<Option<&HgPath>, DirstateV2ParseError> {
-        self.get().copy_map_get(key)
-    }
-
-    fn copy_map_remove(
-        &mut self,
-        key: &HgPath,
-    ) -> Result<Option<HgPathBuf>, DirstateV2ParseError> {
-        self.get_mut().copy_map_remove(key)
-    }
-
-    fn copy_map_insert(
-        &mut self,
-        key: HgPathBuf,
-        value: HgPathBuf,
-    ) -> Result<Option<HgPathBuf>, DirstateV2ParseError> {
-        self.get_mut().copy_map_insert(key, value)
-    }
-
-    fn len(&self) -> usize {
-        self.get().len()
-    }
-
-    fn contains_key(
-        &self,
-        key: &HgPath,
-    ) -> Result<bool, DirstateV2ParseError> {
-        self.get().contains_key(key)
-    }
-
-    fn get(
-        &self,
-        key: &HgPath,
-    ) -> Result<Option<DirstateEntry>, DirstateV2ParseError> {
-        self.get().get(key)
-    }
-
-    fn iter(&self) -> StateMapIter<'_> {
-        self.get().iter()
-    }
-
-    fn iter_tracked_dirs(
-        &mut self,
-    ) -> Result<
-        Box<
-            dyn Iterator<Item = Result<&HgPath, DirstateV2ParseError>>
-                + Send
-                + '_,
-        >,
-        DirstateError,
-    > {
-        self.get_mut().iter_tracked_dirs()
-    }
-
-    fn debug_iter(
-        &self,
-    ) -> Box<
-        dyn Iterator<
-                Item = Result<
-                    (&HgPath, (u8, i32, i32, i32)),
-                    DirstateV2ParseError,
-                >,
-            > + Send
-            + '_,
-    > {
-        self.get().debug_iter()
-    }
-}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/rust/hg-cpython/src/dirstate/item.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -0,0 +1,286 @@
+use cpython::exc;
+use cpython::ObjectProtocol;
+use cpython::PyBytes;
+use cpython::PyErr;
+use cpython::PyNone;
+use cpython::PyObject;
+use cpython::PyResult;
+use cpython::Python;
+use cpython::PythonObject;
+use hg::dirstate::DirstateEntry;
+use hg::dirstate::EntryState;
+use hg::dirstate::TruncatedTimestamp;
+use std::cell::Cell;
+use std::convert::TryFrom;
+
+py_class!(pub class DirstateItem |py| {
+    data entry: Cell<DirstateEntry>;
+
+    def __new__(
+        _cls,
+        wc_tracked: bool = false,
+        p1_tracked: bool = false,
+        p2_info: bool = false,
+        has_meaningful_data: bool = true,
+        has_meaningful_mtime: bool = true,
+        parentfiledata: Option<(u32, u32, (u32, u32))> = None,
+        fallback_exec: Option<bool> = None,
+        fallback_symlink: Option<bool> = None,
+
+    ) -> PyResult<DirstateItem> {
+        let mut mode_size_opt = None;
+        let mut mtime_opt = None;
+        if let Some((mode, size, mtime)) = parentfiledata {
+            if has_meaningful_data {
+                mode_size_opt = Some((mode, size))
+            }
+            if has_meaningful_mtime {
+                mtime_opt = Some(timestamp(py, mtime)?)
+            }
+        }
+        let entry = DirstateEntry::from_v2_data(
+            wc_tracked,
+            p1_tracked,
+            p2_info,
+            mode_size_opt,
+            mtime_opt,
+            fallback_exec,
+            fallback_symlink,
+        );
+        DirstateItem::create_instance(py, Cell::new(entry))
+    }
+
+    @property
+    def state(&self) -> PyResult<PyBytes> {
+        let state_byte: u8 = self.entry(py).get().state().into();
+        Ok(PyBytes::new(py, &[state_byte]))
+    }
+
+    @property
+    def mode(&self) -> PyResult<i32> {
+        Ok(self.entry(py).get().mode())
+    }
+
+    @property
+    def size(&self) -> PyResult<i32> {
+        Ok(self.entry(py).get().size())
+    }
+
+    @property
+    def mtime(&self) -> PyResult<i32> {
+        Ok(self.entry(py).get().mtime())
+    }
+
+    @property
+    def has_fallback_exec(&self) -> PyResult<bool> {
+        match self.entry(py).get().get_fallback_exec() {
+            Some(_) => Ok(true),
+            None => Ok(false),
+        }
+    }
+
+    @property
+    def fallback_exec(&self) -> PyResult<Option<bool>> {
+        match self.entry(py).get().get_fallback_exec() {
+            Some(exec) => Ok(Some(exec)),
+            None => Ok(None),
+        }
+    }
+
+    @fallback_exec.setter
+    def set_fallback_exec(&self, value: Option<PyObject>) -> PyResult<()> {
+        match value {
+            None => {self.entry(py).get().set_fallback_exec(None);},
+            Some(value) => {
+            if value.is_none(py) {
+                self.entry(py).get().set_fallback_exec(None);
+            } else {
+                self.entry(py).get().set_fallback_exec(
+                    Some(value.is_true(py)?)
+                );
+            }},
+        }
+        Ok(())
+    }
+
+    @property
+    def has_fallback_symlink(&self) -> PyResult<bool> {
+        match self.entry(py).get().get_fallback_symlink() {
+            Some(_) => Ok(true),
+            None => Ok(false),
+        }
+    }
+
+    @property
+    def fallback_symlink(&self) -> PyResult<Option<bool>> {
+        match self.entry(py).get().get_fallback_symlink() {
+            Some(symlink) => Ok(Some(symlink)),
+            None => Ok(None),
+        }
+    }
+
+    @fallback_symlink.setter
+    def set_fallback_symlink(&self, value: Option<PyObject>) -> PyResult<()> {
+        match value {
+            None => {self.entry(py).get().set_fallback_symlink(None);},
+            Some(value) => {
+            if value.is_none(py) {
+                self.entry(py).get().set_fallback_symlink(None);
+            } else {
+                self.entry(py).get().set_fallback_symlink(
+                    Some(value.is_true(py)?)
+                );
+            }},
+        }
+        Ok(())
+    }
+
+    @property
+    def tracked(&self) -> PyResult<bool> {
+        Ok(self.entry(py).get().tracked())
+    }
+
+    @property
+    def p1_tracked(&self) -> PyResult<bool> {
+        Ok(self.entry(py).get().p1_tracked())
+    }
+
+    @property
+    def added(&self) -> PyResult<bool> {
+        Ok(self.entry(py).get().added())
+    }
+
+
+    @property
+    def p2_info(&self) -> PyResult<bool> {
+        Ok(self.entry(py).get().p2_info())
+    }
+
+    @property
+    def removed(&self) -> PyResult<bool> {
+        Ok(self.entry(py).get().removed())
+    }
+
+    @property
+    def maybe_clean(&self) -> PyResult<bool> {
+        Ok(self.entry(py).get().maybe_clean())
+    }
+
+    @property
+    def any_tracked(&self) -> PyResult<bool> {
+        Ok(self.entry(py).get().any_tracked())
+    }
+
+    def v1_state(&self) -> PyResult<PyBytes> {
+        let (state, _mode, _size, _mtime) = self.entry(py).get().v1_data();
+        let state_byte: u8 = state.into();
+        Ok(PyBytes::new(py, &[state_byte]))
+    }
+
+    def v1_mode(&self) -> PyResult<i32> {
+        let (_state, mode, _size, _mtime) = self.entry(py).get().v1_data();
+        Ok(mode)
+    }
+
+    def v1_size(&self) -> PyResult<i32> {
+        let (_state, _mode, size, _mtime) = self.entry(py).get().v1_data();
+        Ok(size)
+    }
+
+    def v1_mtime(&self) -> PyResult<i32> {
+        let (_state, _mode, _size, mtime) = self.entry(py).get().v1_data();
+        Ok(mtime)
+    }
+
+    def need_delay(&self, now: (u32, u32)) -> PyResult<bool> {
+        let now = timestamp(py, now)?;
+        Ok(self.entry(py).get().need_delay(now))
+    }
+
+    def mtime_likely_equal_to(&self, other: (u32, u32)) -> PyResult<bool> {
+        if let Some(mtime) = self.entry(py).get().truncated_mtime() {
+            Ok(mtime.likely_equal(timestamp(py, other)?))
+        } else {
+            Ok(false)
+        }
+    }
+
+    @classmethod
+    def from_v1_data(
+        _cls,
+        state: PyBytes,
+        mode: i32,
+        size: i32,
+        mtime: i32,
+    ) -> PyResult<Self> {
+        let state = <[u8; 1]>::try_from(state.data(py))
+            .ok()
+            .and_then(|state| EntryState::try_from(state[0]).ok())
+            .ok_or_else(|| PyErr::new::<exc::ValueError, _>(py, "invalid state"))?;
+        let entry = DirstateEntry::from_v1_data(state, mode, size, mtime);
+        DirstateItem::create_instance(py, Cell::new(entry))
+    }
+
+    def drop_merge_data(&self) -> PyResult<PyNone> {
+        self.update(py, |entry| entry.drop_merge_data());
+        Ok(PyNone)
+    }
+
+    def set_clean(
+        &self,
+        mode: u32,
+        size: u32,
+        mtime: (u32, u32),
+    ) -> PyResult<PyNone> {
+        let mtime = timestamp(py, mtime)?;
+        self.update(py, |entry| entry.set_clean(mode, size, mtime));
+        Ok(PyNone)
+    }
+
+    def set_possibly_dirty(&self) -> PyResult<PyNone> {
+        self.update(py, |entry| entry.set_possibly_dirty());
+        Ok(PyNone)
+    }
+
+    def set_tracked(&self) -> PyResult<PyNone> {
+        self.update(py, |entry| entry.set_tracked());
+        Ok(PyNone)
+    }
+
+    def set_untracked(&self) -> PyResult<PyNone> {
+        self.update(py, |entry| entry.set_untracked());
+        Ok(PyNone)
+    }
+});
+
+impl DirstateItem {
+    pub fn new_as_pyobject(
+        py: Python<'_>,
+        entry: DirstateEntry,
+    ) -> PyResult<PyObject> {
+        Ok(DirstateItem::create_instance(py, Cell::new(entry))?.into_object())
+    }
+
+    pub fn get_entry(&self, py: Python<'_>) -> DirstateEntry {
+        self.entry(py).get()
+    }
+
+    // TODO: Use https://doc.rust-lang.org/std/cell/struct.Cell.html#method.update instead when it’s stable
+    pub fn update(&self, py: Python<'_>, f: impl FnOnce(&mut DirstateEntry)) {
+        let mut entry = self.entry(py).get();
+        f(&mut entry);
+        self.entry(py).set(entry)
+    }
+}
+
+pub(crate) fn timestamp(
+    py: Python<'_>,
+    (s, ns): (u32, u32),
+) -> PyResult<TruncatedTimestamp> {
+    TruncatedTimestamp::from_already_truncated(s, ns).map_err(|_| {
+        PyErr::new::<exc::ValueError, _>(
+            py,
+            "expected mtime truncated to 31 bits",
+        )
+    })
+}
--- a/rust/hg-cpython/src/dirstate/non_normal_entries.rs	Thu Oct 21 14:03:33 2021 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,83 +0,0 @@
-// non_normal_other_parent_entries.rs
-//
-// Copyright 2020 Raphaël Gomès <rgomes@octobus.net>
-//
-// This software may be used and distributed according to the terms of the
-// GNU General Public License version 2 or any later version.
-
-use cpython::{
-    exc::NotImplementedError, CompareOp, ObjectProtocol, PyBytes, PyClone,
-    PyErr, PyObject, PyResult, PyString, Python, PythonObject, ToPyObject,
-    UnsafePyLeaked,
-};
-
-use crate::dirstate::dirstate_map::v2_error;
-use crate::dirstate::DirstateMap;
-use hg::dirstate_tree::on_disk::DirstateV2ParseError;
-use hg::utils::hg_path::HgPath;
-use std::cell::RefCell;
-
-py_class!(pub class NonNormalEntries |py| {
-    data dmap: DirstateMap;
-
-    def __contains__(&self, key: PyObject) -> PyResult<bool> {
-        self.dmap(py).non_normal_entries_contains(py, key)
-    }
-    def remove(&self, key: PyObject) -> PyResult<PyObject> {
-        self.dmap(py).non_normal_entries_remove(py, key)
-    }
-    def add(&self, key: PyObject) -> PyResult<PyObject> {
-        self.dmap(py).non_normal_entries_add(py, key)
-    }
-    def discard(&self, key: PyObject) -> PyResult<PyObject> {
-        self.dmap(py).non_normal_entries_discard(py, key)
-    }
-    def __richcmp__(&self, other: PyObject, op: CompareOp) -> PyResult<bool> {
-        match op {
-            CompareOp::Eq => self.is_equal_to(py, other),
-            CompareOp::Ne => Ok(!self.is_equal_to(py, other)?),
-            _ => Err(PyErr::new::<NotImplementedError, _>(py, ""))
-        }
-    }
-    def __repr__(&self) -> PyResult<PyString> {
-        self.dmap(py).non_normal_entries_display(py)
-    }
-
-    def __iter__(&self) -> PyResult<NonNormalEntriesIterator> {
-        self.dmap(py).non_normal_entries_iter(py)
-    }
-});
-
-impl NonNormalEntries {
-    pub fn from_inner(py: Python, dm: DirstateMap) -> PyResult<Self> {
-        Self::create_instance(py, dm)
-    }
-
-    fn is_equal_to(&self, py: Python, other: PyObject) -> PyResult<bool> {
-        for item in other.iter(py)? {
-            if !self.dmap(py).non_normal_entries_contains(py, item?)? {
-                return Ok(false);
-            }
-        }
-        Ok(true)
-    }
-
-    fn translate_key(
-        py: Python,
-        key: Result<&HgPath, DirstateV2ParseError>,
-    ) -> PyResult<Option<PyBytes>> {
-        let key = key.map_err(|e| v2_error(py, e))?;
-        Ok(Some(PyBytes::new(py, key.as_bytes())))
-    }
-}
-
-type NonNormalEntriesIter<'a> = Box<
-    dyn Iterator<Item = Result<&'a HgPath, DirstateV2ParseError>> + Send + 'a,
->;
-
-py_shared_iterator!(
-    NonNormalEntriesIterator,
-    UnsafePyLeaked<NonNormalEntriesIter<'static>>,
-    NonNormalEntries::translate_key,
-    Option<PyBytes>
-);
--- a/rust/hg-cpython/src/dirstate/owning.rs	Thu Oct 21 14:03:33 2021 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,117 +0,0 @@
-use cpython::PyBytes;
-use cpython::Python;
-use hg::dirstate_tree::dirstate_map::DirstateMap;
-use hg::DirstateError;
-use hg::DirstateParents;
-
-/// Keep a `DirstateMap<'on_disk>` next to the `on_disk` buffer that it
-/// borrows. This is similar to the owning-ref crate.
-///
-/// This is similar to [`OwningRef`] which is more limited because it
-/// represents exactly one `&T` reference next to the value it borrows, as
-/// opposed to a struct that may contain an arbitrary number of references in
-/// arbitrarily-nested data structures.
-///
-/// [`OwningRef`]: https://docs.rs/owning_ref/0.4.1/owning_ref/struct.OwningRef.html
-pub(super) struct OwningDirstateMap {
-    /// Owned handle to a bytes buffer with a stable address.
-    ///
-    /// See <https://docs.rs/owning_ref/0.4.1/owning_ref/trait.StableAddress.html>.
-    on_disk: PyBytes,
-
-    /// Pointer for `Box<DirstateMap<'on_disk>>`, typed-erased because the
-    /// language cannot represent a lifetime referencing a sibling field.
-    /// This is not quite a self-referencial struct (moving this struct is not
-    /// a problem as it doesn’t change the address of the bytes buffer owned
-    /// by `PyBytes`) but touches similar borrow-checker limitations.
-    ptr: *mut (),
-}
-
-impl OwningDirstateMap {
-    pub fn new_v1(
-        py: Python,
-        on_disk: PyBytes,
-    ) -> Result<(Self, Option<DirstateParents>), DirstateError> {
-        let bytes: &'_ [u8] = on_disk.data(py);
-        let (map, parents) = DirstateMap::new_v1(bytes)?;
-
-        // Like in `bytes` above, this `'_` lifetime parameter borrows from
-        // the bytes buffer owned by `on_disk`.
-        let ptr: *mut DirstateMap<'_> = Box::into_raw(Box::new(map));
-
-        // Erase the pointed type entirely in order to erase the lifetime.
-        let ptr: *mut () = ptr.cast();
-
-        Ok((Self { on_disk, ptr }, parents))
-    }
-
-    pub fn new_v2(
-        py: Python,
-        on_disk: PyBytes,
-        data_size: usize,
-        tree_metadata: PyBytes,
-    ) -> Result<Self, DirstateError> {
-        let bytes: &'_ [u8] = on_disk.data(py);
-        let map =
-            DirstateMap::new_v2(bytes, data_size, tree_metadata.data(py))?;
-
-        // Like in `bytes` above, this `'_` lifetime parameter borrows from
-        // the bytes buffer owned by `on_disk`.
-        let ptr: *mut DirstateMap<'_> = Box::into_raw(Box::new(map));
-
-        // Erase the pointed type entirely in order to erase the lifetime.
-        let ptr: *mut () = ptr.cast();
-
-        Ok(Self { on_disk, ptr })
-    }
-
-    pub fn get_mut<'a>(&'a mut self) -> &'a mut DirstateMap<'a> {
-        // SAFETY: We cast the type-erased pointer back to the same type it had
-        // in `new`, except with a different lifetime parameter. This time we
-        // connect the lifetime to that of `self`. This cast is valid because
-        // `self` owns the same `PyBytes` whose buffer `DirstateMap`
-        // references. That buffer has a stable memory address because the byte
-        // string value of a `PyBytes` is immutable.
-        let ptr: *mut DirstateMap<'a> = self.ptr.cast();
-        // SAFETY: we dereference that pointer, connecting the lifetime of the
-        // new   `&mut` to that of `self`. This is valid because the
-        // raw pointer is   to a boxed value, and `self` owns that box.
-        unsafe { &mut *ptr }
-    }
-
-    pub fn get<'a>(&'a self) -> &'a DirstateMap<'a> {
-        // SAFETY: same reasoning as in `get_mut` above.
-        let ptr: *mut DirstateMap<'a> = self.ptr.cast();
-        unsafe { &*ptr }
-    }
-}
-
-impl Drop for OwningDirstateMap {
-    fn drop(&mut self) {
-        // Silence a "field is never read" warning, and demonstrate that this
-        // value is still alive.
-        let _ = &self.on_disk;
-        // SAFETY: this cast is the same as in `get_mut`, and is valid for the
-        // same reason. `self.on_disk` still exists at this point, drop glue
-        // will drop it implicitly after this `drop` method returns.
-        let ptr: *mut DirstateMap<'_> = self.ptr.cast();
-        // SAFETY: `Box::from_raw` takes ownership of the box away from `self`.
-        // This is fine because drop glue does nothig for `*mut ()` and we’re
-        // in `drop`, so `get` and `get_mut` cannot be called again.
-        unsafe { drop(Box::from_raw(ptr)) }
-    }
-}
-
-fn _static_assert_is_send<T: Send>() {}
-
-fn _static_assert_fields_are_send() {
-    _static_assert_is_send::<PyBytes>();
-    _static_assert_is_send::<Box<DirstateMap<'_>>>();
-}
-
-// SAFETY: we don’t get this impl implicitly because `*mut (): !Send` because
-// thread-safety of raw pointers is unknown in the general case. However this
-// particular raw pointer represents a `Box<DirstateMap<'on_disk>>` that we
-// own. Since that `Box` and `PyBytes` are both `Send` as shown in above, it
-// is sound to mark this struct as `Send` too.
-unsafe impl Send for OwningDirstateMap {}
--- a/rust/hg-cpython/src/dirstate/status.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-cpython/src/dirstate/status.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -9,6 +9,7 @@
 //! `hg-core` crate. From Python, this will be seen as
 //! `rustext.dirstate.status`.
 
+use crate::dirstate::item::timestamp;
 use crate::{dirstate::DirstateMap, exceptions::FallbackError};
 use cpython::exc::OSError;
 use cpython::{
@@ -102,12 +103,13 @@
     root_dir: PyObject,
     ignore_files: PyList,
     check_exec: bool,
-    last_normal_time: i64,
+    last_normal_time: (u32, u32),
     list_clean: bool,
     list_ignored: bool,
     list_unknown: bool,
     collect_traversed_dirs: bool,
 ) -> PyResult<PyTuple> {
+    let last_normal_time = timestamp(py, last_normal_time)?;
     let bytes = root_dir.extract::<PyBytes>(py)?;
     let root_dir = get_path_from_bytes(bytes.data(py));
 
--- a/rust/hg-cpython/src/lib.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hg-cpython/src/lib.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -35,7 +35,7 @@
 pub mod dirstate;
 pub mod discovery;
 pub mod exceptions;
-pub mod parsers;
+mod pybytes_deref;
 pub mod revlog;
 pub mod utils;
 
@@ -58,11 +58,6 @@
     m.add(py, "discovery", discovery::init_module(py, &dotted_name)?)?;
     m.add(py, "dirstate", dirstate::init_module(py, &dotted_name)?)?;
     m.add(py, "revlog", revlog::init_module(py, &dotted_name)?)?;
-    m.add(
-        py,
-        "parsers",
-        parsers::init_parsers_module(py, &dotted_name)?,
-    )?;
     m.add(py, "GraphError", py.get_type::<exceptions::GraphError>())?;
     Ok(())
 });
--- a/rust/hg-cpython/src/parsers.rs	Thu Oct 21 14:03:33 2021 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,163 +0,0 @@
-// parsers.rs
-//
-// Copyright 2019 Raphaël Gomès <rgomes@octobus.net>
-//
-// This software may be used and distributed according to the terms of the
-// GNU General Public License version 2 or any later version.
-
-//! Bindings for the `hg::dirstate::parsers` module provided by the
-//! `hg-core` package.
-//!
-//! From Python, this will be seen as `mercurial.rustext.parsers`
-use cpython::{
-    exc, PyBytes, PyDict, PyErr, PyInt, PyModule, PyResult, PyTuple, Python,
-    PythonObject, ToPyObject,
-};
-use hg::{
-    dirstate::parsers::Timestamp, pack_dirstate, parse_dirstate,
-    utils::hg_path::HgPathBuf, DirstateEntry, DirstateParents, FastHashMap,
-    PARENT_SIZE,
-};
-use std::convert::TryInto;
-
-use crate::dirstate::{extract_dirstate, make_dirstate_item};
-
-fn parse_dirstate_wrapper(
-    py: Python,
-    dmap: PyDict,
-    copymap: PyDict,
-    st: PyBytes,
-) -> PyResult<PyTuple> {
-    match parse_dirstate(st.data(py)) {
-        Ok((parents, entries, copies)) => {
-            let dirstate_map: FastHashMap<HgPathBuf, DirstateEntry> = entries
-                .into_iter()
-                .map(|(path, entry)| (path.to_owned(), entry))
-                .collect();
-            let copy_map: FastHashMap<HgPathBuf, HgPathBuf> = copies
-                .into_iter()
-                .map(|(path, copy)| (path.to_owned(), copy.to_owned()))
-                .collect();
-
-            for (filename, entry) in &dirstate_map {
-                dmap.set_item(
-                    py,
-                    PyBytes::new(py, filename.as_bytes()),
-                    make_dirstate_item(py, entry)?,
-                )?;
-            }
-            for (path, copy_path) in copy_map {
-                copymap.set_item(
-                    py,
-                    PyBytes::new(py, path.as_bytes()),
-                    PyBytes::new(py, copy_path.as_bytes()),
-                )?;
-            }
-            Ok(dirstate_parents_to_pytuple(py, parents))
-        }
-        Err(e) => Err(PyErr::new::<exc::ValueError, _>(py, e.to_string())),
-    }
-}
-
-fn pack_dirstate_wrapper(
-    py: Python,
-    dmap: PyDict,
-    copymap: PyDict,
-    pl: PyTuple,
-    now: PyInt,
-) -> PyResult<PyBytes> {
-    let p1 = pl.get_item(py, 0).extract::<PyBytes>(py)?;
-    let p1: &[u8] = p1.data(py);
-    let p2 = pl.get_item(py, 1).extract::<PyBytes>(py)?;
-    let p2: &[u8] = p2.data(py);
-
-    let mut dirstate_map = extract_dirstate(py, &dmap)?;
-
-    let copies: Result<FastHashMap<HgPathBuf, HgPathBuf>, PyErr> = copymap
-        .items(py)
-        .iter()
-        .map(|(key, value)| {
-            Ok((
-                HgPathBuf::from_bytes(key.extract::<PyBytes>(py)?.data(py)),
-                HgPathBuf::from_bytes(value.extract::<PyBytes>(py)?.data(py)),
-            ))
-        })
-        .collect();
-
-    if p1.len() != PARENT_SIZE || p2.len() != PARENT_SIZE {
-        return Err(PyErr::new::<exc::ValueError, _>(
-            py,
-            "expected a 20-byte hash".to_string(),
-        ));
-    }
-
-    match pack_dirstate(
-        &mut dirstate_map,
-        &copies?,
-        DirstateParents {
-            p1: p1.try_into().unwrap(),
-            p2: p2.try_into().unwrap(),
-        },
-        Timestamp(now.as_object().extract::<i64>(py)?),
-    ) {
-        Ok(packed) => {
-            for (filename, entry) in dirstate_map.iter() {
-                dmap.set_item(
-                    py,
-                    PyBytes::new(py, filename.as_bytes()),
-                    make_dirstate_item(py, &entry)?,
-                )?;
-            }
-            Ok(PyBytes::new(py, &packed))
-        }
-        Err(error) => {
-            Err(PyErr::new::<exc::ValueError, _>(py, error.to_string()))
-        }
-    }
-}
-
-/// Create the module, with `__package__` given from parent
-pub fn init_parsers_module(py: Python, package: &str) -> PyResult<PyModule> {
-    let dotted_name = &format!("{}.parsers", package);
-    let m = PyModule::new(py, dotted_name)?;
-
-    m.add(py, "__package__", package)?;
-    m.add(py, "__doc__", "Parsers - Rust implementation")?;
-
-    m.add(
-        py,
-        "parse_dirstate",
-        py_fn!(
-            py,
-            parse_dirstate_wrapper(dmap: PyDict, copymap: PyDict, st: PyBytes)
-        ),
-    )?;
-    m.add(
-        py,
-        "pack_dirstate",
-        py_fn!(
-            py,
-            pack_dirstate_wrapper(
-                dmap: PyDict,
-                copymap: PyDict,
-                pl: PyTuple,
-                now: PyInt
-            )
-        ),
-    )?;
-
-    let sys = PyModule::import(py, "sys")?;
-    let sys_modules: PyDict = sys.get(py, "modules")?.extract(py)?;
-    sys_modules.set_item(py, dotted_name, &m)?;
-
-    Ok(m)
-}
-
-pub(crate) fn dirstate_parents_to_pytuple(
-    py: Python,
-    parents: &DirstateParents,
-) -> PyTuple {
-    let p1 = PyBytes::new(py, parents.p1.as_bytes());
-    let p2 = PyBytes::new(py, parents.p2.as_bytes());
-    (p1, p2).to_py_object(py)
-}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/rust/hg-cpython/src/pybytes_deref.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -0,0 +1,56 @@
+use cpython::{PyBytes, Python};
+use stable_deref_trait::StableDeref;
+
+/// Safe abstraction over a `PyBytes` together with the `&[u8]` slice
+/// that borrows it. Implements `Deref<Target = [u8]>`.
+///
+/// Calling `PyBytes::data` requires a GIL marker but we want to access the
+/// data in a thread that (ideally) does not need to acquire the GIL.
+/// This type allows separating the call an the use.
+///
+/// It also enables using a (wrapped) `PyBytes` in GIL-unaware generic code.
+pub struct PyBytesDeref {
+    #[allow(unused)]
+    keep_alive: PyBytes,
+
+    /// Borrows the buffer inside `self.keep_alive`,
+    /// but the borrow-checker cannot express self-referential structs.
+    data: *const [u8],
+}
+
+impl PyBytesDeref {
+    pub fn new(py: Python, bytes: PyBytes) -> Self {
+        Self {
+            data: bytes.data(py),
+            keep_alive: bytes,
+        }
+    }
+
+    pub fn unwrap(self) -> PyBytes {
+        self.keep_alive
+    }
+}
+
+impl std::ops::Deref for PyBytesDeref {
+    type Target = [u8];
+
+    fn deref(&self) -> &[u8] {
+        // Safety: the raw pointer is valid as long as the PyBytes is still
+        // alive, and the returned slice borrows `self`.
+        unsafe { &*self.data }
+    }
+}
+
+unsafe impl StableDeref for PyBytesDeref {}
+
+fn require_send<T: Send>() {}
+
+#[allow(unused)]
+fn static_assert_pybytes_is_send() {
+    require_send::<PyBytes>;
+}
+
+// Safety: PyBytes is Send. Raw pointers are not by default,
+// but here sending one to another thread is fine since we ensure it stays
+// valid.
+unsafe impl Send for PyBytesDeref {}
--- a/rust/hgcli/README.md	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hgcli/README.md	Thu Oct 21 17:25:41 2021 +0200
@@ -12,23 +12,21 @@
 
 # Building
 
-This project currently requires an unreleased version of PyOxidizer
-(0.7.0-pre). For best results, build the exact PyOxidizer commit
-as defined in the `pyoxidizer.bzl` file:
+First, acquire and build a copy of PyOxidizer; you probably want to do this in
+some directory outside of your clone of Mercurial:
 
     $ git clone https://github.com/indygreg/PyOxidizer.git
     $ cd PyOxidizer
-    $ git checkout <Git commit from pyoxidizer.bzl>
     $ cargo build --release
 
-Then build this Rust project using the built `pyoxidizer` executable::
+Then build this Rust project using the built `pyoxidizer` executable:
 
-    $ /path/to/pyoxidizer/target/release/pyoxidizer build
+    $ /path/to/pyoxidizer/target/release/pyoxidizer build --release
 
 If all goes according to plan, there should be an assembled application
-under `build/<arch>/debug/app/` with an `hg` executable:
+under `build/<arch>/release/app/` with an `hg` executable:
 
-    $ build/x86_64-unknown-linux-gnu/debug/app/hg version
+    $ build/x86_64-unknown-linux-gnu/release/app/hg version
     Mercurial Distributed SCM (version 5.3.1+433-f99cd77d53dc+20200331)
     (see https://mercurial-scm.org for more information)
 
@@ -46,5 +44,5 @@
 to the Mercurial source directory. e.g.:
 
     $ cd /path/to/hg/src/tests
-    $ PYTHONPATH=`pwd`/.. python3.7 run-tests.py \
-        --with-hg `pwd`/../rust/hgcli/build/x86_64-unknown-linux-gnu/debug/app/hg
+    $ PYTHONPATH=`pwd`/.. python3.9 run-tests.py \
+        --with-hg `pwd`/../rust/hgcli/build/x86_64-unknown-linux-gnu/release/app/hg
--- a/rust/hgcli/pyoxidizer.bzl	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/hgcli/pyoxidizer.bzl	Thu Oct 21 17:25:41 2021 +0200
@@ -24,7 +24,7 @@
 
 ROOT = CWD + "/../.."
 
-VERSION = VARS.get("VERSION", "5.8")
+VERSION = VARS.get("VERSION", "0.0")
 MSI_NAME = VARS.get("MSI_NAME", "mercurial")
 EXTRA_MSI_FEATURES = VARS.get("EXTRA_MSI_FEATURES")
 SIGNING_PFX_PATH = VARS.get("SIGNING_PFX_PATH")
@@ -34,6 +34,8 @@
 
 IS_WINDOWS = "windows" in BUILD_TARGET_TRIPLE
 
+USE_IN_MEMORY_RESOURCES = False
+
 # Code to run in Python interpreter.
 RUN_CODE = """
 import os
@@ -57,6 +59,20 @@
                 'site-packages',
             )
         )
+elif sys.platform == "darwin":
+    vi = sys.version_info
+
+    def joinuser(*args):
+        return os.path.expanduser(os.path.join(*args))
+
+    # Note: site.py uses `sys._framework` instead of hardcoding "Python" as the
+    #   3rd arg, but that is set to an empty string in an oxidized binary.  It
+    #   has a fallback to ~/.local when `sys._framework` isn't set, but we want
+    #   to match what the system python uses, so it sees pip installed stuff.
+    usersite = joinuser("~", "Library", "Python",
+                        "%d.%d" % vi[:2], "lib/python/site-packages")
+
+    sys.path.append(usersite)
 import hgdemandimport;
 hgdemandimport.enable();
 from mercurial import dispatch;
@@ -69,7 +85,7 @@
     return default_python_distribution(python_version = "3.9")
 
 def resource_callback(policy, resource):
-    if not IS_WINDOWS:
+    if USE_IN_MEMORY_RESOURCES:
         resource.add_location = "in-memory"
         return
 
@@ -100,7 +116,7 @@
     # extensions.
     packaging_policy.extension_module_filter = "all"
     packaging_policy.resources_location = "in-memory"
-    if IS_WINDOWS:
+    if not USE_IN_MEMORY_RESOURCES:
         packaging_policy.resources_location_fallback = "filesystem-relative:lib"
     packaging_policy.register_resource_callback(resource_callback)
 
--- a/rust/rhg/src/commands/cat.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/rhg/src/commands/cat.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -16,7 +16,7 @@
             Arg::with_name("rev")
                 .help("search the repository as it is in REV")
                 .short("-r")
-                .long("--revision")
+                .long("--rev")
                 .value_name("REV")
                 .takes_value(true),
         )
@@ -26,7 +26,7 @@
                 .multiple(true)
                 .empty_values(false)
                 .value_name("FILE")
-                .help("Activity to start: activity@category"),
+                .help("Files to output"),
         )
         .about(HELP_TEXT)
 }
@@ -46,8 +46,18 @@
 
     let mut files = vec![];
     for file in file_args.iter() {
+        if file.starts_with("set:") {
+            let message = "fileset";
+            return Err(CommandError::unsupported(message));
+        }
+
+        let normalized = cwd.join(&file);
         // TODO: actually normalize `..` path segments etc?
-        let normalized = cwd.join(&file);
+        let dotted = normalized.components().any(|c| c.as_os_str() == "..");
+        if file == &"." || dotted {
+            let message = "`..` or `.` path segment";
+            return Err(CommandError::unsupported(message));
+        }
         let stripped = normalized
             .strip_prefix(&working_directory)
             // TODO: error message for path arguments outside of the repo
@@ -56,29 +66,31 @@
             .map_err(|e| CommandError::abort(e.to_string()))?;
         files.push(hg_file);
     }
+    let files = files.iter().map(|file| file.as_ref()).collect();
+    // TODO probably move this to a util function like `repo.default_rev` or
+    // something when it's used somewhere else
+    let rev = match rev {
+        Some(r) => r.to_string(),
+        None => format!("{:x}", repo.dirstate_parents()?.p1),
+    };
 
-    match rev {
-        Some(rev) => {
-            let output = cat(&repo, rev, &files).map_err(|e| (e, rev))?;
-            invocation.ui.write_stdout(&output.concatenated)?;
-            if !output.missing.is_empty() {
-                let short = format!("{:x}", output.node.short()).into_bytes();
-                for path in &output.missing {
-                    invocation.ui.write_stderr(&format_bytes!(
-                        b"{}: no such file in rev {}\n",
-                        path.as_bytes(),
-                        short
-                    ))?;
-                }
-            }
-            if output.found_any {
-                Ok(())
-            } else {
-                Err(CommandError::Unsuccessful)
-            }
+    let output = cat(&repo, &rev, files).map_err(|e| (e, rev.as_str()))?;
+    for (_file, contents) in output.results {
+        invocation.ui.write_stdout(&contents)?;
+    }
+    if !output.missing.is_empty() {
+        let short = format!("{:x}", output.node.short()).into_bytes();
+        for path in &output.missing {
+            invocation.ui.write_stderr(&format_bytes!(
+                b"{}: no such file in rev {}\n",
+                path.as_bytes(),
+                short
+            ))?;
         }
-        None => Err(CommandError::unsupported(
-            "`rhg cat` without `--rev` / `-r`",
-        )),
+    }
+    if output.found_any {
+        Ok(())
+    } else {
+        Err(CommandError::Unsuccessful)
     }
 }
--- a/rust/rhg/src/commands/files.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/rhg/src/commands/files.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -1,12 +1,13 @@
 use crate::error::CommandError;
 use crate::ui::Ui;
+use crate::ui::UiError;
+use crate::utils::path_utils::relativize_paths;
 use clap::Arg;
 use hg::operations::list_rev_tracked_files;
 use hg::operations::Dirstate;
 use hg::repo::Repo;
-use hg::utils::current_dir;
-use hg::utils::files::{get_bytes_from_path, relativize_path};
-use hg::utils::hg_path::{HgPath, HgPathBuf};
+use hg::utils::hg_path::HgPath;
+use std::borrow::Cow;
 
 pub const HELP_TEXT: &str = "
 List tracked files.
@@ -54,34 +55,13 @@
     files: impl IntoIterator<Item = &'a HgPath>,
 ) -> Result<(), CommandError> {
     let mut stdout = ui.stdout_buffer();
-
-    let cwd = current_dir()?;
-    let working_directory = repo.working_directory_path();
-    let working_directory = cwd.join(working_directory); // Make it absolute
+    let mut any = false;
 
-    let mut any = false;
-    if let Ok(cwd_relative_to_repo) = cwd.strip_prefix(&working_directory) {
-        // The current directory is inside the repo, so we can work with
-        // relative paths
-        let cwd = HgPathBuf::from(get_bytes_from_path(cwd_relative_to_repo));
-        for file in files {
-            any = true;
-            stdout.write_all(relativize_path(&file, &cwd).as_ref())?;
-            stdout.write_all(b"\n")?;
-        }
-    } else {
-        let working_directory =
-            HgPathBuf::from(get_bytes_from_path(working_directory));
-        let cwd = HgPathBuf::from(get_bytes_from_path(cwd));
-        for file in files {
-            any = true;
-            // Absolute path in the filesystem
-            let file = working_directory.join(file);
-            stdout.write_all(relativize_path(&file, &cwd).as_ref())?;
-            stdout.write_all(b"\n")?;
-        }
-    }
-
+    relativize_paths(repo, files, |path: Cow<[u8]>| -> Result<(), UiError> {
+        any = true;
+        stdout.write_all(path.as_ref())?;
+        stdout.write_all(b"\n")
+    })?;
     stdout.flush()?;
     if any {
         Ok(())
--- a/rust/rhg/src/commands/status.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/rhg/src/commands/status.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -6,25 +6,20 @@
 // GNU General Public License version 2 or any later version.
 
 use crate::error::CommandError;
-use crate::ui::Ui;
+use crate::ui::{Ui, UiError};
+use crate::utils::path_utils::relativize_paths;
 use clap::{Arg, SubCommand};
 use hg;
-use hg::dirstate_tree::dirstate_map::DirstateMap;
-use hg::dirstate_tree::on_disk;
-use hg::errors::HgResultExt;
-use hg::errors::IoResultExt;
+use hg::config::Config;
+use hg::dirstate::TruncatedTimestamp;
+use hg::errors::HgError;
+use hg::manifest::Manifest;
 use hg::matchers::AlwaysMatcher;
-use hg::operations::cat;
 use hg::repo::Repo;
-use hg::revlog::node::Node;
 use hg::utils::hg_path::{hg_path_to_os_string, HgPath};
-use hg::StatusError;
 use hg::{HgPathCow, StatusOptions};
 use log::{info, warn};
-use std::convert::TryInto;
-use std::fs;
-use std::io::BufReader;
-use std::io::Read;
+use std::borrow::Cow;
 
 pub const HELP_TEXT: &str = "
 Show changed files in the working directory
@@ -142,7 +137,20 @@
         ));
     }
 
+    // TODO: lift these limitations
+    if invocation.config.get_bool(b"ui", b"tweakdefaults").ok() == Some(true) {
+        return Err(CommandError::unsupported(
+            "ui.tweakdefaults is not yet supported with rhg status",
+        ));
+    }
+    if invocation.config.get_bool(b"ui", b"statuscopies").ok() == Some(true) {
+        return Err(CommandError::unsupported(
+            "ui.statuscopies is not yet supported with rhg status",
+        ));
+    }
+
     let ui = invocation.ui;
+    let config = invocation.config;
     let args = invocation.subcommand_args;
     let display_states = if args.is_present("all") {
         // TODO when implementing `--quiet`: it excludes clean files
@@ -166,47 +174,14 @@
     };
 
     let repo = invocation.repo?;
-    let dirstate_data_mmap;
-    let (mut dmap, parents) = if repo.has_dirstate_v2() {
-        let docket_data =
-            repo.hg_vfs().read("dirstate").io_not_found_as_none()?;
-        let parents;
-        let dirstate_data;
-        let data_size;
-        let docket;
-        let tree_metadata;
-        if let Some(docket_data) = &docket_data {
-            docket = on_disk::read_docket(docket_data)?;
-            tree_metadata = docket.tree_metadata();
-            parents = Some(docket.parents());
-            data_size = docket.data_size();
-            dirstate_data_mmap = repo
-                .hg_vfs()
-                .mmap_open(docket.data_filename())
-                .io_not_found_as_none()?;
-            dirstate_data = dirstate_data_mmap.as_deref().unwrap_or(b"");
-        } else {
-            parents = None;
-            tree_metadata = b"";
-            data_size = 0;
-            dirstate_data = b"";
-        }
-        let dmap =
-            DirstateMap::new_v2(dirstate_data, data_size, tree_metadata)?;
-        (dmap, parents)
-    } else {
-        dirstate_data_mmap =
-            repo.hg_vfs().mmap_open("dirstate").io_not_found_as_none()?;
-        let dirstate_data = dirstate_data_mmap.as_deref().unwrap_or(b"");
-        DirstateMap::new_v1(dirstate_data)?
-    };
+    let mut dmap = repo.dirstate_map_mut()?;
 
     let options = StatusOptions {
         // TODO should be provided by the dirstate parsing and
         // hence be stored on dmap. Using a value that assumes we aren't
         // below the time resolution granularity of the FS and the
         // dirstate.
-        last_normal_time: 0,
+        last_normal_time: TruncatedTimestamp::new_truncate(0, 0),
         // we're currently supporting file systems with exec flags only
         // anyway
         check_exec: true,
@@ -216,8 +191,7 @@
         collect_traversed_dirs: false,
     };
     let ignore_file = repo.working_directory_vfs().join(".hgignore"); // TODO hardcoded
-    let (mut ds_status, pattern_warnings) = hg::dirstate_tree::status::status(
-        &mut dmap,
+    let (mut ds_status, pattern_warnings) = dmap.status(
         &AlwaysMatcher,
         repo.working_directory_path().to_owned(),
         vec![ignore_file],
@@ -239,16 +213,12 @@
     if !ds_status.unsure.is_empty()
         && (display_states.modified || display_states.clean)
     {
-        let p1: Node = parents
-            .expect(
-                "Dirstate with no parents should not list any file to
-            be rechecked for modifications",
-            )
-            .p1
-            .into();
-        let p1_hex = format!("{:x}", p1);
+        let p1 = repo.dirstate_parents()?.p1;
+        let manifest = repo.manifest_for_node(p1).map_err(|e| {
+            CommandError::from((e, &*format!("{:x}", p1.short())))
+        })?;
         for to_check in ds_status.unsure {
-            if cat_file_is_modified(repo, &to_check, &p1_hex)? {
+            if cat_file_is_modified(repo, &manifest, &to_check)? {
                 if display_states.modified {
                     ds_status.modified.push(to_check);
                 }
@@ -260,25 +230,25 @@
         }
     }
     if display_states.modified {
-        display_status_paths(ui, &mut ds_status.modified, b"M")?;
+        display_status_paths(ui, repo, config, &mut ds_status.modified, b"M")?;
     }
     if display_states.added {
-        display_status_paths(ui, &mut ds_status.added, b"A")?;
+        display_status_paths(ui, repo, config, &mut ds_status.added, b"A")?;
     }
     if display_states.removed {
-        display_status_paths(ui, &mut ds_status.removed, b"R")?;
+        display_status_paths(ui, repo, config, &mut ds_status.removed, b"R")?;
     }
     if display_states.deleted {
-        display_status_paths(ui, &mut ds_status.deleted, b"!")?;
+        display_status_paths(ui, repo, config, &mut ds_status.deleted, b"!")?;
     }
     if display_states.unknown {
-        display_status_paths(ui, &mut ds_status.unknown, b"?")?;
+        display_status_paths(ui, repo, config, &mut ds_status.unknown, b"?")?;
     }
     if display_states.ignored {
-        display_status_paths(ui, &mut ds_status.ignored, b"I")?;
+        display_status_paths(ui, repo, config, &mut ds_status.ignored, b"I")?;
     }
     if display_states.clean {
-        display_status_paths(ui, &mut ds_status.clean, b"C")?;
+        display_status_paths(ui, repo, config, &mut ds_status.clean, b"C")?;
     }
     Ok(())
 }
@@ -287,16 +257,35 @@
 // harcode HgPathBuf, but probably not really useful at this point
 fn display_status_paths(
     ui: &Ui,
+    repo: &Repo,
+    config: &Config,
     paths: &mut [HgPathCow],
     status_prefix: &[u8],
 ) -> Result<(), CommandError> {
     paths.sort_unstable();
-    for path in paths {
-        // Same TODO as in commands::root
-        let bytes: &[u8] = path.as_bytes();
-        // TODO optim, probably lots of unneeded copies here, especially
-        // if out stream is buffered
-        ui.write_stdout(&[status_prefix, b" ", bytes, b"\n"].concat())?;
+    let mut relative: bool =
+        config.get_bool(b"ui", b"relative-paths").unwrap_or(false);
+    relative = config
+        .get_bool(b"commands", b"status.relative")
+        .unwrap_or(relative);
+    if relative && !ui.plain() {
+        relativize_paths(
+            repo,
+            paths,
+            |path: Cow<[u8]>| -> Result<(), UiError> {
+                ui.write_stdout(
+                    &[status_prefix, b" ", path.as_ref(), b"\n"].concat(),
+                )
+            },
+        )?;
+    } else {
+        for path in paths {
+            // Same TODO as in commands::root
+            let bytes: &[u8] = path.as_bytes();
+            // TODO optim, probably lots of unneeded copies here, especially
+            // if out stream is buffered
+            ui.write_stdout(&[status_prefix, b" ", bytes, b"\n"].concat())?;
+        }
     }
     Ok(())
 }
@@ -309,39 +298,19 @@
 /// TODO: detect permission bits and similar metadata modifications
 fn cat_file_is_modified(
     repo: &Repo,
+    manifest: &Manifest,
     hg_path: &HgPath,
-    rev: &str,
-) -> Result<bool, CommandError> {
-    // TODO CatRev expects &[HgPathBuf], something like
-    // &[impl Deref<HgPath>] would be nicer and should avoid the copy
-    let path_bufs = [hg_path.into()];
-    // TODO IIUC CatRev returns a simple Vec<u8> for all files
-    //      being able to tell them apart as (path, bytes) would be nicer
-    //      and OPTIM would allow manifest resolution just once.
-    let output = cat(repo, rev, &path_bufs).map_err(|e| (e, rev))?;
+) -> Result<bool, HgError> {
+    let file_node = manifest
+        .find_file(hg_path)?
+        .expect("ambgious file not in p1");
+    let filelog = repo.filelog(hg_path)?;
+    let filelog_entry = filelog.data_for_node(file_node).map_err(|_| {
+        HgError::corrupted("filelog missing node from manifest")
+    })?;
+    let contents_in_p1 = filelog_entry.data()?;
 
-    let fs_path = repo
-        .working_directory_vfs()
-        .join(hg_path_to_os_string(hg_path).expect("HgPath conversion"));
-    let hg_data_len: u64 = match output.concatenated.len().try_into() {
-        Ok(v) => v,
-        Err(_) => {
-            // conversion of data length to u64 failed,
-            // good luck for any file to have this content
-            return Ok(true);
-        }
-    };
-    let fobj = fs::File::open(&fs_path).when_reading_file(&fs_path)?;
-    if fobj.metadata().map_err(|e| StatusError::from(e))?.len() != hg_data_len
-    {
-        return Ok(true);
-    }
-    for (fs_byte, hg_byte) in
-        BufReader::new(fobj).bytes().zip(output.concatenated)
-    {
-        if fs_byte.map_err(|e| StatusError::from(e))? != hg_byte {
-            return Ok(true);
-        }
-    }
-    Ok(false)
+    let fs_path = hg_path_to_os_string(hg_path).expect("HgPath conversion");
+    let fs_contents = repo.working_directory_vfs().read(fs_path)?;
+    return Ok(contents_in_p1 == &*fs_contents);
 }
--- a/rust/rhg/src/main.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/rhg/src/main.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -17,6 +17,9 @@
 mod blackbox;
 mod error;
 mod ui;
+pub mod utils {
+    pub mod path_utils;
+}
 use error::CommandError;
 
 fn main_with_result(
@@ -68,6 +71,25 @@
     let matches = app.clone().get_matches_safe()?;
 
     let (subcommand_name, subcommand_matches) = matches.subcommand();
+
+    // Mercurial allows users to define "defaults" for commands, fallback
+    // if a default is detected for the current command
+    let defaults = config.get_str(b"defaults", subcommand_name.as_bytes());
+    if defaults?.is_some() {
+        let msg = "`defaults` config set";
+        return Err(CommandError::unsupported(msg));
+    }
+
+    for prefix in ["pre", "post", "fail"].iter() {
+        // Mercurial allows users to define generic hooks for commands,
+        // fallback if any are detected
+        let item = format!("{}-{}", prefix, subcommand_name);
+        let hook_for_command = config.get_str(b"hooks", item.as_bytes())?;
+        if hook_for_command.is_some() {
+            let msg = format!("{}-{} hook defined", prefix, subcommand_name);
+            return Err(CommandError::unsupported(msg));
+        }
+    }
     let run = subcommand_run_fn(subcommand_name)
         .expect("unknown subcommand name from clap despite AppSettings::SubcommandRequired");
     let subcommand_args = subcommand_matches
@@ -79,6 +101,15 @@
         config,
         repo,
     };
+
+    if let Ok(repo) = repo {
+        // We don't support subrepos, fallback if the subrepos file is present
+        if repo.working_directory_vfs().join(".hgsub").exists() {
+            let msg = "subrepos (.hgsub is present)";
+            return Err(CommandError::unsupported(msg));
+        }
+    }
+
     let blackbox = blackbox::Blackbox::new(&invocation, process_start_time)?;
     blackbox.log_command_start();
     let result = run(&invocation);
@@ -567,11 +598,10 @@
         unsupported.remove(supported);
     }
 
-    if let Some(ignored_list) =
-        config.get_simple_list(b"rhg", b"ignored-extensions")
+    if let Some(ignored_list) = config.get_list(b"rhg", b"ignored-extensions")
     {
         for ignored in ignored_list {
-            unsupported.remove(ignored);
+            unsupported.remove(ignored.as_slice());
         }
     }
 
--- a/rust/rhg/src/ui.rs	Thu Oct 21 14:03:33 2021 +0200
+++ b/rust/rhg/src/ui.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -1,5 +1,6 @@
 use format_bytes::format_bytes;
 use std::borrow::Cow;
+use std::env;
 use std::io;
 use std::io::{ErrorKind, Write};
 
@@ -49,6 +50,25 @@
 
         stderr.flush().or_else(handle_stderr_error)
     }
+
+    /// is plain mode active
+    ///
+    /// Plain mode means that all configuration variables which affect
+    /// the behavior and output of Mercurial should be
+    /// ignored. Additionally, the output should be stable,
+    /// reproducible and suitable for use in scripts or applications.
+    ///
+    /// The only way to trigger plain mode is by setting either the
+    /// `HGPLAIN' or `HGPLAINEXCEPT' environment variables.
+    ///
+    /// The return value can either be
+    /// - False if HGPLAIN is not set, or feature is in HGPLAINEXCEPT
+    /// - False if feature is disabled by default and not included in HGPLAIN
+    /// - True otherwise
+    pub fn plain(&self) -> bool {
+        // TODO: add support for HGPLAINEXCEPT
+        env::var_os("HGPLAIN").is_some()
+    }
 }
 
 /// A buffered stdout writer for faster batch printing operations.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/rust/rhg/src/utils/path_utils.rs	Thu Oct 21 17:25:41 2021 +0200
@@ -0,0 +1,48 @@
+// path utils module
+//
+// This software may be used and distributed according to the terms of the
+// GNU General Public License version 2 or any later version.
+
+use crate::error::CommandError;
+use crate::ui::UiError;
+use hg::repo::Repo;
+use hg::utils::current_dir;
+use hg::utils::files::{get_bytes_from_path, relativize_path};
+use hg::utils::hg_path::HgPath;
+use hg::utils::hg_path::HgPathBuf;
+use std::borrow::Cow;
+
+pub fn relativize_paths(
+    repo: &Repo,
+    paths: impl IntoIterator<Item = impl AsRef<HgPath>>,
+    mut callback: impl FnMut(Cow<[u8]>) -> Result<(), UiError>,
+) -> Result<(), CommandError> {
+    let cwd = current_dir()?;
+    let repo_root = repo.working_directory_path();
+    let repo_root = cwd.join(repo_root); // Make it absolute
+    let repo_root_hgpath =
+        HgPathBuf::from(get_bytes_from_path(repo_root.to_owned()));
+    let outside_repo: bool;
+    let cwd_hgpath: HgPathBuf;
+
+    if let Ok(cwd_relative_to_repo) = cwd.strip_prefix(&repo_root) {
+        // The current directory is inside the repo, so we can work with
+        // relative paths
+        outside_repo = false;
+        cwd_hgpath =
+            HgPathBuf::from(get_bytes_from_path(cwd_relative_to_repo));
+    } else {
+        outside_repo = true;
+        cwd_hgpath = HgPathBuf::from(get_bytes_from_path(cwd));
+    }
+
+    for file in paths {
+        if outside_repo {
+            let file = repo_root_hgpath.join(file.as_ref());
+            callback(relativize_path(&file, &cwd_hgpath))?;
+        } else {
+            callback(relativize_path(file.as_ref(), &cwd_hgpath))?;
+        }
+    }
+    Ok(())
+}
--- a/setup.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/setup.py	Thu Oct 21 17:25:41 2021 +0200
@@ -780,9 +780,6 @@
 
         pythonlib = None
 
-        dir = os.path.dirname(self.get_ext_fullpath('dummy'))
-        self.hgtarget = os.path.join(dir, 'hg')
-
         if getattr(sys, 'dllhandle', None):
             # Different Python installs can have different Python library
             # names. e.g. the official CPython distribution uses pythonXY.dll
@@ -808,35 +805,6 @@
                     )
                 pythonlib = dllbasename[:-4]
 
-                # Copy the pythonXY.dll next to the binary so that it runs
-                # without tampering with PATH.
-                fsdecode = lambda x: x
-                if sys.version_info[0] >= 3:
-                    fsdecode = os.fsdecode
-                dest = os.path.join(
-                    os.path.dirname(self.hgtarget),
-                    fsdecode(dllbasename),
-                )
-
-                if not os.path.exists(dest):
-                    shutil.copy(buf.value, dest)
-
-                # Also overwrite python3.dll so that hgext.git is usable.
-                # TODO: also handle the MSYS flavor
-                if sys.version_info[0] >= 3:
-                    python_x = os.path.join(
-                        os.path.dirname(fsdecode(buf.value)),
-                        "python3.dll",
-                    )
-
-                    if os.path.exists(python_x):
-                        dest = os.path.join(
-                            os.path.dirname(self.hgtarget),
-                            os.path.basename(python_x),
-                        )
-
-                        shutil.copy(python_x, dest)
-
         if not pythonlib:
             log.warn(
                 'could not determine Python DLL filename; assuming pythonXY'
@@ -859,6 +827,8 @@
             output_dir=self.build_temp,
             macros=macros,
         )
+        dir = os.path.dirname(self.get_ext_fullpath('dummy'))
+        self.hgtarget = os.path.join(dir, 'hg')
         self.compiler.link_executable(
             objects, self.hgtarget, libraries=[], output_dir=self.build_temp
         )
@@ -1428,12 +1398,9 @@
 
     rusttargetdir = os.path.join('rust', 'target', 'release')
 
-    def __init__(
-        self, mpath, sources, rustlibname, subcrate, py3_features=None, **kw
-    ):
+    def __init__(self, mpath, sources, rustlibname, subcrate, **kw):
         Extension.__init__(self, mpath, sources, **kw)
         srcdir = self.rustsrcdir = os.path.join('rust', subcrate)
-        self.py3_features = py3_features
 
         # adding Rust source and control files to depends so that the extension
         # gets rebuilt if they've changed
@@ -1481,9 +1448,11 @@
 
         feature_flags = []
 
-        if sys.version_info[0] == 3 and self.py3_features is not None:
-            feature_flags.append(self.py3_features)
-            cargocmd.append('--no-default-features')
+        cargocmd.append('--no-default-features')
+        if sys.version_info[0] == 2:
+            feature_flags.append('python27')
+        elif sys.version_info[0] == 3:
+            feature_flags.append('python3')
 
         rust_features = env.get("HG_RUST_FEATURES")
         if rust_features:
@@ -1605,7 +1574,9 @@
         extra_compile_args=common_cflags,
     ),
     RustStandaloneExtension(
-        'mercurial.rustext', 'hg-cpython', 'librusthg', py3_features='python3'
+        'mercurial.rustext',
+        'hg-cpython',
+        'librusthg',
     ),
 ]
 
--- a/tests/autodiff.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/autodiff.py	Thu Oct 21 17:25:41 2021 +0200
@@ -4,6 +4,7 @@
 
 from mercurial import (
     error,
+    logcmdutil,
     patch,
     pycompat,
     registrar,
@@ -49,7 +50,7 @@
     else:
         raise error.Abort(b'--git must be yes, no or auto')
 
-    ctx1, ctx2 = scmutil.revpair(repo, [])
+    ctx1, ctx2 = logcmdutil.revpair(repo, [])
     m = scmutil.match(ctx2, pats, opts)
     it = patch.diff(
         repo,
--- a/tests/fakedirstatewritetime.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/fakedirstatewritetime.py	Thu Oct 21 17:25:41 2021 +0200
@@ -15,6 +15,7 @@
     policy,
     registrar,
 )
+from mercurial.dirstateutils import timestamp
 from mercurial.utils import dateutil
 
 try:
@@ -34,15 +35,14 @@
 )
 
 parsers = policy.importmod('parsers')
-rustmod = policy.importrust('parsers')
+has_rust_dirstate = policy.importrust('dirstate') is not None
 
 
 def pack_dirstate(fakenow, orig, dmap, copymap, pl, now):
     # execute what original parsers.pack_dirstate should do actually
     # for consistency
-    actualnow = int(now)
     for f, e in dmap.items():
-        if e.need_delay(actualnow):
+        if e.need_delay(now):
             e.set_possibly_dirty()
 
     return orig(dmap, copymap, pl, fakenow)
@@ -62,8 +62,9 @@
     # parsing 'fakenow' in YYYYmmddHHMM format makes comparison between
     # 'fakenow' value and 'touch -t YYYYmmddHHMM' argument easy
     fakenow = dateutil.parsedate(fakenow, [b'%Y%m%d%H%M'])[0]
+    fakenow = timestamp.timestamp((fakenow, 0))
 
-    if rustmod is not None:
+    if has_rust_dirstate:
         # The Rust implementation does not use public parse/pack dirstate
         # to prevent conversion round-trips
         orig_dirstatemap_write = dirstatemapmod.dirstatemap.write
@@ -85,7 +86,7 @@
     finally:
         orig_module.pack_dirstate = orig_pack_dirstate
         dirstate._getfsnow = orig_dirstate_getfsnow
-        if rustmod is not None:
+        if has_rust_dirstate:
             dirstatemapmod.dirstatemap.write = orig_dirstatemap_write
 
 
--- a/tests/library-infinitepush.sh	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/library-infinitepush.sh	Thu Oct 21 17:25:41 2021 +0200
@@ -14,8 +14,6 @@
   cat >> $HGRCPATH << EOF
 [extensions]
 infinitepush=
-[ui]
-ssh = "$PYTHON" "$TESTDIR/dummyssh"
 [infinitepush]
 branchpattern=re:scratch/.*
 EOF
--- a/tests/narrow-library.sh	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/narrow-library.sh	Thu Oct 21 17:25:41 2021 +0200
@@ -1,8 +1,6 @@
 cat >> $HGRCPATH <<EOF
 [extensions]
 narrow=
-[ui]
-ssh="$PYTHON" "$RUNTESTDIR/dummyssh"
 [experimental]
 changegroup3 = True
 EOF
--- a/tests/remotefilelog-library.sh	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/remotefilelog-library.sh	Thu Oct 21 17:25:41 2021 +0200
@@ -7,8 +7,6 @@
 remotefilelog=
 rebase=
 strip=
-[ui]
-ssh="$PYTHON" "$TESTDIR/dummyssh"
 [server]
 preferuncompressed=True
 [experimental]
--- a/tests/run-tests.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/run-tests.py	Thu Oct 21 17:25:41 2021 +0200
@@ -1554,6 +1554,8 @@
             hgrc.write(b'merge = internal:merge\n')
             hgrc.write(b'mergemarkers = detailed\n')
             hgrc.write(b'promptecho = True\n')
+            dummyssh = os.path.join(self._testdir, b'dummyssh')
+            hgrc.write(b'ssh = "%s" "%s"\n' % (PYTHON, dummyssh))
             hgrc.write(b'timeout.warn=15\n')
             hgrc.write(b'[chgserver]\n')
             hgrc.write(b'idletimeout=60\n')
--- a/tests/simplestorerepo.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/simplestorerepo.py	Thu Oct 21 17:25:41 2021 +0200
@@ -665,20 +665,24 @@
 
 
 class simplestore(store.encodedstore):
-    def datafiles(self):
+    def datafiles(self, undecodable=None):
         for x in super(simplestore, self).datafiles():
             yield x
 
         # Supplement with non-revlog files.
         extrafiles = self._walk('data', True, filefilter=issimplestorefile)
 
-        for unencoded, encoded, size in extrafiles:
+        for f1, size in extrafiles:
             try:
-                unencoded = store.decodefilename(unencoded)
+                f2 = store.decodefilename(f1)
             except KeyError:
-                unencoded = None
+                if undecodable is None:
+                    raise error.StorageError(b'undecodable revlog name %s' % f1)
+                else:
+                    undecodable.append(f1)
+                    continue
 
-            yield unencoded, encoded, size
+            yield f2, size
 
 
 def reposetup(ui, repo):
--- a/tests/test-addremove-similar.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-addremove-similar.t	Thu Oct 21 17:25:41 2021 +0200
@@ -131,13 +131,13 @@
 
   $ hg addremove -s foo
   abort: similarity must be a number
-  [255]
+  [10]
   $ hg addremove -s -1
   abort: similarity must be between 0 and 100
-  [255]
+  [10]
   $ hg addremove -s 1e6
   abort: similarity must be between 0 and 100
-  [255]
+  [10]
 
   $ cd ..
 
--- a/tests/test-annotate.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-annotate.t	Thu Oct 21 17:25:41 2021 +0200
@@ -455,7 +455,7 @@
 
   $ hg ann nosuchfile
   abort: nosuchfile: no such file in rev e9e6b4fa872f
-  [255]
+  [10]
 
 annotate file without '\n' on last line
 
--- a/tests/test-basic.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-basic.t	Thu Oct 21 17:25:41 2021 +0200
@@ -15,6 +15,7 @@
   ui.merge=internal:merge
   ui.mergemarkers=detailed
   ui.promptecho=True
+  ui.ssh=* (glob)
   ui.timeout.warn=15
   web.address=localhost
   web\.ipv6=(?:True|False) (re)
--- a/tests/test-batching.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-batching.py	Thu Oct 21 17:25:41 2021 +0200
@@ -214,14 +214,11 @@
                 mangle(two),
             ),
         ]
-        encoded_res_future = wireprotov1peer.future()
-        yield encoded_args, encoded_res_future
-        yield unmangle(encoded_res_future.value)
+        return encoded_args, unmangle
 
     @wireprotov1peer.batchable
     def bar(self, b, a):
-        encresref = wireprotov1peer.future()
-        yield [
+        return [
             (
                 b'b',
                 mangle(b),
@@ -230,8 +227,7 @@
                 b'a',
                 mangle(a),
             ),
-        ], encresref
-        yield unmangle(encresref.value)
+        ], unmangle
 
     # greet is coded directly. It therefore does not support batching. If it
     # does appear in a batch, the batch is split around greet, and the call to
--- a/tests/test-bookmarks-corner-case.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-bookmarks-corner-case.t	Thu Oct 21 17:25:41 2021 +0200
@@ -12,16 +12,6 @@
 node known to the changelog. If the cache invalidation between these two bits
 goes wrong, bookmark can be dropped.
 
-global setup
-------------
-
-  $ cat >> $HGRCPATH << EOF
-  > [ui]
-  > ssh = "$PYTHON" "$TESTDIR/dummyssh"
-  > [server]
-  > concurrent-push-mode=check-related
-  > EOF
-
 Setup
 -----
 
--- a/tests/test-bookmarks-pushpull.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-bookmarks-pushpull.t	Thu Oct 21 17:25:41 2021 +0200
@@ -490,6 +490,65 @@
      Y                         0:4e3505fd9583
      Z                         1:0d2164f0ce0d
 
+mirroring bookmarks
+
+  $ hg book
+     @                         1:9b140be10808
+     @foo                      2:0d2164f0ce0d
+     X                         1:9b140be10808
+     X@foo                     2:0d2164f0ce0d
+     Y                         0:4e3505fd9583
+     Z                         2:0d2164f0ce0d
+     foo                       -1:000000000000
+   * foobar                    1:9b140be10808
+  $ cp .hg/bookmarks .hg/bookmarks.bak
+  $ hg book -d X
+  $ hg incoming --bookmark  -v ../a
+  comparing with ../a
+  searching for changed bookmarks
+     @                         0d2164f0ce0d diverged
+     X                         0d2164f0ce0d added
+  $ hg incoming --bookmark  -v ../a --config 'paths.*:bookmarks.mode=babar'
+  (paths.*:bookmarks.mode has unknown value: "babar")
+  comparing with ../a
+  searching for changed bookmarks
+     @                         0d2164f0ce0d diverged
+     X                         0d2164f0ce0d added
+  $ hg incoming --bookmark  -v ../a --config 'paths.*:bookmarks.mode=mirror'
+  comparing with ../a
+  searching for changed bookmarks
+     @                         0d2164f0ce0d changed
+     @foo                      000000000000 removed
+     X                         0d2164f0ce0d added
+     X@foo                     000000000000 removed
+     foo                       000000000000 removed
+     foobar                    000000000000 removed
+  $ hg incoming --bookmark  -v ../a --config 'paths.*:bookmarks.mode=ignore'
+  comparing with ../a
+  bookmarks exchange disabled with this path
+  $ hg pull ../a --config 'paths.*:bookmarks.mode=ignore'
+  pulling from ../a
+  searching for changes
+  no changes found
+  $ hg book
+     @                         1:9b140be10808
+     @foo                      2:0d2164f0ce0d
+     X@foo                     2:0d2164f0ce0d
+     Y                         0:4e3505fd9583
+     Z                         2:0d2164f0ce0d
+     foo                       -1:000000000000
+   * foobar                    1:9b140be10808
+  $ hg pull ../a --config 'paths.*:bookmarks.mode=mirror'
+  pulling from ../a
+  searching for changes
+  no changes found
+  $ hg book
+     @                         2:0d2164f0ce0d
+     X                         2:0d2164f0ce0d
+     Y                         0:4e3505fd9583
+     Z                         2:0d2164f0ce0d
+  $ mv .hg/bookmarks.bak .hg/bookmarks
+
 explicit pull should overwrite the local version (issue4439)
 
   $ hg update -r X
@@ -1142,8 +1201,6 @@
   > local=../issue4455-dest/
   > ssh=ssh://user@dummy/issue4455-dest
   > http=http://localhost:$HGPORT/
-  > [ui]
-  > ssh="$PYTHON" "$TESTDIR/dummyssh"
   > EOF
   $ cat >> ../issue4455-dest/.hg/hgrc << EOF
   > [hooks]
@@ -1270,7 +1327,6 @@
 
   $ cat << EOF >> $HGRCPATH
   > [ui]
-  > ssh="$PYTHON" "$TESTDIR/dummyssh"
   > [server]
   > bookmarks-pushkey-compat = yes
   > EOF
--- a/tests/test-bookmarks.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-bookmarks.t	Thu Oct 21 17:25:41 2021 +0200
@@ -185,22 +185,22 @@
 
   $ hg log -r 'bookmark("literal:.")'
   abort: bookmark '.' does not exist
-  [255]
+  [10]
 
 "." should fail if there's no active bookmark:
 
   $ hg bookmark --inactive
   $ hg log -r 'bookmark(.)'
   abort: no active bookmark
-  [255]
+  [10]
   $ hg log -r 'present(bookmark(.))'
 
   $ hg log -r 'bookmark(unknown)'
   abort: bookmark 'unknown' does not exist
-  [255]
+  [10]
   $ hg log -r 'bookmark("literal:unknown")'
   abort: bookmark 'unknown' does not exist
-  [255]
+  [10]
   $ hg log -r 'bookmark("re:unknown")'
   $ hg log -r 'present(bookmark("literal:unknown"))'
   $ hg log -r 'present(bookmark("re:unknown"))'
--- a/tests/test-branch-change.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-branch-change.t	Thu Oct 21 17:25:41 2021 +0200
@@ -147,7 +147,7 @@
   $ hg branch -r 4 foobar
   abort: hidden revision '4' was rewritten as: 7c1991464886
   (use --hidden to access hidden revisions)
-  [255]
+  [10]
 
   $ hg branch -r 4 --hidden foobar
   abort: cannot change branch of 3938acfb5c0f, as that creates content-divergence with 7c1991464886
--- a/tests/test-bundle2-exchange.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-bundle2-exchange.t	Thu Oct 21 17:25:41 2021 +0200
@@ -28,8 +28,6 @@
   > evolution.createmarkers=True
   > evolution.exchange=True
   > bundle2-output-capture=True
-  > [ui]
-  > ssh="$PYTHON" "$TESTDIR/dummyssh"
   > [command-templates]
   > log={rev}:{node|short} {phase} {author} {bookmarks} {desc|firstline}
   > [web]
@@ -922,10 +920,6 @@
 
 Test lazily acquiring the lock during unbundle
   $ cp $TESTTMP/hgrc.orig $HGRCPATH
-  $ cat >> $HGRCPATH <<EOF
-  > [ui]
-  > ssh="$PYTHON" "$TESTDIR/dummyssh"
-  > EOF
 
   $ cat >> $TESTTMP/locktester.py <<EOF
   > import os
--- a/tests/test-bundle2-format.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-bundle2-format.t	Thu Oct 21 17:25:41 2021 +0200
@@ -233,8 +233,6 @@
   > bundle2=$TESTTMP/bundle2.py
   > [experimental]
   > evolution.createmarkers=True
-  > [ui]
-  > ssh="$PYTHON" "$TESTDIR/dummyssh"
   > [command-templates]
   > log={rev}:{node|short} {phase} {author} {bookmarks} {desc|firstline}
   > [web]
--- a/tests/test-bundle2-pushback.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-bundle2-pushback.t	Thu Oct 21 17:25:41 2021 +0200
@@ -37,7 +37,6 @@
 
   $ cat >> $HGRCPATH <<EOF
   > [ui]
-  > ssh = "$PYTHON" "$TESTDIR/dummyssh"
   > username = nobody <no.reply@example.com>
   > 
   > [alias]
--- a/tests/test-bundle2-remote-changegroup.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-bundle2-remote-changegroup.t	Thu Oct 21 17:25:41 2021 +0200
@@ -94,8 +94,6 @@
   $ cat dumb.pid >> $DAEMON_PIDS
 
   $ cat >> $HGRCPATH << EOF
-  > [ui]
-  > ssh="$PYTHON" "$TESTDIR/dummyssh"
   > [command-templates]
   > log={rev}:{node|short} {phase} {author} {bookmarks} {desc|firstline}
   > EOF
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-clone-stream.t	Thu Oct 21 17:25:41 2021 +0200
@@ -0,0 +1,904 @@
+#require serve no-reposimplestore no-chg
+
+#testcases stream-legacy stream-bundle2
+
+#if stream-legacy
+  $ cat << EOF >> $HGRCPATH
+  > [server]
+  > bundle2.stream = no
+  > EOF
+#endif
+
+Initialize repository
+the status call is to check for issue5130
+
+  $ hg init server
+  $ cd server
+  $ touch foo
+  $ hg -q commit -A -m initial
+  >>> for i in range(1024):
+  ...     with open(str(i), 'wb') as fh:
+  ...         fh.write(b"%d" % i) and None
+  $ hg -q commit -A -m 'add a lot of files'
+  $ hg st
+
+add files with "tricky" name:
+
+  $ echo foo > 00changelog.i
+  $ echo foo > 00changelog.d
+  $ echo foo > 00changelog.n
+  $ echo foo > 00changelog-ab349180a0405010.nd
+  $ echo foo > 00manifest.i
+  $ echo foo > 00manifest.d
+  $ echo foo > foo.i
+  $ echo foo > foo.d
+  $ echo foo > foo.n
+  $ echo foo > undo.py
+  $ echo foo > undo.i
+  $ echo foo > undo.d
+  $ echo foo > undo.n
+  $ echo foo > undo.foo.i
+  $ echo foo > undo.foo.d
+  $ echo foo > undo.foo.n
+  $ echo foo > undo.babar
+  $ mkdir savanah
+  $ echo foo > savanah/foo.i
+  $ echo foo > savanah/foo.d
+  $ echo foo > savanah/foo.n
+  $ echo foo > savanah/undo.py
+  $ echo foo > savanah/undo.i
+  $ echo foo > savanah/undo.d
+  $ echo foo > savanah/undo.n
+  $ echo foo > savanah/undo.foo.i
+  $ echo foo > savanah/undo.foo.d
+  $ echo foo > savanah/undo.foo.n
+  $ echo foo > savanah/undo.babar
+  $ mkdir data
+  $ echo foo > data/foo.i
+  $ echo foo > data/foo.d
+  $ echo foo > data/foo.n
+  $ echo foo > data/undo.py
+  $ echo foo > data/undo.i
+  $ echo foo > data/undo.d
+  $ echo foo > data/undo.n
+  $ echo foo > data/undo.foo.i
+  $ echo foo > data/undo.foo.d
+  $ echo foo > data/undo.foo.n
+  $ echo foo > data/undo.babar
+  $ mkdir meta
+  $ echo foo > meta/foo.i
+  $ echo foo > meta/foo.d
+  $ echo foo > meta/foo.n
+  $ echo foo > meta/undo.py
+  $ echo foo > meta/undo.i
+  $ echo foo > meta/undo.d
+  $ echo foo > meta/undo.n
+  $ echo foo > meta/undo.foo.i
+  $ echo foo > meta/undo.foo.d
+  $ echo foo > meta/undo.foo.n
+  $ echo foo > meta/undo.babar
+  $ mkdir store
+  $ echo foo > store/foo.i
+  $ echo foo > store/foo.d
+  $ echo foo > store/foo.n
+  $ echo foo > store/undo.py
+  $ echo foo > store/undo.i
+  $ echo foo > store/undo.d
+  $ echo foo > store/undo.n
+  $ echo foo > store/undo.foo.i
+  $ echo foo > store/undo.foo.d
+  $ echo foo > store/undo.foo.n
+  $ echo foo > store/undo.babar
+
+Name with special characters
+
+  $ echo foo > store/CélesteVille_is_a_Capital_City
+
+name causing issue6581
+
+  $ mkdir --parents container/isam-build-centos7/
+  $ touch container/isam-build-centos7/bazel-coverage-generator-sandboxfs-compatibility-0758e3e4f6057904d44399bd666faba9e7f40686.patch
+
+Add all that
+
+  $ hg add .
+  adding 00changelog-ab349180a0405010.nd
+  adding 00changelog.d
+  adding 00changelog.i
+  adding 00changelog.n
+  adding 00manifest.d
+  adding 00manifest.i
+  adding container/isam-build-centos7/bazel-coverage-generator-sandboxfs-compatibility-0758e3e4f6057904d44399bd666faba9e7f40686.patch
+  adding data/foo.d
+  adding data/foo.i
+  adding data/foo.n
+  adding data/undo.babar
+  adding data/undo.d
+  adding data/undo.foo.d
+  adding data/undo.foo.i
+  adding data/undo.foo.n
+  adding data/undo.i
+  adding data/undo.n
+  adding data/undo.py
+  adding foo.d
+  adding foo.i
+  adding foo.n
+  adding meta/foo.d
+  adding meta/foo.i
+  adding meta/foo.n
+  adding meta/undo.babar
+  adding meta/undo.d
+  adding meta/undo.foo.d
+  adding meta/undo.foo.i
+  adding meta/undo.foo.n
+  adding meta/undo.i
+  adding meta/undo.n
+  adding meta/undo.py
+  adding savanah/foo.d
+  adding savanah/foo.i
+  adding savanah/foo.n
+  adding savanah/undo.babar
+  adding savanah/undo.d
+  adding savanah/undo.foo.d
+  adding savanah/undo.foo.i
+  adding savanah/undo.foo.n
+  adding savanah/undo.i
+  adding savanah/undo.n
+  adding savanah/undo.py
+  adding store/C\xc3\xa9lesteVille_is_a_Capital_City (esc)
+  adding store/foo.d
+  adding store/foo.i
+  adding store/foo.n
+  adding store/undo.babar
+  adding store/undo.d
+  adding store/undo.foo.d
+  adding store/undo.foo.i
+  adding store/undo.foo.n
+  adding store/undo.i
+  adding store/undo.n
+  adding store/undo.py
+  adding undo.babar
+  adding undo.d
+  adding undo.foo.d
+  adding undo.foo.i
+  adding undo.foo.n
+  adding undo.i
+  adding undo.n
+  adding undo.py
+  $ hg ci -m 'add files with "tricky" name'
+  $ hg --config server.uncompressed=false serve -p $HGPORT -d --pid-file=hg.pid
+  $ cat hg.pid > $DAEMON_PIDS
+  $ cd ..
+
+Check local clone
+==================
+
+The logic is close enough of uncompressed.
+This is present here to reuse the testing around file with "special" names.
+
+  $ hg clone server local-clone
+  updating to branch default
+  1088 files updated, 0 files merged, 0 files removed, 0 files unresolved
+
+Check that the clone went well
+
+  $ hg verify -R local-clone
+  checking changesets
+  checking manifests
+  crosschecking files in changesets and manifests
+  checking files
+  checked 3 changesets with 1088 changes to 1088 files
+
+Check uncompressed
+==================
+
+Cannot stream clone when server.uncompressed is set
+
+  $ get-with-headers.py $LOCALIP:$HGPORT '?cmd=stream_out'
+  200 Script output follows
+  
+  1
+
+#if stream-legacy
+  $ hg debugcapabilities http://localhost:$HGPORT
+  Main capabilities:
+    batch
+    branchmap
+    $USUAL_BUNDLE2_CAPS_SERVER$
+    changegroupsubset
+    compression=$BUNDLE2_COMPRESSIONS$
+    getbundle
+    httpheader=1024
+    httpmediatype=0.1rx,0.1tx,0.2tx
+    known
+    lookup
+    pushkey
+    unbundle=HG10GZ,HG10BZ,HG10UN
+    unbundlehash
+  Bundle2 capabilities:
+    HG20
+    bookmarks
+    changegroup
+      01
+      02
+    checkheads
+      related
+    digests
+      md5
+      sha1
+      sha512
+    error
+      abort
+      unsupportedcontent
+      pushraced
+      pushkey
+    hgtagsfnodes
+    listkeys
+    phases
+      heads
+    pushkey
+    remote-changegroup
+      http
+      https
+
+  $ hg clone --stream -U http://localhost:$HGPORT server-disabled
+  warning: stream clone requested but server has them disabled
+  requesting all changes
+  adding changesets
+  adding manifests
+  adding file changes
+  added 3 changesets with 1088 changes to 1088 files
+  new changesets 96ee1d7354c4:5223b5e3265f
+
+  $ get-with-headers.py $LOCALIP:$HGPORT '?cmd=getbundle' content-type --bodyfile body --hgproto 0.2 --requestheader "x-hgarg-1=bundlecaps=HG20%2Cbundle2%3DHG20%250Abookmarks%250Achangegroup%253D01%252C02%250Adigests%253Dmd5%252Csha1%252Csha512%250Aerror%253Dabort%252Cunsupportedcontent%252Cpushraced%252Cpushkey%250Ahgtagsfnodes%250Alistkeys%250Aphases%253Dheads%250Apushkey%250Aremote-changegroup%253Dhttp%252Chttps&cg=0&common=0000000000000000000000000000000000000000&heads=c17445101a72edac06facd130d14808dfbd5c7c2&stream=1"
+  200 Script output follows
+  content-type: application/mercurial-0.2
+  
+
+  $ f --size body --hexdump --bytes 100
+  body: size=232
+  0000: 04 6e 6f 6e 65 48 47 32 30 00 00 00 00 00 00 00 |.noneHG20.......|
+  0010: cf 0b 45 52 52 4f 52 3a 41 42 4f 52 54 00 00 00 |..ERROR:ABORT...|
+  0020: 00 01 01 07 3c 04 72 6d 65 73 73 61 67 65 73 74 |....<.rmessagest|
+  0030: 72 65 61 6d 20 64 61 74 61 20 72 65 71 75 65 73 |ream data reques|
+  0040: 74 65 64 20 62 75 74 20 73 65 72 76 65 72 20 64 |ted but server d|
+  0050: 6f 65 73 20 6e 6f 74 20 61 6c 6c 6f 77 20 74 68 |oes not allow th|
+  0060: 69 73 20 66                                     |is f|
+
+#endif
+#if stream-bundle2
+  $ hg debugcapabilities http://localhost:$HGPORT
+  Main capabilities:
+    batch
+    branchmap
+    $USUAL_BUNDLE2_CAPS_SERVER$
+    changegroupsubset
+    compression=$BUNDLE2_COMPRESSIONS$
+    getbundle
+    httpheader=1024
+    httpmediatype=0.1rx,0.1tx,0.2tx
+    known
+    lookup
+    pushkey
+    unbundle=HG10GZ,HG10BZ,HG10UN
+    unbundlehash
+  Bundle2 capabilities:
+    HG20
+    bookmarks
+    changegroup
+      01
+      02
+    checkheads
+      related
+    digests
+      md5
+      sha1
+      sha512
+    error
+      abort
+      unsupportedcontent
+      pushraced
+      pushkey
+    hgtagsfnodes
+    listkeys
+    phases
+      heads
+    pushkey
+    remote-changegroup
+      http
+      https
+
+  $ hg clone --stream -U http://localhost:$HGPORT server-disabled
+  warning: stream clone requested but server has them disabled
+  requesting all changes
+  adding changesets
+  adding manifests
+  adding file changes
+  added 3 changesets with 1088 changes to 1088 files
+  new changesets 96ee1d7354c4:5223b5e3265f
+
+  $ get-with-headers.py $LOCALIP:$HGPORT '?cmd=getbundle' content-type --bodyfile body --hgproto 0.2 --requestheader "x-hgarg-1=bundlecaps=HG20%2Cbundle2%3DHG20%250Abookmarks%250Achangegroup%253D01%252C02%250Adigests%253Dmd5%252Csha1%252Csha512%250Aerror%253Dabort%252Cunsupportedcontent%252Cpushraced%252Cpushkey%250Ahgtagsfnodes%250Alistkeys%250Aphases%253Dheads%250Apushkey%250Aremote-changegroup%253Dhttp%252Chttps&cg=0&common=0000000000000000000000000000000000000000&heads=c17445101a72edac06facd130d14808dfbd5c7c2&stream=1"
+  200 Script output follows
+  content-type: application/mercurial-0.2
+  
+
+  $ f --size body --hexdump --bytes 100
+  body: size=232
+  0000: 04 6e 6f 6e 65 48 47 32 30 00 00 00 00 00 00 00 |.noneHG20.......|
+  0010: cf 0b 45 52 52 4f 52 3a 41 42 4f 52 54 00 00 00 |..ERROR:ABORT...|
+  0020: 00 01 01 07 3c 04 72 6d 65 73 73 61 67 65 73 74 |....<.rmessagest|
+  0030: 72 65 61 6d 20 64 61 74 61 20 72 65 71 75 65 73 |ream data reques|
+  0040: 74 65 64 20 62 75 74 20 73 65 72 76 65 72 20 64 |ted but server d|
+  0050: 6f 65 73 20 6e 6f 74 20 61 6c 6c 6f 77 20 74 68 |oes not allow th|
+  0060: 69 73 20 66                                     |is f|
+
+#endif
+
+  $ killdaemons.py
+  $ cd server
+  $ hg serve -p $HGPORT -d --pid-file=hg.pid --error errors.txt
+  $ cat hg.pid > $DAEMON_PIDS
+  $ cd ..
+
+Basic clone
+
+#if stream-legacy
+  $ hg clone --stream -U http://localhost:$HGPORT clone1
+  streaming all changes
+  1090 files to transfer, 102 KB of data (no-zstd !)
+  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
+  1090 files to transfer, 98.8 KB of data (zstd !)
+  transferred 98.8 KB in * seconds (* */sec) (glob) (zstd !)
+  searching for changes
+  no changes found
+  $ cat server/errors.txt
+#endif
+#if stream-bundle2
+  $ hg clone --stream -U http://localhost:$HGPORT clone1
+  streaming all changes
+  1093 files to transfer, 102 KB of data (no-zstd !)
+  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
+  1093 files to transfer, 98.9 KB of data (zstd !)
+  transferred 98.9 KB in * seconds (* */sec) (glob) (zstd !)
+
+  $ ls -1 clone1/.hg/cache
+  branch2-base
+  branch2-immutable
+  branch2-served
+  branch2-served.hidden
+  branch2-visible
+  branch2-visible-hidden
+  rbc-names-v1
+  rbc-revs-v1
+  tags2
+  tags2-served
+  $ cat server/errors.txt
+#endif
+
+getbundle requests with stream=1 are uncompressed
+
+  $ get-with-headers.py $LOCALIP:$HGPORT '?cmd=getbundle' content-type --bodyfile body --hgproto '0.1 0.2 comp=zlib,none' --requestheader "x-hgarg-1=bundlecaps=HG20%2Cbundle2%3DHG20%250Abookmarks%250Achangegroup%253D01%252C02%250Adigests%253Dmd5%252Csha1%252Csha512%250Aerror%253Dabort%252Cunsupportedcontent%252Cpushraced%252Cpushkey%250Ahgtagsfnodes%250Alistkeys%250Aphases%253Dheads%250Apushkey%250Aremote-changegroup%253Dhttp%252Chttps&cg=0&common=0000000000000000000000000000000000000000&heads=c17445101a72edac06facd130d14808dfbd5c7c2&stream=1"
+  200 Script output follows
+  content-type: application/mercurial-0.2
+  
+
+#if no-zstd no-rust
+  $ f --size --hex --bytes 256 body
+  body: size=119153
+  0000: 04 6e 6f 6e 65 48 47 32 30 00 00 00 00 00 00 00 |.noneHG20.......|
+  0010: 80 07 53 54 52 45 41 4d 32 00 00 00 00 03 00 09 |..STREAM2.......|
+  0020: 06 09 04 0c 44 62 79 74 65 63 6f 75 6e 74 31 30 |....Dbytecount10|
+  0030: 34 31 31 35 66 69 6c 65 63 6f 75 6e 74 31 30 39 |4115filecount109|
+  0040: 33 72 65 71 75 69 72 65 6d 65 6e 74 73 64 6f 74 |3requirementsdot|
+  0050: 65 6e 63 6f 64 65 25 32 43 66 6e 63 61 63 68 65 |encode%2Cfncache|
+  0060: 25 32 43 67 65 6e 65 72 61 6c 64 65 6c 74 61 25 |%2Cgeneraldelta%|
+  0070: 32 43 72 65 76 6c 6f 67 76 31 25 32 43 73 70 61 |2Crevlogv1%2Cspa|
+  0080: 72 73 65 72 65 76 6c 6f 67 25 32 43 73 74 6f 72 |rserevlog%2Cstor|
+  0090: 65 00 00 80 00 73 08 42 64 61 74 61 2f 30 2e 69 |e....s.Bdata/0.i|
+  00a0: 00 03 00 01 00 00 00 00 00 00 00 02 00 00 00 01 |................|
+  00b0: 00 00 00 00 00 00 00 01 ff ff ff ff ff ff ff ff |................|
+  00c0: 80 29 63 a0 49 d3 23 87 bf ce fe 56 67 92 67 2c |.)c.I.#....Vg.g,|
+  00d0: 69 d1 ec 39 00 00 00 00 00 00 00 00 00 00 00 00 |i..9............|
+  00e0: 75 30 73 26 45 64 61 74 61 2f 30 30 63 68 61 6e |u0s&Edata/00chan|
+  00f0: 67 65 6c 6f 67 2d 61 62 33 34 39 31 38 30 61 30 |gelog-ab349180a0|
+#endif
+#if zstd no-rust
+  $ f --size --hex --bytes 256 body
+  body: size=116340
+  0000: 04 6e 6f 6e 65 48 47 32 30 00 00 00 00 00 00 00 |.noneHG20.......|
+  0010: 9a 07 53 54 52 45 41 4d 32 00 00 00 00 03 00 09 |..STREAM2.......|
+  0020: 06 09 04 0c 5e 62 79 74 65 63 6f 75 6e 74 31 30 |....^bytecount10|
+  0030: 31 32 37 36 66 69 6c 65 63 6f 75 6e 74 31 30 39 |1276filecount109|
+  0040: 33 72 65 71 75 69 72 65 6d 65 6e 74 73 64 6f 74 |3requirementsdot|
+  0050: 65 6e 63 6f 64 65 25 32 43 66 6e 63 61 63 68 65 |encode%2Cfncache|
+  0060: 25 32 43 67 65 6e 65 72 61 6c 64 65 6c 74 61 25 |%2Cgeneraldelta%|
+  0070: 32 43 72 65 76 6c 6f 67 2d 63 6f 6d 70 72 65 73 |2Crevlog-compres|
+  0080: 73 69 6f 6e 2d 7a 73 74 64 25 32 43 72 65 76 6c |sion-zstd%2Crevl|
+  0090: 6f 67 76 31 25 32 43 73 70 61 72 73 65 72 65 76 |ogv1%2Csparserev|
+  00a0: 6c 6f 67 25 32 43 73 74 6f 72 65 00 00 80 00 73 |log%2Cstore....s|
+  00b0: 08 42 64 61 74 61 2f 30 2e 69 00 03 00 01 00 00 |.Bdata/0.i......|
+  00c0: 00 00 00 00 00 02 00 00 00 01 00 00 00 00 00 00 |................|
+  00d0: 00 01 ff ff ff ff ff ff ff ff 80 29 63 a0 49 d3 |...........)c.I.|
+  00e0: 23 87 bf ce fe 56 67 92 67 2c 69 d1 ec 39 00 00 |#....Vg.g,i..9..|
+  00f0: 00 00 00 00 00 00 00 00 00 00 75 30 73 26 45 64 |..........u0s&Ed|
+#endif
+#if zstd rust no-dirstate-v2
+  $ f --size --hex --bytes 256 body
+  body: size=116361
+  0000: 04 6e 6f 6e 65 48 47 32 30 00 00 00 00 00 00 00 |.noneHG20.......|
+  0010: af 07 53 54 52 45 41 4d 32 00 00 00 00 03 00 09 |..STREAM2.......|
+  0020: 06 09 04 0c 73 62 79 74 65 63 6f 75 6e 74 31 30 |....sbytecount10|
+  0030: 31 32 37 36 66 69 6c 65 63 6f 75 6e 74 31 30 39 |1276filecount109|
+  0040: 33 72 65 71 75 69 72 65 6d 65 6e 74 73 64 6f 74 |3requirementsdot|
+  0050: 65 6e 63 6f 64 65 25 32 43 66 6e 63 61 63 68 65 |encode%2Cfncache|
+  0060: 25 32 43 67 65 6e 65 72 61 6c 64 65 6c 74 61 25 |%2Cgeneraldelta%|
+  0070: 32 43 70 65 72 73 69 73 74 65 6e 74 2d 6e 6f 64 |2Cpersistent-nod|
+  0080: 65 6d 61 70 25 32 43 72 65 76 6c 6f 67 2d 63 6f |emap%2Crevlog-co|
+  0090: 6d 70 72 65 73 73 69 6f 6e 2d 7a 73 74 64 25 32 |mpression-zstd%2|
+  00a0: 43 72 65 76 6c 6f 67 76 31 25 32 43 73 70 61 72 |Crevlogv1%2Cspar|
+  00b0: 73 65 72 65 76 6c 6f 67 25 32 43 73 74 6f 72 65 |serevlog%2Cstore|
+  00c0: 00 00 80 00 73 08 42 64 61 74 61 2f 30 2e 69 00 |....s.Bdata/0.i.|
+  00d0: 03 00 01 00 00 00 00 00 00 00 02 00 00 00 01 00 |................|
+  00e0: 00 00 00 00 00 00 01 ff ff ff ff ff ff ff ff 80 |................|
+  00f0: 29 63 a0 49 d3 23 87 bf ce fe 56 67 92 67 2c 69 |)c.I.#....Vg.g,i|
+#endif
+#if zstd dirstate-v2
+  $ f --size --hex --bytes 256 body
+  body: size=109549
+  0000: 04 6e 6f 6e 65 48 47 32 30 00 00 00 00 00 00 00 |.noneHG20.......|
+  0010: c0 07 53 54 52 45 41 4d 32 00 00 00 00 03 00 09 |..STREAM2.......|
+  0020: 05 09 04 0c 85 62 79 74 65 63 6f 75 6e 74 39 35 |.....bytecount95|
+  0030: 38 39 37 66 69 6c 65 63 6f 75 6e 74 31 30 33 30 |897filecount1030|
+  0040: 72 65 71 75 69 72 65 6d 65 6e 74 73 64 6f 74 65 |requirementsdote|
+  0050: 6e 63 6f 64 65 25 32 43 65 78 70 2d 64 69 72 73 |ncode%2Cexp-dirs|
+  0060: 74 61 74 65 2d 76 32 25 32 43 66 6e 63 61 63 68 |tate-v2%2Cfncach|
+  0070: 65 25 32 43 67 65 6e 65 72 61 6c 64 65 6c 74 61 |e%2Cgeneraldelta|
+  0080: 25 32 43 70 65 72 73 69 73 74 65 6e 74 2d 6e 6f |%2Cpersistent-no|
+  0090: 64 65 6d 61 70 25 32 43 72 65 76 6c 6f 67 2d 63 |demap%2Crevlog-c|
+  00a0: 6f 6d 70 72 65 73 73 69 6f 6e 2d 7a 73 74 64 25 |ompression-zstd%|
+  00b0: 32 43 72 65 76 6c 6f 67 76 31 25 32 43 73 70 61 |2Crevlogv1%2Cspa|
+  00c0: 72 73 65 72 65 76 6c 6f 67 25 32 43 73 74 6f 72 |rserevlog%2Cstor|
+  00d0: 65 00 00 80 00 73 08 42 64 61 74 61 2f 30 2e 69 |e....s.Bdata/0.i|
+  00e0: 00 03 00 01 00 00 00 00 00 00 00 02 00 00 00 01 |................|
+  00f0: 00 00 00 00 00 00 00 01 ff ff ff ff ff ff ff ff |................|
+#endif
+
+--uncompressed is an alias to --stream
+
+#if stream-legacy
+  $ hg clone --uncompressed -U http://localhost:$HGPORT clone1-uncompressed
+  streaming all changes
+  1090 files to transfer, 102 KB of data (no-zstd !)
+  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
+  1090 files to transfer, 98.8 KB of data (zstd !)
+  transferred 98.8 KB in * seconds (* */sec) (glob) (zstd !)
+  searching for changes
+  no changes found
+#endif
+#if stream-bundle2
+  $ hg clone --uncompressed -U http://localhost:$HGPORT clone1-uncompressed
+  streaming all changes
+  1093 files to transfer, 102 KB of data (no-zstd !)
+  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
+  1093 files to transfer, 98.9 KB of data (zstd !)
+  transferred 98.9 KB in * seconds (* */sec) (glob) (zstd !)
+#endif
+
+Clone with background file closing enabled
+
+#if stream-legacy
+  $ hg --debug --config worker.backgroundclose=true --config worker.backgroundcloseminfilecount=1 clone --stream -U http://localhost:$HGPORT clone-background | grep -v adding
+  using http://localhost:$HGPORT/
+  sending capabilities command
+  sending branchmap command
+  streaming all changes
+  sending stream_out command
+  1090 files to transfer, 102 KB of data (no-zstd !)
+  1090 files to transfer, 98.8 KB of data (zstd !)
+  starting 4 threads for background file closing
+  updating the branch cache
+  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
+  transferred 98.8 KB in * seconds (* */sec) (glob) (zstd !)
+  query 1; heads
+  sending batch command
+  searching for changes
+  all remote heads known locally
+  no changes found
+  sending getbundle command
+  bundle2-input-bundle: with-transaction
+  bundle2-input-part: "listkeys" (params: 1 mandatory) supported
+  bundle2-input-part: "phase-heads" supported
+  bundle2-input-part: total payload size 24
+  bundle2-input-bundle: 2 parts total
+  checking for updated bookmarks
+  updating the branch cache
+  (sent 5 HTTP requests and * bytes; received * bytes in responses) (glob)
+#endif
+#if stream-bundle2
+  $ hg --debug --config worker.backgroundclose=true --config worker.backgroundcloseminfilecount=1 clone --stream -U http://localhost:$HGPORT clone-background | grep -v adding
+  using http://localhost:$HGPORT/
+  sending capabilities command
+  query 1; heads
+  sending batch command
+  streaming all changes
+  sending getbundle command
+  bundle2-input-bundle: with-transaction
+  bundle2-input-part: "stream2" (params: 3 mandatory) supported
+  applying stream bundle
+  1093 files to transfer, 102 KB of data (no-zstd !)
+  1093 files to transfer, 98.9 KB of data (zstd !)
+  starting 4 threads for background file closing
+  starting 4 threads for background file closing
+  updating the branch cache
+  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
+  bundle2-input-part: total payload size 118984 (no-zstd !)
+  transferred 98.9 KB in * seconds (* */sec) (glob) (zstd !)
+  bundle2-input-part: total payload size 116145 (zstd !)
+  bundle2-input-part: "listkeys" (params: 1 mandatory) supported
+  bundle2-input-bundle: 2 parts total
+  checking for updated bookmarks
+  updating the branch cache
+  (sent 3 HTTP requests and * bytes; received * bytes in responses) (glob)
+#endif
+
+Cannot stream clone when there are secret changesets
+
+  $ hg -R server phase --force --secret -r tip
+  $ hg clone --stream -U http://localhost:$HGPORT secret-denied
+  warning: stream clone requested but server has them disabled
+  requesting all changes
+  adding changesets
+  adding manifests
+  adding file changes
+  added 2 changesets with 1025 changes to 1025 files
+  new changesets 96ee1d7354c4:c17445101a72
+
+  $ killdaemons.py
+
+Streaming of secrets can be overridden by server config
+
+  $ cd server
+  $ hg serve --config server.uncompressedallowsecret=true -p $HGPORT -d --pid-file=hg.pid
+  $ cat hg.pid > $DAEMON_PIDS
+  $ cd ..
+
+#if stream-legacy
+  $ hg clone --stream -U http://localhost:$HGPORT secret-allowed
+  streaming all changes
+  1090 files to transfer, 102 KB of data (no-zstd !)
+  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
+  1090 files to transfer, 98.8 KB of data (zstd !)
+  transferred 98.8 KB in * seconds (* */sec) (glob) (zstd !)
+  searching for changes
+  no changes found
+#endif
+#if stream-bundle2
+  $ hg clone --stream -U http://localhost:$HGPORT secret-allowed
+  streaming all changes
+  1093 files to transfer, 102 KB of data (no-zstd !)
+  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
+  1093 files to transfer, 98.9 KB of data (zstd !)
+  transferred 98.9 KB in * seconds (* */sec) (glob) (zstd !)
+#endif
+
+  $ killdaemons.py
+
+Verify interaction between preferuncompressed and secret presence
+
+  $ cd server
+  $ hg serve --config server.preferuncompressed=true -p $HGPORT -d --pid-file=hg.pid
+  $ cat hg.pid > $DAEMON_PIDS
+  $ cd ..
+
+  $ hg clone -U http://localhost:$HGPORT preferuncompressed-secret
+  requesting all changes
+  adding changesets
+  adding manifests
+  adding file changes
+  added 2 changesets with 1025 changes to 1025 files
+  new changesets 96ee1d7354c4:c17445101a72
+
+  $ killdaemons.py
+
+Clone not allowed when full bundles disabled and can't serve secrets
+
+  $ cd server
+  $ hg serve --config server.disablefullbundle=true -p $HGPORT -d --pid-file=hg.pid
+  $ cat hg.pid > $DAEMON_PIDS
+  $ cd ..
+
+  $ hg clone --stream http://localhost:$HGPORT secret-full-disabled
+  warning: stream clone requested but server has them disabled
+  requesting all changes
+  remote: abort: server has pull-based clones disabled
+  abort: pull failed on remote
+  (remove --pull if specified or upgrade Mercurial)
+  [100]
+
+Local stream clone with secrets involved
+(This is just a test over behavior: if you have access to the repo's files,
+there is no security so it isn't important to prevent a clone here.)
+
+  $ hg clone -U --stream server local-secret
+  warning: stream clone requested but server has them disabled
+  requesting all changes
+  adding changesets
+  adding manifests
+  adding file changes
+  added 2 changesets with 1025 changes to 1025 files
+  new changesets 96ee1d7354c4:c17445101a72
+
+Stream clone while repo is changing:
+
+  $ mkdir changing
+  $ cd changing
+
+extension for delaying the server process so we reliably can modify the repo
+while cloning
+
+  $ cat > stream_steps.py <<EOF
+  > import os
+  > import sys
+  > from mercurial import (
+  >     encoding,
+  >     extensions,
+  >     streamclone,
+  >     testing,
+  > )
+  > WALKED_FILE_1 = encoding.environ[b'HG_TEST_STREAM_WALKED_FILE_1']
+  > WALKED_FILE_2 = encoding.environ[b'HG_TEST_STREAM_WALKED_FILE_2']
+  > 
+  > def _test_sync_point_walk_1(orig, repo):
+  >     testing.write_file(WALKED_FILE_1)
+  > 
+  > def _test_sync_point_walk_2(orig, repo):
+  >     assert repo._currentlock(repo._lockref) is None
+  >     testing.wait_file(WALKED_FILE_2)
+  > 
+  > extensions.wrapfunction(
+  >     streamclone,
+  >     '_test_sync_point_walk_1',
+  >     _test_sync_point_walk_1
+  > )
+  > extensions.wrapfunction(
+  >     streamclone,
+  >     '_test_sync_point_walk_2',
+  >     _test_sync_point_walk_2
+  > )
+  > EOF
+
+prepare repo with small and big file to cover both code paths in emitrevlogdata
+
+  $ hg init repo
+  $ touch repo/f1
+  $ $TESTDIR/seq.py 50000 > repo/f2
+  $ hg -R repo ci -Aqm "0"
+  $ HG_TEST_STREAM_WALKED_FILE_1="$TESTTMP/sync_file_walked_1"
+  $ export HG_TEST_STREAM_WALKED_FILE_1
+  $ HG_TEST_STREAM_WALKED_FILE_2="$TESTTMP/sync_file_walked_2"
+  $ export HG_TEST_STREAM_WALKED_FILE_2
+  $ HG_TEST_STREAM_WALKED_FILE_3="$TESTTMP/sync_file_walked_3"
+  $ export HG_TEST_STREAM_WALKED_FILE_3
+#   $ cat << EOF >> $HGRCPATH
+#   > [hooks]
+#   > pre-clone=rm -f "$TESTTMP/sync_file_walked_*"
+#   > EOF
+  $ hg serve -R repo -p $HGPORT1 -d --error errors.log --pid-file=hg.pid --config extensions.stream_steps="$RUNTESTDIR/testlib/ext-stream-clone-steps.py"
+  $ cat hg.pid >> $DAEMON_PIDS
+
+clone while modifying the repo between stating file with write lock and
+actually serving file content
+
+  $ (hg clone -q --stream -U http://localhost:$HGPORT1 clone; touch "$HG_TEST_STREAM_WALKED_FILE_3") &
+  $ $RUNTESTDIR/testlib/wait-on-file 10 $HG_TEST_STREAM_WALKED_FILE_1
+  $ echo >> repo/f1
+  $ echo >> repo/f2
+  $ hg -R repo ci -m "1" --config ui.timeout.warn=-1
+  $ touch $HG_TEST_STREAM_WALKED_FILE_2
+  $ $RUNTESTDIR/testlib/wait-on-file 10 $HG_TEST_STREAM_WALKED_FILE_3
+  $ hg -R clone id
+  000000000000
+  $ cat errors.log
+  $ cd ..
+
+Stream repository with bookmarks
+--------------------------------
+
+(revert introduction of secret changeset)
+
+  $ hg -R server phase --draft 'secret()'
+
+add a bookmark
+
+  $ hg -R server bookmark -r tip some-bookmark
+
+clone it
+
+#if stream-legacy
+  $ hg clone --stream http://localhost:$HGPORT with-bookmarks
+  streaming all changes
+  1090 files to transfer, 102 KB of data (no-zstd !)
+  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
+  1090 files to transfer, 98.8 KB of data (zstd !)
+  transferred 98.8 KB in * seconds (* */sec) (glob) (zstd !)
+  searching for changes
+  no changes found
+  updating to branch default
+  1088 files updated, 0 files merged, 0 files removed, 0 files unresolved
+#endif
+#if stream-bundle2
+  $ hg clone --stream http://localhost:$HGPORT with-bookmarks
+  streaming all changes
+  1096 files to transfer, 102 KB of data (no-zstd !)
+  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
+  1096 files to transfer, 99.1 KB of data (zstd !)
+  transferred 99.1 KB in * seconds (* */sec) (glob) (zstd !)
+  updating to branch default
+  1088 files updated, 0 files merged, 0 files removed, 0 files unresolved
+#endif
+  $ hg verify -R with-bookmarks
+  checking changesets
+  checking manifests
+  crosschecking files in changesets and manifests
+  checking files
+  checked 3 changesets with 1088 changes to 1088 files
+  $ hg -R with-bookmarks bookmarks
+     some-bookmark             2:5223b5e3265f
+
+Stream repository with phases
+-----------------------------
+
+Clone as publishing
+
+  $ hg -R server phase -r 'all()'
+  0: draft
+  1: draft
+  2: draft
+
+#if stream-legacy
+  $ hg clone --stream http://localhost:$HGPORT phase-publish
+  streaming all changes
+  1090 files to transfer, 102 KB of data (no-zstd !)
+  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
+  1090 files to transfer, 98.8 KB of data (zstd !)
+  transferred 98.8 KB in * seconds (* */sec) (glob) (zstd !)
+  searching for changes
+  no changes found
+  updating to branch default
+  1088 files updated, 0 files merged, 0 files removed, 0 files unresolved
+#endif
+#if stream-bundle2
+  $ hg clone --stream http://localhost:$HGPORT phase-publish
+  streaming all changes
+  1096 files to transfer, 102 KB of data (no-zstd !)
+  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
+  1096 files to transfer, 99.1 KB of data (zstd !)
+  transferred 99.1 KB in * seconds (* */sec) (glob) (zstd !)
+  updating to branch default
+  1088 files updated, 0 files merged, 0 files removed, 0 files unresolved
+#endif
+  $ hg verify -R phase-publish
+  checking changesets
+  checking manifests
+  crosschecking files in changesets and manifests
+  checking files
+  checked 3 changesets with 1088 changes to 1088 files
+  $ hg -R phase-publish phase -r 'all()'
+  0: public
+  1: public
+  2: public
+
+Clone as non publishing
+
+  $ cat << EOF >> server/.hg/hgrc
+  > [phases]
+  > publish = False
+  > EOF
+  $ killdaemons.py
+  $ hg -R server serve -p $HGPORT -d --pid-file=hg.pid
+  $ cat hg.pid > $DAEMON_PIDS
+
+#if stream-legacy
+
+With v1 of the stream protocol, changeset are always cloned as public. It make
+stream v1 unsuitable for non-publishing repository.
+
+  $ hg clone --stream http://localhost:$HGPORT phase-no-publish
+  streaming all changes
+  1090 files to transfer, 102 KB of data (no-zstd !)
+  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
+  1090 files to transfer, 98.8 KB of data (zstd !)
+  transferred 98.8 KB in * seconds (* */sec) (glob) (zstd !)
+  searching for changes
+  no changes found
+  updating to branch default
+  1088 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  $ hg -R phase-no-publish phase -r 'all()'
+  0: public
+  1: public
+  2: public
+#endif
+#if stream-bundle2
+  $ hg clone --stream http://localhost:$HGPORT phase-no-publish
+  streaming all changes
+  1097 files to transfer, 102 KB of data (no-zstd !)
+  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
+  1097 files to transfer, 99.1 KB of data (zstd !)
+  transferred 99.1 KB in * seconds (* */sec) (glob) (zstd !)
+  updating to branch default
+  1088 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  $ hg -R phase-no-publish phase -r 'all()'
+  0: draft
+  1: draft
+  2: draft
+#endif
+  $ hg verify -R phase-no-publish
+  checking changesets
+  checking manifests
+  crosschecking files in changesets and manifests
+  checking files
+  checked 3 changesets with 1088 changes to 1088 files
+
+  $ killdaemons.py
+
+#if stream-legacy
+
+With v1 of the stream protocol, changeset are always cloned as public. There's
+no obsolescence markers exchange in stream v1.
+
+#endif
+#if stream-bundle2
+
+Stream repository with obsolescence
+-----------------------------------
+
+Clone non-publishing with obsolescence
+
+  $ cat >> $HGRCPATH << EOF
+  > [experimental]
+  > evolution=all
+  > EOF
+
+  $ cd server
+  $ echo foo > foo
+  $ hg -q commit -m 'about to be pruned'
+  $ hg debugobsolete `hg log -r . -T '{node}'` -d '0 0' -u test --record-parents
+  1 new obsolescence markers
+  obsoleted 1 changesets
+  $ hg up null -q
+  $ hg log -T '{rev}: {phase}\n'
+  2: draft
+  1: draft
+  0: draft
+  $ hg serve -p $HGPORT -d --pid-file=hg.pid
+  $ cat hg.pid > $DAEMON_PIDS
+  $ cd ..
+
+  $ hg clone -U --stream http://localhost:$HGPORT with-obsolescence
+  streaming all changes
+  1098 files to transfer, 102 KB of data (no-zstd !)
+  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
+  1098 files to transfer, 99.5 KB of data (zstd !)
+  transferred 99.5 KB in * seconds (* */sec) (glob) (zstd !)
+  $ hg -R with-obsolescence log -T '{rev}: {phase}\n'
+  2: draft
+  1: draft
+  0: draft
+  $ hg debugobsolete -R with-obsolescence
+  8c206a663911c1f97f2f9d7382e417ae55872cfa 0 {5223b5e3265f0df40bb743da62249413d74ac70f} (Thu Jan 01 00:00:00 1970 +0000) {'user': 'test'}
+  $ hg verify -R with-obsolescence
+  checking changesets
+  checking manifests
+  crosschecking files in changesets and manifests
+  checking files
+  checked 4 changesets with 1089 changes to 1088 files
+
+  $ hg clone -U --stream --config experimental.evolution=0 http://localhost:$HGPORT with-obsolescence-no-evolution
+  streaming all changes
+  remote: abort: server has obsolescence markers, but client cannot receive them via stream clone
+  abort: pull failed on remote
+  [100]
+
+  $ killdaemons.py
+
+#endif
--- a/tests/test-clone-uncompressed.t	Thu Oct 21 14:03:33 2021 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,904 +0,0 @@
-#require serve no-reposimplestore no-chg
-
-#testcases stream-legacy stream-bundle2
-
-#if stream-legacy
-  $ cat << EOF >> $HGRCPATH
-  > [server]
-  > bundle2.stream = no
-  > EOF
-#endif
-
-Initialize repository
-the status call is to check for issue5130
-
-  $ hg init server
-  $ cd server
-  $ touch foo
-  $ hg -q commit -A -m initial
-  >>> for i in range(1024):
-  ...     with open(str(i), 'wb') as fh:
-  ...         fh.write(b"%d" % i) and None
-  $ hg -q commit -A -m 'add a lot of files'
-  $ hg st
-
-add files with "tricky" name:
-
-  $ echo foo > 00changelog.i
-  $ echo foo > 00changelog.d
-  $ echo foo > 00changelog.n
-  $ echo foo > 00changelog-ab349180a0405010.nd
-  $ echo foo > 00manifest.i
-  $ echo foo > 00manifest.d
-  $ echo foo > foo.i
-  $ echo foo > foo.d
-  $ echo foo > foo.n
-  $ echo foo > undo.py
-  $ echo foo > undo.i
-  $ echo foo > undo.d
-  $ echo foo > undo.n
-  $ echo foo > undo.foo.i
-  $ echo foo > undo.foo.d
-  $ echo foo > undo.foo.n
-  $ echo foo > undo.babar
-  $ mkdir savanah
-  $ echo foo > savanah/foo.i
-  $ echo foo > savanah/foo.d
-  $ echo foo > savanah/foo.n
-  $ echo foo > savanah/undo.py
-  $ echo foo > savanah/undo.i
-  $ echo foo > savanah/undo.d
-  $ echo foo > savanah/undo.n
-  $ echo foo > savanah/undo.foo.i
-  $ echo foo > savanah/undo.foo.d
-  $ echo foo > savanah/undo.foo.n
-  $ echo foo > savanah/undo.babar
-  $ mkdir data
-  $ echo foo > data/foo.i
-  $ echo foo > data/foo.d
-  $ echo foo > data/foo.n
-  $ echo foo > data/undo.py
-  $ echo foo > data/undo.i
-  $ echo foo > data/undo.d
-  $ echo foo > data/undo.n
-  $ echo foo > data/undo.foo.i
-  $ echo foo > data/undo.foo.d
-  $ echo foo > data/undo.foo.n
-  $ echo foo > data/undo.babar
-  $ mkdir meta
-  $ echo foo > meta/foo.i
-  $ echo foo > meta/foo.d
-  $ echo foo > meta/foo.n
-  $ echo foo > meta/undo.py
-  $ echo foo > meta/undo.i
-  $ echo foo > meta/undo.d
-  $ echo foo > meta/undo.n
-  $ echo foo > meta/undo.foo.i
-  $ echo foo > meta/undo.foo.d
-  $ echo foo > meta/undo.foo.n
-  $ echo foo > meta/undo.babar
-  $ mkdir store
-  $ echo foo > store/foo.i
-  $ echo foo > store/foo.d
-  $ echo foo > store/foo.n
-  $ echo foo > store/undo.py
-  $ echo foo > store/undo.i
-  $ echo foo > store/undo.d
-  $ echo foo > store/undo.n
-  $ echo foo > store/undo.foo.i
-  $ echo foo > store/undo.foo.d
-  $ echo foo > store/undo.foo.n
-  $ echo foo > store/undo.babar
-
-Name with special characters
-
-  $ echo foo > store/CélesteVille_is_a_Capital_City
-
-name causing issue6581
-
-  $ mkdir --parents container/isam-build-centos7/
-  $ touch container/isam-build-centos7/bazel-coverage-generator-sandboxfs-compatibility-0758e3e4f6057904d44399bd666faba9e7f40686.patch
-
-Add all that
-
-  $ hg add .
-  adding 00changelog-ab349180a0405010.nd
-  adding 00changelog.d
-  adding 00changelog.i
-  adding 00changelog.n
-  adding 00manifest.d
-  adding 00manifest.i
-  adding container/isam-build-centos7/bazel-coverage-generator-sandboxfs-compatibility-0758e3e4f6057904d44399bd666faba9e7f40686.patch
-  adding data/foo.d
-  adding data/foo.i
-  adding data/foo.n
-  adding data/undo.babar
-  adding data/undo.d
-  adding data/undo.foo.d
-  adding data/undo.foo.i
-  adding data/undo.foo.n
-  adding data/undo.i
-  adding data/undo.n
-  adding data/undo.py
-  adding foo.d
-  adding foo.i
-  adding foo.n
-  adding meta/foo.d
-  adding meta/foo.i
-  adding meta/foo.n
-  adding meta/undo.babar
-  adding meta/undo.d
-  adding meta/undo.foo.d
-  adding meta/undo.foo.i
-  adding meta/undo.foo.n
-  adding meta/undo.i
-  adding meta/undo.n
-  adding meta/undo.py
-  adding savanah/foo.d
-  adding savanah/foo.i
-  adding savanah/foo.n
-  adding savanah/undo.babar
-  adding savanah/undo.d
-  adding savanah/undo.foo.d
-  adding savanah/undo.foo.i
-  adding savanah/undo.foo.n
-  adding savanah/undo.i
-  adding savanah/undo.n
-  adding savanah/undo.py
-  adding store/C\xc3\xa9lesteVille_is_a_Capital_City (esc)
-  adding store/foo.d
-  adding store/foo.i
-  adding store/foo.n
-  adding store/undo.babar
-  adding store/undo.d
-  adding store/undo.foo.d
-  adding store/undo.foo.i
-  adding store/undo.foo.n
-  adding store/undo.i
-  adding store/undo.n
-  adding store/undo.py
-  adding undo.babar
-  adding undo.d
-  adding undo.foo.d
-  adding undo.foo.i
-  adding undo.foo.n
-  adding undo.i
-  adding undo.n
-  adding undo.py
-  $ hg ci -m 'add files with "tricky" name'
-  $ hg --config server.uncompressed=false serve -p $HGPORT -d --pid-file=hg.pid
-  $ cat hg.pid > $DAEMON_PIDS
-  $ cd ..
-
-Check local clone
-==================
-
-The logic is close enough of uncompressed.
-This is present here to reuse the testing around file with "special" names.
-
-  $ hg clone server local-clone
-  updating to branch default
-  1088 files updated, 0 files merged, 0 files removed, 0 files unresolved
-
-Check that the clone went well
-
-  $ hg verify -R local-clone
-  checking changesets
-  checking manifests
-  crosschecking files in changesets and manifests
-  checking files
-  checked 3 changesets with 1088 changes to 1088 files
-
-Check uncompressed
-==================
-
-Cannot stream clone when server.uncompressed is set
-
-  $ get-with-headers.py $LOCALIP:$HGPORT '?cmd=stream_out'
-  200 Script output follows
-  
-  1
-
-#if stream-legacy
-  $ hg debugcapabilities http://localhost:$HGPORT
-  Main capabilities:
-    batch
-    branchmap
-    $USUAL_BUNDLE2_CAPS_SERVER$
-    changegroupsubset
-    compression=$BUNDLE2_COMPRESSIONS$
-    getbundle
-    httpheader=1024
-    httpmediatype=0.1rx,0.1tx,0.2tx
-    known
-    lookup
-    pushkey
-    unbundle=HG10GZ,HG10BZ,HG10UN
-    unbundlehash
-  Bundle2 capabilities:
-    HG20
-    bookmarks
-    changegroup
-      01
-      02
-    checkheads
-      related
-    digests
-      md5
-      sha1
-      sha512
-    error
-      abort
-      unsupportedcontent
-      pushraced
-      pushkey
-    hgtagsfnodes
-    listkeys
-    phases
-      heads
-    pushkey
-    remote-changegroup
-      http
-      https
-
-  $ hg clone --stream -U http://localhost:$HGPORT server-disabled
-  warning: stream clone requested but server has them disabled
-  requesting all changes
-  adding changesets
-  adding manifests
-  adding file changes
-  added 3 changesets with 1088 changes to 1088 files
-  new changesets 96ee1d7354c4:5223b5e3265f
-
-  $ get-with-headers.py $LOCALIP:$HGPORT '?cmd=getbundle' content-type --bodyfile body --hgproto 0.2 --requestheader "x-hgarg-1=bundlecaps=HG20%2Cbundle2%3DHG20%250Abookmarks%250Achangegroup%253D01%252C02%250Adigests%253Dmd5%252Csha1%252Csha512%250Aerror%253Dabort%252Cunsupportedcontent%252Cpushraced%252Cpushkey%250Ahgtagsfnodes%250Alistkeys%250Aphases%253Dheads%250Apushkey%250Aremote-changegroup%253Dhttp%252Chttps&cg=0&common=0000000000000000000000000000000000000000&heads=c17445101a72edac06facd130d14808dfbd5c7c2&stream=1"
-  200 Script output follows
-  content-type: application/mercurial-0.2
-  
-
-  $ f --size body --hexdump --bytes 100
-  body: size=232
-  0000: 04 6e 6f 6e 65 48 47 32 30 00 00 00 00 00 00 00 |.noneHG20.......|
-  0010: cf 0b 45 52 52 4f 52 3a 41 42 4f 52 54 00 00 00 |..ERROR:ABORT...|
-  0020: 00 01 01 07 3c 04 72 6d 65 73 73 61 67 65 73 74 |....<.rmessagest|
-  0030: 72 65 61 6d 20 64 61 74 61 20 72 65 71 75 65 73 |ream data reques|
-  0040: 74 65 64 20 62 75 74 20 73 65 72 76 65 72 20 64 |ted but server d|
-  0050: 6f 65 73 20 6e 6f 74 20 61 6c 6c 6f 77 20 74 68 |oes not allow th|
-  0060: 69 73 20 66                                     |is f|
-
-#endif
-#if stream-bundle2
-  $ hg debugcapabilities http://localhost:$HGPORT
-  Main capabilities:
-    batch
-    branchmap
-    $USUAL_BUNDLE2_CAPS_SERVER$
-    changegroupsubset
-    compression=$BUNDLE2_COMPRESSIONS$
-    getbundle
-    httpheader=1024
-    httpmediatype=0.1rx,0.1tx,0.2tx
-    known
-    lookup
-    pushkey
-    unbundle=HG10GZ,HG10BZ,HG10UN
-    unbundlehash
-  Bundle2 capabilities:
-    HG20
-    bookmarks
-    changegroup
-      01
-      02
-    checkheads
-      related
-    digests
-      md5
-      sha1
-      sha512
-    error
-      abort
-      unsupportedcontent
-      pushraced
-      pushkey
-    hgtagsfnodes
-    listkeys
-    phases
-      heads
-    pushkey
-    remote-changegroup
-      http
-      https
-
-  $ hg clone --stream -U http://localhost:$HGPORT server-disabled
-  warning: stream clone requested but server has them disabled
-  requesting all changes
-  adding changesets
-  adding manifests
-  adding file changes
-  added 3 changesets with 1088 changes to 1088 files
-  new changesets 96ee1d7354c4:5223b5e3265f
-
-  $ get-with-headers.py $LOCALIP:$HGPORT '?cmd=getbundle' content-type --bodyfile body --hgproto 0.2 --requestheader "x-hgarg-1=bundlecaps=HG20%2Cbundle2%3DHG20%250Abookmarks%250Achangegroup%253D01%252C02%250Adigests%253Dmd5%252Csha1%252Csha512%250Aerror%253Dabort%252Cunsupportedcontent%252Cpushraced%252Cpushkey%250Ahgtagsfnodes%250Alistkeys%250Aphases%253Dheads%250Apushkey%250Aremote-changegroup%253Dhttp%252Chttps&cg=0&common=0000000000000000000000000000000000000000&heads=c17445101a72edac06facd130d14808dfbd5c7c2&stream=1"
-  200 Script output follows
-  content-type: application/mercurial-0.2
-  
-
-  $ f --size body --hexdump --bytes 100
-  body: size=232
-  0000: 04 6e 6f 6e 65 48 47 32 30 00 00 00 00 00 00 00 |.noneHG20.......|
-  0010: cf 0b 45 52 52 4f 52 3a 41 42 4f 52 54 00 00 00 |..ERROR:ABORT...|
-  0020: 00 01 01 07 3c 04 72 6d 65 73 73 61 67 65 73 74 |....<.rmessagest|
-  0030: 72 65 61 6d 20 64 61 74 61 20 72 65 71 75 65 73 |ream data reques|
-  0040: 74 65 64 20 62 75 74 20 73 65 72 76 65 72 20 64 |ted but server d|
-  0050: 6f 65 73 20 6e 6f 74 20 61 6c 6c 6f 77 20 74 68 |oes not allow th|
-  0060: 69 73 20 66                                     |is f|
-
-#endif
-
-  $ killdaemons.py
-  $ cd server
-  $ hg serve -p $HGPORT -d --pid-file=hg.pid --error errors.txt
-  $ cat hg.pid > $DAEMON_PIDS
-  $ cd ..
-
-Basic clone
-
-#if stream-legacy
-  $ hg clone --stream -U http://localhost:$HGPORT clone1
-  streaming all changes
-  1090 files to transfer, 102 KB of data (no-zstd !)
-  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
-  1090 files to transfer, 98.8 KB of data (zstd !)
-  transferred 98.8 KB in * seconds (* */sec) (glob) (zstd !)
-  searching for changes
-  no changes found
-  $ cat server/errors.txt
-#endif
-#if stream-bundle2
-  $ hg clone --stream -U http://localhost:$HGPORT clone1
-  streaming all changes
-  1093 files to transfer, 102 KB of data (no-zstd !)
-  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
-  1093 files to transfer, 98.9 KB of data (zstd !)
-  transferred 98.9 KB in * seconds (* */sec) (glob) (zstd !)
-
-  $ ls -1 clone1/.hg/cache
-  branch2-base
-  branch2-immutable
-  branch2-served
-  branch2-served.hidden
-  branch2-visible
-  branch2-visible-hidden
-  rbc-names-v1
-  rbc-revs-v1
-  tags2
-  tags2-served
-  $ cat server/errors.txt
-#endif
-
-getbundle requests with stream=1 are uncompressed
-
-  $ get-with-headers.py $LOCALIP:$HGPORT '?cmd=getbundle' content-type --bodyfile body --hgproto '0.1 0.2 comp=zlib,none' --requestheader "x-hgarg-1=bundlecaps=HG20%2Cbundle2%3DHG20%250Abookmarks%250Achangegroup%253D01%252C02%250Adigests%253Dmd5%252Csha1%252Csha512%250Aerror%253Dabort%252Cunsupportedcontent%252Cpushraced%252Cpushkey%250Ahgtagsfnodes%250Alistkeys%250Aphases%253Dheads%250Apushkey%250Aremote-changegroup%253Dhttp%252Chttps&cg=0&common=0000000000000000000000000000000000000000&heads=c17445101a72edac06facd130d14808dfbd5c7c2&stream=1"
-  200 Script output follows
-  content-type: application/mercurial-0.2
-  
-
-#if no-zstd no-rust
-  $ f --size --hex --bytes 256 body
-  body: size=119153
-  0000: 04 6e 6f 6e 65 48 47 32 30 00 00 00 00 00 00 00 |.noneHG20.......|
-  0010: 80 07 53 54 52 45 41 4d 32 00 00 00 00 03 00 09 |..STREAM2.......|
-  0020: 06 09 04 0c 44 62 79 74 65 63 6f 75 6e 74 31 30 |....Dbytecount10|
-  0030: 34 31 31 35 66 69 6c 65 63 6f 75 6e 74 31 30 39 |4115filecount109|
-  0040: 33 72 65 71 75 69 72 65 6d 65 6e 74 73 64 6f 74 |3requirementsdot|
-  0050: 65 6e 63 6f 64 65 25 32 43 66 6e 63 61 63 68 65 |encode%2Cfncache|
-  0060: 25 32 43 67 65 6e 65 72 61 6c 64 65 6c 74 61 25 |%2Cgeneraldelta%|
-  0070: 32 43 72 65 76 6c 6f 67 76 31 25 32 43 73 70 61 |2Crevlogv1%2Cspa|
-  0080: 72 73 65 72 65 76 6c 6f 67 25 32 43 73 74 6f 72 |rserevlog%2Cstor|
-  0090: 65 00 00 80 00 73 08 42 64 61 74 61 2f 30 2e 69 |e....s.Bdata/0.i|
-  00a0: 00 03 00 01 00 00 00 00 00 00 00 02 00 00 00 01 |................|
-  00b0: 00 00 00 00 00 00 00 01 ff ff ff ff ff ff ff ff |................|
-  00c0: 80 29 63 a0 49 d3 23 87 bf ce fe 56 67 92 67 2c |.)c.I.#....Vg.g,|
-  00d0: 69 d1 ec 39 00 00 00 00 00 00 00 00 00 00 00 00 |i..9............|
-  00e0: 75 30 73 26 45 64 61 74 61 2f 30 30 63 68 61 6e |u0s&Edata/00chan|
-  00f0: 67 65 6c 6f 67 2d 61 62 33 34 39 31 38 30 61 30 |gelog-ab349180a0|
-#endif
-#if zstd no-rust
-  $ f --size --hex --bytes 256 body
-  body: size=116340
-  0000: 04 6e 6f 6e 65 48 47 32 30 00 00 00 00 00 00 00 |.noneHG20.......|
-  0010: 9a 07 53 54 52 45 41 4d 32 00 00 00 00 03 00 09 |..STREAM2.......|
-  0020: 06 09 04 0c 5e 62 79 74 65 63 6f 75 6e 74 31 30 |....^bytecount10|
-  0030: 31 32 37 36 66 69 6c 65 63 6f 75 6e 74 31 30 39 |1276filecount109|
-  0040: 33 72 65 71 75 69 72 65 6d 65 6e 74 73 64 6f 74 |3requirementsdot|
-  0050: 65 6e 63 6f 64 65 25 32 43 66 6e 63 61 63 68 65 |encode%2Cfncache|
-  0060: 25 32 43 67 65 6e 65 72 61 6c 64 65 6c 74 61 25 |%2Cgeneraldelta%|
-  0070: 32 43 72 65 76 6c 6f 67 2d 63 6f 6d 70 72 65 73 |2Crevlog-compres|
-  0080: 73 69 6f 6e 2d 7a 73 74 64 25 32 43 72 65 76 6c |sion-zstd%2Crevl|
-  0090: 6f 67 76 31 25 32 43 73 70 61 72 73 65 72 65 76 |ogv1%2Csparserev|
-  00a0: 6c 6f 67 25 32 43 73 74 6f 72 65 00 00 80 00 73 |log%2Cstore....s|
-  00b0: 08 42 64 61 74 61 2f 30 2e 69 00 03 00 01 00 00 |.Bdata/0.i......|
-  00c0: 00 00 00 00 00 02 00 00 00 01 00 00 00 00 00 00 |................|
-  00d0: 00 01 ff ff ff ff ff ff ff ff 80 29 63 a0 49 d3 |...........)c.I.|
-  00e0: 23 87 bf ce fe 56 67 92 67 2c 69 d1 ec 39 00 00 |#....Vg.g,i..9..|
-  00f0: 00 00 00 00 00 00 00 00 00 00 75 30 73 26 45 64 |..........u0s&Ed|
-#endif
-#if zstd rust no-dirstate-v2
-  $ f --size --hex --bytes 256 body
-  body: size=116361
-  0000: 04 6e 6f 6e 65 48 47 32 30 00 00 00 00 00 00 00 |.noneHG20.......|
-  0010: af 07 53 54 52 45 41 4d 32 00 00 00 00 03 00 09 |..STREAM2.......|
-  0020: 06 09 04 0c 73 62 79 74 65 63 6f 75 6e 74 31 30 |....sbytecount10|
-  0030: 31 32 37 36 66 69 6c 65 63 6f 75 6e 74 31 30 39 |1276filecount109|
-  0040: 33 72 65 71 75 69 72 65 6d 65 6e 74 73 64 6f 74 |3requirementsdot|
-  0050: 65 6e 63 6f 64 65 25 32 43 66 6e 63 61 63 68 65 |encode%2Cfncache|
-  0060: 25 32 43 67 65 6e 65 72 61 6c 64 65 6c 74 61 25 |%2Cgeneraldelta%|
-  0070: 32 43 70 65 72 73 69 73 74 65 6e 74 2d 6e 6f 64 |2Cpersistent-nod|
-  0080: 65 6d 61 70 25 32 43 72 65 76 6c 6f 67 2d 63 6f |emap%2Crevlog-co|
-  0090: 6d 70 72 65 73 73 69 6f 6e 2d 7a 73 74 64 25 32 |mpression-zstd%2|
-  00a0: 43 72 65 76 6c 6f 67 76 31 25 32 43 73 70 61 72 |Crevlogv1%2Cspar|
-  00b0: 73 65 72 65 76 6c 6f 67 25 32 43 73 74 6f 72 65 |serevlog%2Cstore|
-  00c0: 00 00 80 00 73 08 42 64 61 74 61 2f 30 2e 69 00 |....s.Bdata/0.i.|
-  00d0: 03 00 01 00 00 00 00 00 00 00 02 00 00 00 01 00 |................|
-  00e0: 00 00 00 00 00 00 01 ff ff ff ff ff ff ff ff 80 |................|
-  00f0: 29 63 a0 49 d3 23 87 bf ce fe 56 67 92 67 2c 69 |)c.I.#....Vg.g,i|
-#endif
-#if zstd dirstate-v2
-  $ f --size --hex --bytes 256 body
-  body: size=109549
-  0000: 04 6e 6f 6e 65 48 47 32 30 00 00 00 00 00 00 00 |.noneHG20.......|
-  0010: c0 07 53 54 52 45 41 4d 32 00 00 00 00 03 00 09 |..STREAM2.......|
-  0020: 05 09 04 0c 85 62 79 74 65 63 6f 75 6e 74 39 35 |.....bytecount95|
-  0030: 38 39 37 66 69 6c 65 63 6f 75 6e 74 31 30 33 30 |897filecount1030|
-  0040: 72 65 71 75 69 72 65 6d 65 6e 74 73 64 6f 74 65 |requirementsdote|
-  0050: 6e 63 6f 64 65 25 32 43 65 78 70 2d 64 69 72 73 |ncode%2Cexp-dirs|
-  0060: 74 61 74 65 2d 76 32 25 32 43 66 6e 63 61 63 68 |tate-v2%2Cfncach|
-  0070: 65 25 32 43 67 65 6e 65 72 61 6c 64 65 6c 74 61 |e%2Cgeneraldelta|
-  0080: 25 32 43 70 65 72 73 69 73 74 65 6e 74 2d 6e 6f |%2Cpersistent-no|
-  0090: 64 65 6d 61 70 25 32 43 72 65 76 6c 6f 67 2d 63 |demap%2Crevlog-c|
-  00a0: 6f 6d 70 72 65 73 73 69 6f 6e 2d 7a 73 74 64 25 |ompression-zstd%|
-  00b0: 32 43 72 65 76 6c 6f 67 76 31 25 32 43 73 70 61 |2Crevlogv1%2Cspa|
-  00c0: 72 73 65 72 65 76 6c 6f 67 25 32 43 73 74 6f 72 |rserevlog%2Cstor|
-  00d0: 65 00 00 80 00 73 08 42 64 61 74 61 2f 30 2e 69 |e....s.Bdata/0.i|
-  00e0: 00 03 00 01 00 00 00 00 00 00 00 02 00 00 00 01 |................|
-  00f0: 00 00 00 00 00 00 00 01 ff ff ff ff ff ff ff ff |................|
-#endif
-
---uncompressed is an alias to --stream
-
-#if stream-legacy
-  $ hg clone --uncompressed -U http://localhost:$HGPORT clone1-uncompressed
-  streaming all changes
-  1090 files to transfer, 102 KB of data (no-zstd !)
-  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
-  1090 files to transfer, 98.8 KB of data (zstd !)
-  transferred 98.8 KB in * seconds (* */sec) (glob) (zstd !)
-  searching for changes
-  no changes found
-#endif
-#if stream-bundle2
-  $ hg clone --uncompressed -U http://localhost:$HGPORT clone1-uncompressed
-  streaming all changes
-  1093 files to transfer, 102 KB of data (no-zstd !)
-  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
-  1093 files to transfer, 98.9 KB of data (zstd !)
-  transferred 98.9 KB in * seconds (* */sec) (glob) (zstd !)
-#endif
-
-Clone with background file closing enabled
-
-#if stream-legacy
-  $ hg --debug --config worker.backgroundclose=true --config worker.backgroundcloseminfilecount=1 clone --stream -U http://localhost:$HGPORT clone-background | grep -v adding
-  using http://localhost:$HGPORT/
-  sending capabilities command
-  sending branchmap command
-  streaming all changes
-  sending stream_out command
-  1090 files to transfer, 102 KB of data (no-zstd !)
-  1090 files to transfer, 98.8 KB of data (zstd !)
-  starting 4 threads for background file closing
-  updating the branch cache
-  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
-  transferred 98.8 KB in * seconds (* */sec) (glob) (zstd !)
-  query 1; heads
-  sending batch command
-  searching for changes
-  all remote heads known locally
-  no changes found
-  sending getbundle command
-  bundle2-input-bundle: with-transaction
-  bundle2-input-part: "listkeys" (params: 1 mandatory) supported
-  bundle2-input-part: "phase-heads" supported
-  bundle2-input-part: total payload size 24
-  bundle2-input-bundle: 2 parts total
-  checking for updated bookmarks
-  updating the branch cache
-  (sent 5 HTTP requests and * bytes; received * bytes in responses) (glob)
-#endif
-#if stream-bundle2
-  $ hg --debug --config worker.backgroundclose=true --config worker.backgroundcloseminfilecount=1 clone --stream -U http://localhost:$HGPORT clone-background | grep -v adding
-  using http://localhost:$HGPORT/
-  sending capabilities command
-  query 1; heads
-  sending batch command
-  streaming all changes
-  sending getbundle command
-  bundle2-input-bundle: with-transaction
-  bundle2-input-part: "stream2" (params: 3 mandatory) supported
-  applying stream bundle
-  1093 files to transfer, 102 KB of data (no-zstd !)
-  1093 files to transfer, 98.9 KB of data (zstd !)
-  starting 4 threads for background file closing
-  starting 4 threads for background file closing
-  updating the branch cache
-  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
-  bundle2-input-part: total payload size 118984 (no-zstd !)
-  transferred 98.9 KB in * seconds (* */sec) (glob) (zstd !)
-  bundle2-input-part: total payload size 116145 (zstd !)
-  bundle2-input-part: "listkeys" (params: 1 mandatory) supported
-  bundle2-input-bundle: 2 parts total
-  checking for updated bookmarks
-  updating the branch cache
-  (sent 3 HTTP requests and * bytes; received * bytes in responses) (glob)
-#endif
-
-Cannot stream clone when there are secret changesets
-
-  $ hg -R server phase --force --secret -r tip
-  $ hg clone --stream -U http://localhost:$HGPORT secret-denied
-  warning: stream clone requested but server has them disabled
-  requesting all changes
-  adding changesets
-  adding manifests
-  adding file changes
-  added 2 changesets with 1025 changes to 1025 files
-  new changesets 96ee1d7354c4:c17445101a72
-
-  $ killdaemons.py
-
-Streaming of secrets can be overridden by server config
-
-  $ cd server
-  $ hg serve --config server.uncompressedallowsecret=true -p $HGPORT -d --pid-file=hg.pid
-  $ cat hg.pid > $DAEMON_PIDS
-  $ cd ..
-
-#if stream-legacy
-  $ hg clone --stream -U http://localhost:$HGPORT secret-allowed
-  streaming all changes
-  1090 files to transfer, 102 KB of data (no-zstd !)
-  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
-  1090 files to transfer, 98.8 KB of data (zstd !)
-  transferred 98.8 KB in * seconds (* */sec) (glob) (zstd !)
-  searching for changes
-  no changes found
-#endif
-#if stream-bundle2
-  $ hg clone --stream -U http://localhost:$HGPORT secret-allowed
-  streaming all changes
-  1093 files to transfer, 102 KB of data (no-zstd !)
-  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
-  1093 files to transfer, 98.9 KB of data (zstd !)
-  transferred 98.9 KB in * seconds (* */sec) (glob) (zstd !)
-#endif
-
-  $ killdaemons.py
-
-Verify interaction between preferuncompressed and secret presence
-
-  $ cd server
-  $ hg serve --config server.preferuncompressed=true -p $HGPORT -d --pid-file=hg.pid
-  $ cat hg.pid > $DAEMON_PIDS
-  $ cd ..
-
-  $ hg clone -U http://localhost:$HGPORT preferuncompressed-secret
-  requesting all changes
-  adding changesets
-  adding manifests
-  adding file changes
-  added 2 changesets with 1025 changes to 1025 files
-  new changesets 96ee1d7354c4:c17445101a72
-
-  $ killdaemons.py
-
-Clone not allowed when full bundles disabled and can't serve secrets
-
-  $ cd server
-  $ hg serve --config server.disablefullbundle=true -p $HGPORT -d --pid-file=hg.pid
-  $ cat hg.pid > $DAEMON_PIDS
-  $ cd ..
-
-  $ hg clone --stream http://localhost:$HGPORT secret-full-disabled
-  warning: stream clone requested but server has them disabled
-  requesting all changes
-  remote: abort: server has pull-based clones disabled
-  abort: pull failed on remote
-  (remove --pull if specified or upgrade Mercurial)
-  [100]
-
-Local stream clone with secrets involved
-(This is just a test over behavior: if you have access to the repo's files,
-there is no security so it isn't important to prevent a clone here.)
-
-  $ hg clone -U --stream server local-secret
-  warning: stream clone requested but server has them disabled
-  requesting all changes
-  adding changesets
-  adding manifests
-  adding file changes
-  added 2 changesets with 1025 changes to 1025 files
-  new changesets 96ee1d7354c4:c17445101a72
-
-Stream clone while repo is changing:
-
-  $ mkdir changing
-  $ cd changing
-
-extension for delaying the server process so we reliably can modify the repo
-while cloning
-
-  $ cat > stream_steps.py <<EOF
-  > import os
-  > import sys
-  > from mercurial import (
-  >     encoding,
-  >     extensions,
-  >     streamclone,
-  >     testing,
-  > )
-  > WALKED_FILE_1 = encoding.environ[b'HG_TEST_STREAM_WALKED_FILE_1']
-  > WALKED_FILE_2 = encoding.environ[b'HG_TEST_STREAM_WALKED_FILE_2']
-  > 
-  > def _test_sync_point_walk_1(orig, repo):
-  >     testing.write_file(WALKED_FILE_1)
-  > 
-  > def _test_sync_point_walk_2(orig, repo):
-  >     assert repo._currentlock(repo._lockref) is None
-  >     testing.wait_file(WALKED_FILE_2)
-  > 
-  > extensions.wrapfunction(
-  >     streamclone,
-  >     '_test_sync_point_walk_1',
-  >     _test_sync_point_walk_1
-  > )
-  > extensions.wrapfunction(
-  >     streamclone,
-  >     '_test_sync_point_walk_2',
-  >     _test_sync_point_walk_2
-  > )
-  > EOF
-
-prepare repo with small and big file to cover both code paths in emitrevlogdata
-
-  $ hg init repo
-  $ touch repo/f1
-  $ $TESTDIR/seq.py 50000 > repo/f2
-  $ hg -R repo ci -Aqm "0"
-  $ HG_TEST_STREAM_WALKED_FILE_1="$TESTTMP/sync_file_walked_1"
-  $ export HG_TEST_STREAM_WALKED_FILE_1
-  $ HG_TEST_STREAM_WALKED_FILE_2="$TESTTMP/sync_file_walked_2"
-  $ export HG_TEST_STREAM_WALKED_FILE_2
-  $ HG_TEST_STREAM_WALKED_FILE_3="$TESTTMP/sync_file_walked_3"
-  $ export HG_TEST_STREAM_WALKED_FILE_3
-#   $ cat << EOF >> $HGRCPATH
-#   > [hooks]
-#   > pre-clone=rm -f "$TESTTMP/sync_file_walked_*"
-#   > EOF
-  $ hg serve -R repo -p $HGPORT1 -d --error errors.log --pid-file=hg.pid --config extensions.stream_steps="$RUNTESTDIR/testlib/ext-stream-clone-steps.py"
-  $ cat hg.pid >> $DAEMON_PIDS
-
-clone while modifying the repo between stating file with write lock and
-actually serving file content
-
-  $ (hg clone -q --stream -U http://localhost:$HGPORT1 clone; touch "$HG_TEST_STREAM_WALKED_FILE_3") &
-  $ $RUNTESTDIR/testlib/wait-on-file 10 $HG_TEST_STREAM_WALKED_FILE_1
-  $ echo >> repo/f1
-  $ echo >> repo/f2
-  $ hg -R repo ci -m "1" --config ui.timeout.warn=-1
-  $ touch $HG_TEST_STREAM_WALKED_FILE_2
-  $ $RUNTESTDIR/testlib/wait-on-file 10 $HG_TEST_STREAM_WALKED_FILE_3
-  $ hg -R clone id
-  000000000000
-  $ cat errors.log
-  $ cd ..
-
-Stream repository with bookmarks
---------------------------------
-
-(revert introduction of secret changeset)
-
-  $ hg -R server phase --draft 'secret()'
-
-add a bookmark
-
-  $ hg -R server bookmark -r tip some-bookmark
-
-clone it
-
-#if stream-legacy
-  $ hg clone --stream http://localhost:$HGPORT with-bookmarks
-  streaming all changes
-  1090 files to transfer, 102 KB of data (no-zstd !)
-  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
-  1090 files to transfer, 98.8 KB of data (zstd !)
-  transferred 98.8 KB in * seconds (* */sec) (glob) (zstd !)
-  searching for changes
-  no changes found
-  updating to branch default
-  1088 files updated, 0 files merged, 0 files removed, 0 files unresolved
-#endif
-#if stream-bundle2
-  $ hg clone --stream http://localhost:$HGPORT with-bookmarks
-  streaming all changes
-  1096 files to transfer, 102 KB of data (no-zstd !)
-  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
-  1096 files to transfer, 99.1 KB of data (zstd !)
-  transferred 99.1 KB in * seconds (* */sec) (glob) (zstd !)
-  updating to branch default
-  1088 files updated, 0 files merged, 0 files removed, 0 files unresolved
-#endif
-  $ hg verify -R with-bookmarks
-  checking changesets
-  checking manifests
-  crosschecking files in changesets and manifests
-  checking files
-  checked 3 changesets with 1088 changes to 1088 files
-  $ hg -R with-bookmarks bookmarks
-     some-bookmark             2:5223b5e3265f
-
-Stream repository with phases
------------------------------
-
-Clone as publishing
-
-  $ hg -R server phase -r 'all()'
-  0: draft
-  1: draft
-  2: draft
-
-#if stream-legacy
-  $ hg clone --stream http://localhost:$HGPORT phase-publish
-  streaming all changes
-  1090 files to transfer, 102 KB of data (no-zstd !)
-  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
-  1090 files to transfer, 98.8 KB of data (zstd !)
-  transferred 98.8 KB in * seconds (* */sec) (glob) (zstd !)
-  searching for changes
-  no changes found
-  updating to branch default
-  1088 files updated, 0 files merged, 0 files removed, 0 files unresolved
-#endif
-#if stream-bundle2
-  $ hg clone --stream http://localhost:$HGPORT phase-publish
-  streaming all changes
-  1096 files to transfer, 102 KB of data (no-zstd !)
-  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
-  1096 files to transfer, 99.1 KB of data (zstd !)
-  transferred 99.1 KB in * seconds (* */sec) (glob) (zstd !)
-  updating to branch default
-  1088 files updated, 0 files merged, 0 files removed, 0 files unresolved
-#endif
-  $ hg verify -R phase-publish
-  checking changesets
-  checking manifests
-  crosschecking files in changesets and manifests
-  checking files
-  checked 3 changesets with 1088 changes to 1088 files
-  $ hg -R phase-publish phase -r 'all()'
-  0: public
-  1: public
-  2: public
-
-Clone as non publishing
-
-  $ cat << EOF >> server/.hg/hgrc
-  > [phases]
-  > publish = False
-  > EOF
-  $ killdaemons.py
-  $ hg -R server serve -p $HGPORT -d --pid-file=hg.pid
-  $ cat hg.pid > $DAEMON_PIDS
-
-#if stream-legacy
-
-With v1 of the stream protocol, changeset are always cloned as public. It make
-stream v1 unsuitable for non-publishing repository.
-
-  $ hg clone --stream http://localhost:$HGPORT phase-no-publish
-  streaming all changes
-  1090 files to transfer, 102 KB of data (no-zstd !)
-  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
-  1090 files to transfer, 98.8 KB of data (zstd !)
-  transferred 98.8 KB in * seconds (* */sec) (glob) (zstd !)
-  searching for changes
-  no changes found
-  updating to branch default
-  1088 files updated, 0 files merged, 0 files removed, 0 files unresolved
-  $ hg -R phase-no-publish phase -r 'all()'
-  0: public
-  1: public
-  2: public
-#endif
-#if stream-bundle2
-  $ hg clone --stream http://localhost:$HGPORT phase-no-publish
-  streaming all changes
-  1097 files to transfer, 102 KB of data (no-zstd !)
-  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
-  1097 files to transfer, 99.1 KB of data (zstd !)
-  transferred 99.1 KB in * seconds (* */sec) (glob) (zstd !)
-  updating to branch default
-  1088 files updated, 0 files merged, 0 files removed, 0 files unresolved
-  $ hg -R phase-no-publish phase -r 'all()'
-  0: draft
-  1: draft
-  2: draft
-#endif
-  $ hg verify -R phase-no-publish
-  checking changesets
-  checking manifests
-  crosschecking files in changesets and manifests
-  checking files
-  checked 3 changesets with 1088 changes to 1088 files
-
-  $ killdaemons.py
-
-#if stream-legacy
-
-With v1 of the stream protocol, changeset are always cloned as public. There's
-no obsolescence markers exchange in stream v1.
-
-#endif
-#if stream-bundle2
-
-Stream repository with obsolescence
------------------------------------
-
-Clone non-publishing with obsolescence
-
-  $ cat >> $HGRCPATH << EOF
-  > [experimental]
-  > evolution=all
-  > EOF
-
-  $ cd server
-  $ echo foo > foo
-  $ hg -q commit -m 'about to be pruned'
-  $ hg debugobsolete `hg log -r . -T '{node}'` -d '0 0' -u test --record-parents
-  1 new obsolescence markers
-  obsoleted 1 changesets
-  $ hg up null -q
-  $ hg log -T '{rev}: {phase}\n'
-  2: draft
-  1: draft
-  0: draft
-  $ hg serve -p $HGPORT -d --pid-file=hg.pid
-  $ cat hg.pid > $DAEMON_PIDS
-  $ cd ..
-
-  $ hg clone -U --stream http://localhost:$HGPORT with-obsolescence
-  streaming all changes
-  1098 files to transfer, 102 KB of data (no-zstd !)
-  transferred 102 KB in * seconds (* */sec) (glob) (no-zstd !)
-  1098 files to transfer, 99.5 KB of data (zstd !)
-  transferred 99.5 KB in * seconds (* */sec) (glob) (zstd !)
-  $ hg -R with-obsolescence log -T '{rev}: {phase}\n'
-  2: draft
-  1: draft
-  0: draft
-  $ hg debugobsolete -R with-obsolescence
-  8c206a663911c1f97f2f9d7382e417ae55872cfa 0 {5223b5e3265f0df40bb743da62249413d74ac70f} (Thu Jan 01 00:00:00 1970 +0000) {'user': 'test'}
-  $ hg verify -R with-obsolescence
-  checking changesets
-  checking manifests
-  crosschecking files in changesets and manifests
-  checking files
-  checked 4 changesets with 1089 changes to 1088 files
-
-  $ hg clone -U --stream --config experimental.evolution=0 http://localhost:$HGPORT with-obsolescence-no-evolution
-  streaming all changes
-  remote: abort: server has obsolescence markers, but client cannot receive them via stream clone
-  abort: pull failed on remote
-  [100]
-
-  $ killdaemons.py
-
-#endif
--- a/tests/test-clone.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-clone.t	Thu Oct 21 17:25:41 2021 +0200
@@ -1125,7 +1125,7 @@
   $ hg id -R remote -r 0
   abort: repository remote not found
   [255]
-  $ hg --config share.pool=share -q clone -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" a ssh://user@dummy/remote
+  $ hg --config share.pool=share -q clone a ssh://user@dummy/remote
   $ hg -R remote id -r 0
   acb14030fe0a
 
--- a/tests/test-clonebundles.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-clonebundles.t	Thu Oct 21 17:25:41 2021 +0200
@@ -208,7 +208,7 @@
 
 Feature works over SSH
 
-  $ hg clone -U -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/server ssh-full-clone
+  $ hg clone -U ssh://user@dummy/server ssh-full-clone
   applying clone bundle from http://localhost:$HGPORT1/full.hg
   adding changesets
   adding manifests
--- a/tests/test-commandserver.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-commandserver.t	Thu Oct 21 17:25:41 2021 +0200
@@ -101,7 +101,7 @@
   000000000000 tip
   *** runcommand id -runknown
   abort: unknown revision 'unknown'
-   [255]
+   [10]
 
   >>> from hgclient import bprint, check, readchannel
   >>> @check
@@ -226,6 +226,7 @@
   ui.detailed-exit-code=True
   ui.merge=internal:merge
   ui.mergemarkers=detailed
+  ui.ssh=* (glob)
   ui.timeout.warn=15
   ui.foo=bar
   ui.nontty=true
@@ -239,6 +240,7 @@
   ui.detailed-exit-code=True
   ui.merge=internal:merge
   ui.mergemarkers=detailed
+  ui.ssh=* (glob)
   ui.timeout.warn=15
   ui.nontty=true
 #endif
--- a/tests/test-completion.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-completion.t	Thu Oct 21 17:25:41 2021 +0200
@@ -316,7 +316,7 @@
   debugpushkey: 
   debugpvec: 
   debugrebuilddirstate: rev, minimal
-  debugrebuildfncache: 
+  debugrebuildfncache: only-data
   debugrename: rev
   debugrequires: 
   debugrevlog: changelog, manifest, dir, dump
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-config-parselist.py	Thu Oct 21 17:25:41 2021 +0200
@@ -0,0 +1,52 @@
+"""
+List-valued configuration keys have an ad-hoc microsyntax. From `hg help config`:
+
+> List values are separated by whitespace or comma, except when values are
+> placed in double quotation marks:
+>
+>     allow_read = "John Doe, PhD", brian, betty
+>
+> Quotation marks can be escaped by prefixing them with a backslash. Only
+> quotation marks at the beginning of a word is counted as a quotation
+> (e.g., ``foo"bar baz`` is the list of ``foo"bar`` and ``baz``).
+
+That help documentation is fairly light on details, the actual parser has many
+other edge cases. This test tries to cover them.
+"""
+
+from mercurial.utils import stringutil
+
+
+def assert_parselist(input, expected):
+    result = stringutil.parselist(input)
+    if result != expected:
+        raise AssertionError(
+            "parse_input(%r)\n     got %r\nexpected %r"
+            % (input, result, expected)
+        )
+
+
+# Keep these Python tests in sync with the Rust ones in `rust/hg-core/src/config/values.rs`
+
+assert_parselist(b'', [])
+assert_parselist(b',', [])
+assert_parselist(b'A', [b'A'])
+assert_parselist(b'B,B', [b'B', b'B'])
+assert_parselist(b', C, ,C,', [b'C', b'C'])
+assert_parselist(b'"', [b'"'])
+assert_parselist(b'""', [b'', b''])
+assert_parselist(b'D,"', [b'D', b'"'])
+assert_parselist(b'E,""', [b'E', b'', b''])
+assert_parselist(b'"F,F"', [b'F,F'])
+assert_parselist(b'"G,G', [b'"G', b'G'])
+assert_parselist(b'"H \\",\\"H', [b'"H', b',', b'H'])
+assert_parselist(b'I,I"', [b'I', b'I"'])
+assert_parselist(b'J,"J', [b'J', b'"J'])
+assert_parselist(b'K K', [b'K', b'K'])
+assert_parselist(b'"K" K', [b'K', b'K'])
+assert_parselist(b'L\tL', [b'L', b'L'])
+assert_parselist(b'"L"\tL', [b'L', b'', b'L'])
+assert_parselist(b'M\x0bM', [b'M', b'M'])
+assert_parselist(b'"M"\x0bM', [b'M', b'', b'M'])
+assert_parselist(b'"N"  , ,"', [b'N"'])
+assert_parselist(b'" ,O,  ', [b'"', b'O'])
--- a/tests/test-config.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-config.t	Thu Oct 21 17:25:41 2021 +0200
@@ -413,7 +413,7 @@
 
 The feature is experimental and behavior may varies. This test exists to make sure the code is run. We grep it to avoid too much variability in its current experimental state.
 
-  $ hg config --exp-all-known | grep commit
+  $ hg config --exp-all-known | grep commit | grep -v ssh
   commands.commit.interactive.git=False
   commands.commit.interactive.ignoreblanklines=False
   commands.commit.interactive.ignorews=False
--- a/tests/test-debugcommands.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-debugcommands.t	Thu Oct 21 17:25:41 2021 +0200
@@ -644,14 +644,13 @@
 
 Test debugpeer
 
-  $ hg --config ui.ssh="\"$PYTHON\" \"$TESTDIR/dummyssh\"" debugpeer ssh://user@dummy/debugrevlog
+  $ hg debugpeer ssh://user@dummy/debugrevlog
   url: ssh://user@dummy/debugrevlog
   local: no
   pushable: yes
 
-  $ hg --config ui.ssh="\"$PYTHON\" \"$TESTDIR/dummyssh\"" --debug debugpeer ssh://user@dummy/debugrevlog
-  running "*" "*/tests/dummyssh" 'user@dummy' 'hg -R debugrevlog serve --stdio' (glob) (no-windows !)
-  running "*" "*\tests/dummyssh" "user@dummy" "hg -R debugrevlog serve --stdio" (glob) (windows !)
+  $ hg --debug debugpeer ssh://user@dummy/debugrevlog
+  running .* ".*[/\\]dummyssh" ['"]user@dummy['"] ['"]hg -R debugrevlog serve --stdio['"] (re)
   devel-peer-request: hello+between
   devel-peer-request:   pairs: 81 bytes
   sending hello command
--- a/tests/test-diff-change.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-diff-change.t	Thu Oct 21 17:25:41 2021 +0200
@@ -119,7 +119,7 @@
   +wdir
   $ hg diff -r "2 and 1"
   abort: empty revision range
-  [255]
+  [10]
 
   $ cd ..
 
--- a/tests/test-directaccess.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-directaccess.t	Thu Oct 21 17:25:41 2021 +0200
@@ -42,7 +42,7 @@
   $ hg exp 2 --config experimental.directaccess.revnums=False
   abort: hidden revision '2' was rewritten as: 2443a0e66469
   (use --hidden to access hidden revisions)
-  [255]
+  [10]
 
   $ hg exp 2
   # HG changeset patch
@@ -75,7 +75,7 @@
   $ hg status --change 2 --config experimental.directaccess.revnums=False
   abort: hidden revision '2' was rewritten as: 2443a0e66469
   (use --hidden to access hidden revisions)
-  [255]
+  [10]
 
   $ hg diff -c 2
   diff -r 29becc82797a -r 28ad74487de9 c
@@ -197,12 +197,12 @@
   $ hg phase -r 28ad74
   abort: hidden revision '28ad74' was rewritten as: 2443a0e66469
   (use --hidden to access hidden revisions)
-  [255]
+  [10]
 
   $ hg phase -r 2
   abort: hidden revision '2' was rewritten as: 2443a0e66469
   (use --hidden to access hidden revisions)
-  [255]
+  [10]
 
 Setting a bookmark will make that changeset unhidden, so this should come in end
 
--- a/tests/test-dirs.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-dirs.py	Thu Oct 21 17:25:41 2021 +0200
@@ -13,13 +13,13 @@
             (b'a/a/a', [b'a', b'a/a', b'']),
             (b'alpha/beta/gamma', [b'', b'alpha', b'alpha/beta']),
         ]:
-            d = pathutil.dirs({})
+            d = pathutil.dirs([])
             d.addpath(case)
             self.assertEqual(sorted(d), sorted(want))
 
     def testinvalid(self):
         with self.assertRaises(ValueError):
-            d = pathutil.dirs({})
+            d = pathutil.dirs([])
             d.addpath(b'a//b')
 
 
--- a/tests/test-dirstate-nonnormalset.t	Thu Oct 21 14:03:33 2021 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,22 +0,0 @@
-  $ cat >> $HGRCPATH << EOF
-  > [command-templates]
-  > log="{rev}:{node|short} ({phase}) [{tags} {bookmarks}] {desc|firstline}\n"
-  > [extensions]
-  > dirstateparanoidcheck = $TESTDIR/../contrib/dirstatenonnormalcheck.py
-  > [experimental]
-  > nonnormalparanoidcheck = True
-  > [devel]
-  > all-warnings=True
-  > EOF
-  $ mkcommit() {
-  >    echo "$1" > "$1"
-  >    hg add "$1"
-  >    hg ci -m "add $1"
-  > }
-
-  $ hg init testrepo
-  $ cd testrepo
-  $ mkcommit a
-  $ mkcommit b
-  $ mkcommit c
-  $ hg status
--- a/tests/test-dirstate-race.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-dirstate-race.t	Thu Oct 21 17:25:41 2021 +0200
@@ -1,15 +1,12 @@
-#testcases dirstate-v1 dirstate-v1-tree dirstate-v2
-
-#if dirstate-v1-tree
-#require rust
-  $ echo '[experimental]' >> $HGRCPATH
-  $ echo 'dirstate-tree.in-memory=1' >> $HGRCPATH
-#endif
+#testcases dirstate-v1 dirstate-v2
 
 #if dirstate-v2
-#require rust
-  $ echo '[format]' >> $HGRCPATH
-  $ echo 'exp-dirstate-v2=1' >> $HGRCPATH
+  $ cat >> $HGRCPATH << EOF
+  > [format]
+  > exp-dirstate-v2=1
+  > [storage]
+  > dirstate-v2.slow-path=allow
+  > EOF
 #endif
 
   $ hg init repo
--- a/tests/test-dirstate-race2.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-dirstate-race2.t	Thu Oct 21 17:25:41 2021 +0200
@@ -1,15 +1,12 @@
-#testcases dirstate-v1 dirstate-v1-tree dirstate-v2
-
-#if dirstate-v1-tree
-#require rust
-  $ echo '[experimental]' >> $HGRCPATH
-  $ echo 'dirstate-tree.in-memory=1' >> $HGRCPATH
-#endif
+#testcases dirstate-v1 dirstate-v2
 
 #if dirstate-v2
-#require rust
-  $ echo '[format]' >> $HGRCPATH
-  $ echo 'exp-dirstate-v2=1' >> $HGRCPATH
+  $ cat >> $HGRCPATH << EOF
+  > [format]
+  > exp-dirstate-v2=1
+  > [storage]
+  > dirstate-v2.slow-path=allow
+  > EOF
 #endif
 
 Checking the size/permissions/file-type of files stored in the
--- a/tests/test-dirstate.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-dirstate.t	Thu Oct 21 17:25:41 2021 +0200
@@ -1,15 +1,12 @@
-#testcases dirstate-v1 dirstate-v1-tree dirstate-v2
-
-#if dirstate-v1-tree
-#require rust
-  $ echo '[experimental]' >> $HGRCPATH
-  $ echo 'dirstate-tree.in-memory=1' >> $HGRCPATH
-#endif
+#testcases dirstate-v1 dirstate-v2
 
 #if dirstate-v2
-#require rust
-  $ echo '[format]' >> $HGRCPATH
-  $ echo 'exp-dirstate-v2=1' >> $HGRCPATH
+  $ cat >> $HGRCPATH << EOF
+  > [format]
+  > exp-dirstate-v2=1
+  > [storage]
+  > dirstate-v2.slow-path=allow
+  > EOF
 #endif
 
 ------ Test dirstate._dirs refcounting
@@ -59,13 +56,13 @@
 
 Set mtime of a into the future:
 
-  $ touch -t 202101011200 a
+  $ touch -t 203101011200 a
 
 Status must not set a's entry to unset (issue1790):
 
   $ hg status
   $ hg debugstate
-  n 644          2 2021-01-01 12:00:00 a
+  n 644          2 2031-01-01 12:00:00 a
 
 Test modulo storage/comparison of absurd dates:
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-empty-manifest-index.t	Thu Oct 21 17:25:41 2021 +0200
@@ -0,0 +1,27 @@
+Test null revisions (node 0000000000000000000000000000000000000000, aka rev -1)
+in various circumstances.
+
+Make an empty repo:
+
+  $ hg init a
+  $ cd a
+
+  $ hg files -r 0000000000000000000000000000000000000000
+  [1]
+  $ hg files -r .
+  [1]
+
+Add an empty commit (this makes the changelog refer to a null manifest node):
+
+
+  $ hg commit -m "init" --config ui.allowemptycommit=true
+
+  $ hg files -r .
+  [1]
+
+Strip that empty commit (this makes the changelog file empty, as opposed to missing):
+
+  $ hg --config 'extensions.strip=' strip . > /dev/null
+
+  $ hg files -r .
+  [1]
--- a/tests/test-export.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-export.t	Thu Oct 21 17:25:41 2021 +0200
@@ -370,7 +370,7 @@
   [10]
   $ hg export 999
   abort: unknown revision '999'
-  [255]
+  [10]
   $ hg export "not all()"
   abort: export requires at least one changeset
   [10]
--- a/tests/test-extdiff.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-extdiff.t	Thu Oct 21 17:25:41 2021 +0200
@@ -87,7 +87,7 @@
 
   $ hg extdiff -p diff --patch --rev 'ancestor()' --rev 1
   abort: empty revision on one side of range
-  [255]
+  [10]
 
 Test diff during merge:
 
--- a/tests/test-extension.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-extension.t	Thu Oct 21 17:25:41 2021 +0200
@@ -1692,6 +1692,26 @@
   $ hg --config extensions.minversion=minversion3.py version 2>&1 | egrep '\(third'
   [1]
 
+Don't explode on py3 with a bad version number (both str vs bytes, and not enough
+parts)
+
+  $ cat > minversion4.py << EOF
+  > from mercurial import util
+  > util.version = lambda: b'3.5'
+  > minimumhgversion = '3'
+  > EOF
+  $ hg --config extensions.minversion=minversion4.py version -v
+  Mercurial Distributed SCM (version 3.5)
+  (see https://mercurial-scm.org for more information)
+  
+  Copyright (C) 2005-* Olivia Mackall and others (glob)
+  This is free software; see the source for copying conditions. There is NO
+  warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+  
+  Enabled extensions:
+  
+    minversion  external  
+
 Restore HGRCPATH
 
   $ HGRCPATH=$ORGHGRCPATH
--- a/tests/test-fastannotate-hg.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-fastannotate-hg.t	Thu Oct 21 17:25:41 2021 +0200
@@ -458,7 +458,7 @@
 
   $ hg ann nosuchfile
   abort: nosuchfile: no such file in rev e9e6b4fa872f
-  [255]
+  [10]
 
 annotate file without '\n' on last line
 
--- a/tests/test-fastannotate-protocol.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-fastannotate-protocol.t	Thu Oct 21 17:25:41 2021 +0200
@@ -1,6 +1,4 @@
   $ cat >> $HGRCPATH << EOF
-  > [ui]
-  > ssh = "$PYTHON" "$TESTDIR/dummyssh"
   > [extensions]
   > fastannotate=
   > [fastannotate]
--- a/tests/test-fix.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-fix.t	Thu Oct 21 17:25:41 2021 +0200
@@ -1752,3 +1752,101 @@
   r0.whole:
   hello
   
+
+We should execute the fixer tools as few times as possible, because they might
+be slow or expensive to execute. The inputs to each execution are effectively
+the file path, file content, and line ranges. So, we should be able to re-use
+results whenever those inputs are repeated. That saves a lot of work when
+fixing chains of commits that all have the same file revision for a path being
+fixed.
+
+  $ hg init numberofinvocations
+  $ cd numberofinvocations
+
+  $ printf "bar1" > bar.log
+  $ printf "baz1" > baz.log
+  $ printf "foo1" > foo.log
+  $ printf "qux1" > qux.log
+  $ hg commit -Aqm "commit1"
+
+  $ printf "bar2" > bar.log
+  $ printf "baz2" > baz.log
+  $ printf "foo2" > foo.log
+  $ hg commit -Aqm "commit2"
+
+  $ printf "bar3" > bar.log
+  $ printf "baz3" > baz.log
+  $ hg commit -Aqm "commit3"
+
+  $ printf "bar4" > bar.log
+
+  $ LOGFILE=$TESTTMP/log
+  $ LOGGER=$TESTTMP/log.py
+  $ cat >> $LOGGER <<EOF
+  > # Appends the input file's name to the log file.
+  > import sys
+  > with open('$LOGFILE', 'a') as f:
+  >     f.write(sys.argv[1] + '\n')
+  > sys.stdout.write(sys.stdin.read())
+  > EOF
+
+  $ hg fix --working-dir -r "all()" \
+  >        --config "fix.log:command=\"$PYTHON\" \"$LOGGER\" {rootpath}" \
+  >        --config "fix.log:pattern=glob:**.log"
+
+  $ cat $LOGFILE | sort | uniq -c
+        4 bar.log
+        4 baz.log
+        3 foo.log
+        2 qux.log
+
+  $ cd ..
+
+For tools that support line ranges, it's wrong to blindly re-use fixed file
+content for the same file revision if it appears twice with different baserevs,
+because the line ranges could be different. Since computing line ranges is
+ambiguous, this isn't a matter of correctness, but it affects the usability of
+this extension. It could maybe be simpler if baserevs were computed on a
+per-file basis to make this situation impossible to construct.
+
+In the following example, we construct two subgraphs with the same file
+revisions, and fix different sub-subgraphs to get different baserevs and
+different changed line ranges. The key precondition is that revisions 1 and 4
+have the same file revision, and the key result is that their successors don't
+have the same file content, because we want to fix different areas of that same
+file revision's content.
+
+  $ hg init differentlineranges
+  $ cd differentlineranges
+
+  $ printf "a\nb\n" > file.changed
+  $ hg commit -Aqm "0 ab"
+  $ printf "a\nx\n" > file.changed
+  $ hg commit -Aqm "1 ax"
+  $ hg remove file.changed
+  $ hg commit -Aqm "2 removed"
+  $ hg revert file.changed -r 0
+  $ hg commit -Aqm "3 ab (reverted)"
+  $ hg revert file.changed -r 1
+  $ hg commit -Aqm "4 ax (reverted)"
+
+  $ hg manifest --debug --template "{hash}\n" -r 0; \
+  > hg manifest --debug --template "{hash}\n" -r 3
+  418f692145676128d2fb518b027ddbac624be76e
+  418f692145676128d2fb518b027ddbac624be76e
+  $ hg manifest --debug --template "{hash}\n" -r 1; \
+  > hg manifest --debug --template "{hash}\n" -r 4
+  09b8b3ce5a507caaa282f7262679e6d04091426c
+  09b8b3ce5a507caaa282f7262679e6d04091426c
+
+  $ hg fix --working-dir -r 1+3+4
+  3 new orphan changesets
+
+  $ hg cat file.changed -r "successors(1)" --hidden
+  a
+  X
+  $ hg cat file.changed -r "successors(4)" --hidden
+  A
+  X
+
+  $ cd ..
--- a/tests/test-help.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-help.t	Thu Oct 21 17:25:41 2021 +0200
@@ -1121,6 +1121,7 @@
        censor        Censor
        changegroups  Changegroups
        config        Config Registrar
+       dirstate-v2   dirstate-v2 file format
        extensions    Extension API
        mergestate    Mergestate
        requirements  Repository Requirements
@@ -1899,6 +1900,17 @@
          Revsets specifying bookmarks will not result in the bookmark being
          pushed.
   
+      "bookmarks.mode"
+        How bookmark will be dealt during the exchange. It support the following
+        value
+  
+        - "default": the default behavior, local and remote bookmarks are
+          "merged" on push/pull.
+        - "mirror": when pulling, replace local bookmarks by remote bookmarks.
+          This is useful to replicate a repository, or as an optimization.
+        - "ignore": ignore bookmarks during exchange. (This currently only
+          affect pulling)
+  
       The following special named paths exist:
   
       "default"
@@ -3566,6 +3578,13 @@
   Config Registrar
   </td></tr>
   <tr><td>
+  <a href="/help/internals.dirstate-v2">
+  dirstate-v2
+  </a>
+  </td><td>
+  dirstate-v2 file format
+  </td></tr>
+  <tr><td>
   <a href="/help/internals.extensions">
   extensions
   </a>
--- a/tests/test-hgignore.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-hgignore.t	Thu Oct 21 17:25:41 2021 +0200
@@ -1,15 +1,12 @@
-#testcases dirstate-v1 dirstate-v1-tree dirstate-v2
-
-#if dirstate-v1-tree
-#require rust
-  $ echo '[experimental]' >> $HGRCPATH
-  $ echo 'dirstate-tree.in-memory=1' >> $HGRCPATH
-#endif
+#testcases dirstate-v1 dirstate-v2
 
 #if dirstate-v2
-#require rust
-  $ echo '[format]' >> $HGRCPATH
-  $ echo 'exp-dirstate-v2=1' >> $HGRCPATH
+  $ cat >> $HGRCPATH << EOF
+  > [format]
+  > exp-dirstate-v2=1
+  > [storage]
+  > dirstate-v2.slow-path=allow
+  > EOF
 #endif
 
   $ hg init ignorerepo
@@ -403,9 +400,10 @@
 
 #endif
 
-#if dirstate-v2
+#if dirstate-v2 rust
 
 Check the hash of ignore patterns written in the dirstate
+This is an optimization that is only relevant when using the Rust extensions
 
   $ hg status > /dev/null
   $ cat .hg/testhgignore .hg/testhgignorerel .hgignore dir2/.hgignore dir1/.hgignore dir1/.hgignoretwo | $TESTDIR/f --sha1
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-hgwebdir-gc.py	Thu Oct 21 17:25:41 2021 +0200
@@ -0,0 +1,49 @@
+from __future__ import absolute_import
+
+import os
+from mercurial.hgweb import hgwebdir_mod
+
+hgwebdir = hgwebdir_mod.hgwebdir
+
+os.mkdir(b'webdir')
+os.chdir(b'webdir')
+
+webdir = os.path.realpath(b'.')
+
+
+def trivial_response(req, res):
+    return []
+
+
+def make_hgwebdir(gc_rate=None):
+    config = os.path.join(webdir, b'hgwebdir.conf')
+    with open(config, 'wb') as configfile:
+        configfile.write(b'[experimental]\n')
+        if gc_rate is not None:
+            configfile.write(b'web.full-garbage-collection-rate=%d\n' % gc_rate)
+    hg_wd = hgwebdir(config)
+    hg_wd._runwsgi = trivial_response
+    return hg_wd
+
+
+def process_requests(webdir_instance, number):
+    # we don't care for now about passing realistic arguments
+    for _ in range(number):
+        for chunk in webdir_instance.run_wsgi(None, None):
+            pass
+
+
+without_gc = make_hgwebdir(gc_rate=0)
+process_requests(without_gc, 5)
+assert without_gc.requests_count == 5
+assert without_gc.gc_full_collections_done == 0
+
+with_gc = make_hgwebdir(gc_rate=2)
+process_requests(with_gc, 5)
+assert with_gc.requests_count == 5
+assert with_gc.gc_full_collections_done == 2
+
+with_systematic_gc = make_hgwebdir()  # default value of the setting
+process_requests(with_systematic_gc, 3)
+assert with_systematic_gc.requests_count == 3
+assert with_systematic_gc.gc_full_collections_done == 3
--- a/tests/test-histedit-arguments.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-histedit-arguments.t	Thu Oct 21 17:25:41 2021 +0200
@@ -93,7 +93,7 @@
   1 files updated, 0 files merged, 0 files removed, 0 files unresolved
   $ hg histedit -r 4
   abort: 08d98a8350f3 is not an ancestor of working directory
-  [255]
+  [10]
   $ hg up --quiet
 
 
@@ -290,7 +290,7 @@
   created new head
   $ hg histedit -r 'heads(all())'
   abort: The specified revisions must have exactly one common root
-  [255]
+  [10]
 
 Test that trimming description using multi-byte characters
 --------------------------------------------------------------------
--- a/tests/test-histedit-edit.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-histedit-edit.t	Thu Oct 21 17:25:41 2021 +0200
@@ -552,5 +552,5 @@
   do you want to continue (yN)?  n
   abort: histedit cancelled
   
-  [255]
+  [250]
   $ cd ..
--- a/tests/test-histedit-non-commute-abort.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-histedit-non-commute-abort.t	Thu Oct 21 17:25:41 2021 +0200
@@ -160,7 +160,7 @@
   $ hg histedit e860deea161a
   c: untracked file differs
   abort: untracked files in working directory conflict with files in 055a42cdd887
-  [255]
+  [20]
 
 We should have detected the collision early enough we're not in a
 histedit state, and p1 is unchanged.
--- a/tests/test-histedit-obsolete.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-histedit-obsolete.t	Thu Oct 21 17:25:41 2021 +0200
@@ -508,7 +508,7 @@
   $ hg ci -m 'modify wat'
   $ hg histedit 050280826e04
   abort: cannot edit history that contains merges
-  [255]
+  [20]
   $ cd ..
 
 Check abort behavior
--- a/tests/test-histedit-outgoing.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-histedit-outgoing.t	Thu Oct 21 17:25:41 2021 +0200
@@ -134,7 +134,7 @@
   $ HGEDITOR=cat hg -q histedit --outgoing '../r'
   abort: there are ambiguous outgoing revisions
   (see 'hg help histedit' for more detail)
-  [255]
+  [20]
 
   $ hg -q update -C 2
   $ echo aa >> a
@@ -151,6 +151,6 @@
   $ HGEDITOR=cat hg -q histedit --outgoing '../r#default'
   abort: there are ambiguous outgoing revisions
   (see 'hg help histedit' for more detail)
-  [255]
+  [20]
 
   $ cd ..
--- a/tests/test-infinitepush-ci.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-infinitepush-ci.t	Thu Oct 21 17:25:41 2021 +0200
@@ -9,8 +9,6 @@
 
   $ . "$TESTDIR/library-infinitepush.sh"
   $ cat >> $HGRCPATH <<EOF
-  > [ui]
-  > ssh = "$PYTHON" "$TESTDIR/dummyssh"
   > [alias]
   > glog = log -GT "{rev}:{node|short} {desc}\n{phase}"
   > EOF
--- a/tests/test-init.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-init.t	Thu Oct 21 17:25:41 2021 +0200
@@ -123,7 +123,7 @@
 
 init+push to remote2
 
-  $ hg init -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/remote2
+  $ hg init ssh://user@dummy/remote2
   $ hg incoming -R remote2 local
   comparing with local
   changeset:   0:08b9e9f63b32
@@ -133,7 +133,7 @@
   summary:     init
   
 
-  $ hg push -R local -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/remote2
+  $ hg push -R local ssh://user@dummy/remote2
   pushing to ssh://user@dummy/remote2
   searching for changes
   remote: adding changesets
@@ -143,7 +143,7 @@
 
 clone to remote1
 
-  $ hg clone -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" local ssh://user@dummy/remote1
+  $ hg clone local ssh://user@dummy/remote1
   searching for changes
   remote: adding changesets
   remote: adding manifests
@@ -151,7 +151,7 @@
   remote: added 1 changesets with 1 changes to 1 files
 
 The largefiles extension doesn't crash
-  $ hg clone -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" local ssh://user@dummy/remotelf --config extensions.largefiles=
+  $ hg clone local ssh://user@dummy/remotelf --config extensions.largefiles=
   The fsmonitor extension is incompatible with the largefiles extension and has been disabled. (fsmonitor !)
   The fsmonitor extension is incompatible with the largefiles extension and has been disabled. (fsmonitor !)
   searching for changes
@@ -162,14 +162,14 @@
 
 init to existing repo
 
-  $ hg init -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/remote1
+  $ hg init ssh://user@dummy/remote1
   abort: repository remote1 already exists
   abort: could not create remote repo
   [255]
 
 clone to existing repo
 
-  $ hg clone -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" local ssh://user@dummy/remote1
+  $ hg clone local ssh://user@dummy/remote1
   abort: repository remote1 already exists
   abort: could not create remote repo
   [255]
@@ -283,7 +283,7 @@
   $ hg -R local bookmark test
   $ hg -R local bookmarks
    * test                      0:08b9e9f63b32
-  $ hg clone -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" local ssh://user@dummy/remote-bookmarks
+  $ hg clone local ssh://user@dummy/remote-bookmarks
   searching for changes
   remote: adding changesets
   remote: adding manifests
--- a/tests/test-largefiles-cache.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-largefiles-cache.t	Thu Oct 21 17:25:41 2021 +0200
@@ -185,10 +185,12 @@
 
   $ find share_dst/.hg/largefiles/* | sort
   share_dst/.hg/largefiles/dirstate
+  share_dst/.hg/largefiles/undo.backup.dirstate
 
   $ find src/.hg/largefiles/* | egrep "(dirstate|$hash)" | sort
   src/.hg/largefiles/dirstate
   src/.hg/largefiles/e2fb5f2139d086ded2cb600d5a91a196e76bf020
+  src/.hg/largefiles/undo.backup.dirstate
 
 Verify that backwards compatibility is maintained for old storage layout
   $ mv src/.hg/largefiles/$hash share_dst/.hg/largefiles
--- a/tests/test-largefiles-wireproto.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-largefiles-wireproto.t	Thu Oct 21 17:25:41 2021 +0200
@@ -124,7 +124,7 @@
 #endif
 
 vanilla clients locked out from largefiles ssh repos
-  $ hg --config extensions.largefiles=! clone -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/r4 r5
+  $ hg --config extensions.largefiles=! clone ssh://user@dummy/r4 r5
   remote: 
   remote: This repository uses the largefiles extension.
   remote: 
--- a/tests/test-log.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-log.t	Thu Oct 21 17:25:41 2021 +0200
@@ -5,13 +5,13 @@
   $ hg log
   $ hg log -r 1
   abort: unknown revision '1'
-  [255]
+  [10]
   $ hg log -r -1:0
   abort: unknown revision '-1'
-  [255]
+  [10]
   $ hg log -r 'branch(name)'
   abort: unknown revision 'name'
-  [255]
+  [10]
   $ hg log -r null -q
   -1:000000000000
 
@@ -1104,7 +1104,7 @@
 
   $ hg log -r 1000000000000000000000000000000000000000
   abort: unknown revision '1000000000000000000000000000000000000000'
-  [255]
+  [10]
 
 log -k r1
 
@@ -2061,7 +2061,7 @@
   $ hg log -r a
   abort: hidden revision 'a' is pruned
   (use --hidden to access hidden revisions)
-  [255]
+  [10]
 
 test that parent prevent a changeset to be hidden
 
@@ -2125,7 +2125,7 @@
   $ hg log -T'{rev}:{node}\n' -r:0
   abort: hidden revision '0' is pruned
   (use --hidden to access hidden revisions)
-  [255]
+  [10]
   $ hg log -T'{rev}:{node}\n' -f
   3:d7d28b288a6b83d5d2cf49f10c5974deed3a1d2e
   2:94375ec45bddd2a824535fc04855bd058c926ec0
@@ -2516,10 +2516,9 @@
 is global. So we shouldn't expect the namespace always exists. Using
 ssh:// makes sure a bundle repository is created from scratch. (issue6301)
 
-  $ hg clone -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" \
-  >          -qr0 "ssh://user@dummy/`pwd`/a" a-clone
+  $ hg clone -qr0 "ssh://user@dummy/`pwd`/a" a-clone
   $ hg incoming --config extensions.names=names.py -R a-clone \
-  >             -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" -T '{bars}\n' -l1
+  > -T '{bars}\n' -l1
   comparing with ssh://user@dummy/$TESTTMP/a
   searching for changes
   
--- a/tests/test-logexchange.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-logexchange.t	Thu Oct 21 17:25:41 2021 +0200
@@ -2,8 +2,6 @@
 =============================================
 
   $ cat >> $HGRCPATH << EOF
-  > [ui]
-  > ssh = "$PYTHON" "$TESTDIR/dummyssh"
   > [alias]
   > glog = log -G -T '{rev}:{node|short}  {desc}'
   > [extensions]
@@ -482,15 +480,15 @@
 
   $ hg log -r 'remotebranches(def)' -GT "{rev}:{node|short} {remotenames}\n"
   abort: remote name 'def' does not exist
-  [255]
+  [10]
 
   $ hg log -r 'remotebookmarks("server3")' -GT "{rev}:{node|short} {remotenames}\n"
   abort: remote name 'server3' does not exist
-  [255]
+  [10]
 
   $ hg log -r 'remotenames("server3")' -GT "{rev}:{node|short} {remotenames}\n"
   abort: remote name 'server3' does not exist
-  [255]
+  [10]
 
 Testing for a pattern which does not match anything, which shouldn't fail.
 
--- a/tests/test-manifest.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-manifest.t	Thu Oct 21 17:25:41 2021 +0200
@@ -88,7 +88,7 @@
 
   $ hg manifest -r 2
   abort: unknown revision '2'
-  [255]
+  [10]
 
   $ hg manifest -r tip tip
   abort: please specify just one revision
--- a/tests/test-merge-remove.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-merge-remove.t	Thu Oct 21 17:25:41 2021 +0200
@@ -55,8 +55,8 @@
   adding foo1
 
   $ hg debugstate --no-dates
-  n   0         -2 unset               bar
-  n   0         -2 unset               foo1
+  m   0         -2 unset               bar
+  m   0         -2 unset               foo1
   copy: foo -> foo1
 
   $ hg st -qC
@@ -74,8 +74,8 @@
   reverting foo1
 
   $ hg debugstate --no-dates
-  n   0         -2 unset               bar
-  n   0         -2 unset               foo1
+  m   0         -2 unset               bar
+  m   0         -2 unset               foo1
   copy: foo -> foo1
 
   $ hg st -qC
--- a/tests/test-missing-capability.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-missing-capability.t	Thu Oct 21 17:25:41 2021 +0200
@@ -24,10 +24,6 @@
   > [extensions]
   > disable-lookup = $TESTTMP/disable-lookup.py
   > EOF
-  $ cat >> .hg/hgrc <<EOF
-  > [ui]
-  > ssh = "$PYTHON" "$TESTDIR/dummyssh"
-  > EOF
 
   $ hg pull ssh://user@dummy/repo1 -r tip -B a
   pulling from ssh://user@dummy/repo1
--- a/tests/test-mq-qdelete.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-mq-qdelete.t	Thu Oct 21 17:25:41 2021 +0200
@@ -115,7 +115,7 @@
 
   $ hg qfinish -a pc
   abort: unknown revision 'pc'
-  [255]
+  [10]
 
   $ hg qpush
   applying pc
--- a/tests/test-narrow-share.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-narrow-share.t	Thu Oct 21 17:25:41 2021 +0200
@@ -100,7 +100,7 @@
   $ hg -R main files
   abort: working copy's narrowspec is stale
   (run 'hg tracked --update-working-copy')
-  [255]
+  [20]
   $ hg -R main tracked --update-working-copy
   not deleting possibly dirty file d3/f
   not deleting possibly dirty file d3/g
@@ -138,7 +138,7 @@
   $ hg -R main files
   abort: working copy's narrowspec is stale
   (run 'hg tracked --update-working-copy')
-  [255]
+  [20]
   $ hg -R main tracked --update-working-copy
 # d1/f, d3/f should be back
   $ hg -R main files
@@ -189,7 +189,7 @@
   $ hg ci -Am test
   abort: working copy's narrowspec is stale
   (run 'hg tracked --update-working-copy')
-  [255]
+  [20]
   $ hg tracked --update-working-copy
   $ hg st
   M d1/f
--- a/tests/test-obshistory.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-obshistory.t	Thu Oct 21 17:25:41 2021 +0200
@@ -54,7 +54,7 @@
   $ hg update 471f378eab4c
   abort: hidden revision '471f378eab4c' was rewritten as: 4ae3a4151de9
   (use --hidden to access hidden revisions)
-  [255]
+  [10]
   $ hg update --hidden "desc(A0)"
   1 files updated, 0 files merged, 0 files removed, 0 files unresolved
   updated to hidden changeset 471f378eab4c
@@ -118,7 +118,7 @@
   $ hg up 0dec01379d3b
   abort: hidden revision '0dec01379d3b' is pruned
   (use --hidden to access hidden revisions)
-  [255]
+  [10]
   $ hg up --hidden -r 'desc(B0)'
   1 files updated, 0 files merged, 0 files removed, 0 files unresolved
   updated to hidden changeset 0dec01379d3b
@@ -196,7 +196,7 @@
   $ hg update 471597cad322
   abort: hidden revision '471597cad322' was split as: 337fec4d2edc, f257fde29c7a
   (use --hidden to access hidden revisions)
-  [255]
+  [10]
   $ hg update --hidden 'min(desc(A0))'
   0 files updated, 0 files merged, 0 files removed, 0 files unresolved
   updated to hidden changeset 471597cad322
@@ -296,7 +296,7 @@
   $ hg update de7290d8b885
   abort: hidden revision 'de7290d8b885' was split as: 337fec4d2edc, f257fde29c7a and 2 more
   (use --hidden to access hidden revisions)
-  [255]
+  [10]
   $ hg update --hidden 'min(desc(A0))'
   0 files updated, 0 files merged, 0 files removed, 0 files unresolved
   updated to hidden changeset de7290d8b885
@@ -377,7 +377,7 @@
   $ hg update 471f378eab4c
   abort: hidden revision '471f378eab4c' was rewritten as: eb5a0daa2192
   (use --hidden to access hidden revisions)
-  [255]
+  [10]
   $ hg update --hidden 'desc(A0)'
   0 files updated, 0 files merged, 1 files removed, 0 files unresolved
   updated to hidden changeset 471f378eab4c
@@ -385,7 +385,7 @@
   $ hg update 0dec01379d3b
   abort: hidden revision '0dec01379d3b' was rewritten as: eb5a0daa2192
   (use --hidden to access hidden revisions)
-  [255]
+  [10]
   $ hg update --hidden 'desc(B0)'
   1 files updated, 0 files merged, 0 files removed, 0 files unresolved
   updated to hidden changeset 0dec01379d3b
@@ -460,7 +460,7 @@
   $ hg update 471f378eab4c
   abort: hidden revision '471f378eab4c' has diverged
   (use --hidden to access hidden revisions)
-  [255]
+  [10]
   $ hg update --hidden 'desc(A0)'
   0 files updated, 0 files merged, 0 files removed, 0 files unresolved
   updated to hidden changeset 471f378eab4c
@@ -557,7 +557,7 @@
   $ hg update 471f378eab4c
   abort: hidden revision '471f378eab4c' was rewritten as: eb5a0daa2192
   (use --hidden to access hidden revisions)
-  [255]
+  [10]
   $ hg update --hidden 'desc(A0)'
   0 files updated, 0 files merged, 1 files removed, 0 files unresolved
   updated to hidden changeset 471f378eab4c
--- a/tests/test-obsolete.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-obsolete.t	Thu Oct 21 17:25:41 2021 +0200
@@ -203,11 +203,11 @@
   5:5601fb93a350 (draft) [tip ] add new_3_c
   $ hg log -r 6
   abort: unknown revision '6'
-  [255]
+  [10]
   $ hg log -r 4
   abort: hidden revision '4' was rewritten as: 5601fb93a350
   (use --hidden to access hidden revisions)
-  [255]
+  [10]
   $ hg debugrevspec 'rev(6)'
   $ hg debugrevspec 'rev(4)'
   $ hg debugrevspec 'null'
@@ -1544,7 +1544,7 @@
   $ hg log -r 13bedc178fce
   abort: hidden revision '13bedc178fce' was rewritten as: a9b1f8652753
   (use --hidden to access hidden revisions)
-  [255]
+  [10]
 
 Empty out the test extension, as it isn't compatible with later parts
 of the test.
--- a/tests/test-permissions.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-permissions.t	Thu Oct 21 17:25:41 2021 +0200
@@ -1,17 +1,14 @@
 #require unix-permissions no-root reporevlogstore
 
-#testcases dirstate-v1 dirstate-v1-tree dirstate-v2
-
-#if dirstate-v1-tree
-#require rust
-  $ echo '[experimental]' >> $HGRCPATH
-  $ echo 'dirstate-tree.in-memory=1' >> $HGRCPATH
-#endif
+#testcases dirstate-v1 dirstate-v2
 
 #if dirstate-v2
-#require rust
-  $ echo '[format]' >> $HGRCPATH
-  $ echo 'exp-dirstate-v2=1' >> $HGRCPATH
+  $ cat >> $HGRCPATH << EOF
+  > [format]
+  > exp-dirstate-v2=1
+  > [storage]
+  > dirstate-v2.slow-path=allow
+  > EOF
 #endif
 
   $ hg init t
--- a/tests/test-persistent-nodemap.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-persistent-nodemap.t	Thu Oct 21 17:25:41 2021 +0200
@@ -1016,7 +1016,7 @@
 
 No race condition
 
-  $ hg clone -U --stream --config ui.ssh="\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/test-repo stream-clone --debug | egrep '00(changelog|manifest)'
+  $ hg clone -U --stream ssh://user@dummy/test-repo stream-clone --debug | egrep '00(changelog|manifest)'
   adding [s] 00manifest.n (62 bytes)
   adding [s] 00manifest-*.nd (118 KB) (glob)
   adding [s] 00changelog.n (62 bytes)
@@ -1081,7 +1081,7 @@
 
 Do a mix of clone and commit at the same time so that the file listed on disk differ at actual transfer time.
 
-  $ (hg clone -U --stream --config ui.ssh="\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/test-repo stream-clone-race-1 --debug 2>> clone-output | egrep '00(changelog|manifest)' >> clone-output; touch $HG_TEST_STREAM_WALKED_FILE_3) &
+  $ (hg clone -U --stream ssh://user@dummy/test-repo stream-clone-race-1 --debug 2>> clone-output | egrep '00(changelog|manifest)' >> clone-output; touch $HG_TEST_STREAM_WALKED_FILE_3) &
   $ $RUNTESTDIR/testlib/wait-on-file 10 $HG_TEST_STREAM_WALKED_FILE_1
   $ hg -R test-repo/ commit -m foo
   $ touch $HG_TEST_STREAM_WALKED_FILE_2
@@ -1178,7 +1178,7 @@
 Performe the mix of clone and full refresh of the nodemap, so that the files
 (and filenames) are different between listing time and actual transfer time.
 
-  $ (hg clone -U --stream --config ui.ssh="\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/test-repo stream-clone-race-2 --debug 2>> clone-output-2 | egrep '00(changelog|manifest)' >> clone-output-2; touch $HG_TEST_STREAM_WALKED_FILE_3) &
+  $ (hg clone -U --stream ssh://user@dummy/test-repo stream-clone-race-2 --debug 2>> clone-output-2 | egrep '00(changelog|manifest)' >> clone-output-2; touch $HG_TEST_STREAM_WALKED_FILE_3) &
   $ $RUNTESTDIR/testlib/wait-on-file 10 $HG_TEST_STREAM_WALKED_FILE_1
   $ rm test-repo/.hg/store/00changelog.n
   $ rm test-repo/.hg/store/00changelog-*.nd
--- a/tests/test-purge.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-purge.t	Thu Oct 21 17:25:41 2021 +0200
@@ -1,15 +1,12 @@
-#testcases dirstate-v1 dirstate-v1-tree dirstate-v2
-
-#if dirstate-v1-tree
-#require rust
-  $ echo '[experimental]' >> $HGRCPATH
-  $ echo 'dirstate-tree.in-memory=1' >> $HGRCPATH
-#endif
+#testcases dirstate-v1 dirstate-v2
 
 #if dirstate-v2
-#require rust
-  $ echo '[format]' >> $HGRCPATH
-  $ echo 'exp-dirstate-v2=1' >> $HGRCPATH
+  $ cat >> $HGRCPATH << EOF
+  > [format]
+  > exp-dirstate-v2=1
+  > [storage]
+  > dirstate-v2.slow-path=allow
+  > EOF
 #endif
 
 init
--- a/tests/test-push-race.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-push-race.t	Thu Oct 21 17:25:41 2021 +0200
@@ -102,7 +102,6 @@
 
   $ cat >> $HGRCPATH << EOF
   > [ui]
-  > ssh = "$PYTHON" "$TESTDIR/dummyssh"
   > # simplify output
   > logtemplate = {node|short} {desc} ({branch})
   > [phases]
--- a/tests/test-rebase-dest.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-rebase-dest.t	Thu Oct 21 17:25:41 2021 +0200
@@ -162,7 +162,7 @@
   > A D
   > EOS
   abort: unknown revision 'SRC'
-  [255]
+  [10]
 
 Rebase to null should work:
 
--- a/tests/test-rebase-parameters.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-rebase-parameters.t	Thu Oct 21 17:25:41 2021 +0200
@@ -132,7 +132,7 @@
 
   $ hg rebase --dest '1 & !1'
   abort: empty revision set
-  [255]
+  [10]
 
 These work:
 
--- a/tests/test-rebuildstate.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-rebuildstate.t	Thu Oct 21 17:25:41 2021 +0200
@@ -17,9 +17,16 @@
   >   try:
   >     for file in pats:
   >       if opts.get('normal_lookup'):
-  >         repo.dirstate._normallookup(file)
+  >         with repo.dirstate.parentchange():
+  >             repo.dirstate.update_file(
+  >                 file,
+  >                 p1_tracked=True,
+  >                 wc_tracked=True,
+  >                 possibly_dirty=True,
+  >             )
   >       else:
-  >         repo.dirstate._drop(file)
+  >         repo.dirstate._map.reset_state(file)
+  >         repo.dirstate._dirty = True
   > 
   >     repo.dirstate.write(repo.currenttransaction())
   >   finally:
--- a/tests/test-rename-rev.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-rename-rev.t	Thu Oct 21 17:25:41 2021 +0200
@@ -42,6 +42,17 @@
     d1/b
   A d1/d
     d1/b
+# Should get helpful message if we try to copy or rename after commit
+  $ hg cp --forget --at-rev . d1/d
+  saved backup bundle to $TESTTMP/.hg/strip-backup/3f7c325d3f9e-46f377bb-uncopy.hg
+  $ hg cp d1/b d1/d
+  d1/d: not overwriting - file already committed
+  ('hg copy --at-rev .' to record the copy in the parent of the working copy)
+  [1]
+  $ hg mv d1/b d1/d
+  d1/d: not overwriting - file already committed
+  ('hg rename --at-rev .' to record the rename in the parent of the working copy)
+  [1]
 
 Test moved file (not copied) using 'hg cp' command
 
--- a/tests/test-revset-legacy-lookup.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-revset-legacy-lookup.t	Thu Oct 21 17:25:41 2021 +0200
@@ -96,10 +96,10 @@
   2:fb616635b18f Added tag rev(0) for changeset 43114e71eddd ["foo"]
   $ hg log -r '("foo")'
   abort: unknown revision 'foo'
-  [255]
+  [10]
   $ hg log -r 'revset("foo")'
   abort: unknown revision 'foo'
-  [255]
+  [10]
   $ hg log -r '("\"foo\"")'
   2:fb616635b18f Added tag rev(0) for changeset 43114e71eddd ["foo"]
   $ hg log -r 'revset("\"foo\"")'
@@ -126,10 +126,10 @@
   4:bbf52b87b370 Added tag foo-bar for changeset a50aae922707 [foo+bar]
   $ hg log -r '(foo+bar)'
   abort: unknown revision 'foo'
-  [255]
+  [10]
   $ hg log -r 'revset(foo+bar)'
   abort: unknown revision 'foo'
-  [255]
+  [10]
   $ hg log -r '"foo+bar"'
   4:bbf52b87b370 Added tag foo-bar for changeset a50aae922707 [foo+bar]
   $ hg log -r '("foo+bar")'
--- a/tests/test-revset.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-revset.t	Thu Oct 21 17:25:41 2021 +0200
@@ -407,7 +407,7 @@
   [10]
   $ log 'date'
   abort: unknown revision 'date'
-  [255]
+  [10]
   $ log 'date('
   hg: parse error at 5: not a prefix: end
   (date(
@@ -421,10 +421,10 @@
   [10]
   $ log '0:date'
   abort: unknown revision 'date'
-  [255]
+  [10]
   $ log '::"date"'
   abort: unknown revision 'date'
-  [255]
+  [10]
   $ hg book date -r 4
   $ log '0:date'
   0
@@ -3067,7 +3067,7 @@
   0
   $ log 'expectsize(0:1, 1)'
   abort: revset size mismatch. expected 1, got 2
-  [255]
+  [10]
   $ log 'expectsize(0:4, -1)'
   hg: parse error: negative size
   [10]
@@ -3077,7 +3077,7 @@
   2
   $ log 'expectsize(0:1, 3:5)'
   abort: revset size mismatch. expected between 3 and 5, got 2
-  [255]
+  [10]
   $ log 'expectsize(0:1, -1:2)'
   hg: parse error: negative size
   [10]
@@ -3104,10 +3104,10 @@
   2
   $ log 'expectsize(0:2, 4:)'
   abort: revset size mismatch. expected between 4 and 11, got 3
-  [255]
+  [10]
   $ log 'expectsize(0:2, :2)'
   abort: revset size mismatch. expected between 0 and 2, got 3
-  [255]
+  [10]
 
 Test getting list of node from file
 
--- a/tests/test-revset2.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-revset2.t	Thu Oct 21 17:25:41 2021 +0200
@@ -320,7 +320,7 @@
 
   $ log '0|unknown'
   abort: unknown revision 'unknown'
-  [255]
+  [10]
 
 test integer range in `_list`
 
@@ -330,11 +330,11 @@
 
   $ log '-10|-11'
   abort: unknown revision '-11'
-  [255]
+  [10]
 
   $ log '9|10'
   abort: unknown revision '10'
-  [255]
+  [10]
 
 test '0000' != '0' in `_list`
 
@@ -590,7 +590,7 @@
 
   $ log 'tag("1..*")'
   abort: tag '1..*' does not exist
-  [255]
+  [10]
   $ log 'tag("re:1..*")'
   6
   $ log 'tag("re:[0-9].[0-9]")'
@@ -601,16 +601,16 @@
 
   $ log 'tag(unknown)'
   abort: tag 'unknown' does not exist
-  [255]
+  [10]
   $ log 'tag("re:unknown")'
   $ log 'present(tag("unknown"))'
   $ log 'present(tag("re:unknown"))'
   $ log 'branch(unknown)'
   abort: unknown revision 'unknown'
-  [255]
+  [10]
   $ log 'branch("literal:unknown")'
   abort: branch 'unknown' does not exist
-  [255]
+  [10]
   $ log 'branch("re:unknown")'
   $ log 'present(branch("unknown"))'
   $ log 'present(branch("re:unknown"))'
@@ -666,7 +666,7 @@
 
   $ log 'named("unknown")'
   abort: namespace 'unknown' does not exist
-  [255]
+  [10]
   $ log 'named("re:unknown")'
   $ log 'present(named("unknown"))'
   $ log 'present(named("re:unknown"))'
@@ -759,7 +759,7 @@
 
   $ log 'branchpoint()~-1'
   abort: revision in set has more than one child
-  [255]
+  [10]
 
 Bogus function gets suggestions
   $ log 'add()'
@@ -840,7 +840,7 @@
 
   $ hg diff -r 'author("babar") or author("celeste")'
   abort: empty revision range
-  [255]
+  [10]
 
 aliases:
 
--- a/tests/test-rhg.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-rhg.t	Thu Oct 21 17:25:41 2021 +0200
@@ -121,11 +121,16 @@
   file-3
   $ $NO_FALLBACK rhg cat -r cf8b83 file-2
   2
+  $ $NO_FALLBACK rhg cat --rev cf8b83 file-2
+  2
   $ $NO_FALLBACK rhg cat -r c file-2
   abort: ambiguous revision identifier: c
   [255]
   $ $NO_FALLBACK rhg cat -r d file-2
   2
+  $ $NO_FALLBACK rhg cat -r 0000 file-2
+  file-2: no such file in rev 000000000000
+  [1]
 
 Cat files
   $ cd $TESTTMP
@@ -135,42 +140,102 @@
   $ echo "original content" > original
   $ hg add original
   $ hg commit -m "add original" original
+Without `--rev`
+  $ $NO_FALLBACK rhg cat original
+  original content
+With `--rev`
   $ $NO_FALLBACK rhg cat -r 0 original
   original content
 Cat copied file should not display copy metadata
   $ hg copy original copy_of_original
   $ hg commit -m "add copy of original"
+  $ $NO_FALLBACK rhg cat original
+  original content
   $ $NO_FALLBACK rhg cat -r 1 copy_of_original
   original content
 
+
 Fallback to Python
-  $ $NO_FALLBACK rhg cat original
-  unsupported feature: `rhg cat` without `--rev` / `-r`
+  $ $NO_FALLBACK rhg cat original --exclude="*.rs"
+  unsupported feature: error: Found argument '--exclude' which wasn't expected, or isn't valid in this context
+  
+  USAGE:
+      rhg cat [OPTIONS] <FILE>...
+  
+  For more information try --help
+  
   [252]
-  $ rhg cat original
+  $ rhg cat original --exclude="*.rs"
   original content
 
   $ FALLBACK_EXE="$RHG_FALLBACK_EXECUTABLE"
   $ unset RHG_FALLBACK_EXECUTABLE
-  $ rhg cat original
+  $ rhg cat original --exclude="*.rs"
   abort: 'rhg.on-unsupported=fallback' without 'rhg.fallback-executable' set.
   [255]
   $ RHG_FALLBACK_EXECUTABLE="$FALLBACK_EXE"
   $ export RHG_FALLBACK_EXECUTABLE
 
-  $ rhg cat original --config rhg.fallback-executable=false
+  $ rhg cat original --exclude="*.rs" --config rhg.fallback-executable=false
   [1]
 
-  $ rhg cat original --config rhg.fallback-executable=hg-non-existent
+  $ rhg cat original --exclude="*.rs" --config rhg.fallback-executable=hg-non-existent
   tried to fall back to a 'hg-non-existent' sub-process but got error $ENOENT$
-  unsupported feature: `rhg cat` without `--rev` / `-r`
+  unsupported feature: error: Found argument '--exclude' which wasn't expected, or isn't valid in this context
+  
+  USAGE:
+      rhg cat [OPTIONS] <FILE>...
+  
+  For more information try --help
+  
+  [252]
+
+  $ rhg cat original --exclude="*.rs" --config rhg.fallback-executable=rhg
+  Blocking recursive fallback. The 'rhg.fallback-executable = rhg' config points to `rhg` itself.
+  unsupported feature: error: Found argument '--exclude' which wasn't expected, or isn't valid in this context
+  
+  USAGE:
+      rhg cat [OPTIONS] <FILE>...
+  
+  For more information try --help
+  
   [252]
 
-  $ rhg cat original --config rhg.fallback-executable=rhg
-  Blocking recursive fallback. The 'rhg.fallback-executable = rhg' config points to `rhg` itself.
-  unsupported feature: `rhg cat` without `--rev` / `-r`
+Fallback with shell path segments
+  $ $NO_FALLBACK rhg cat .
+  unsupported feature: `..` or `.` path segment
+  [252]
+  $ $NO_FALLBACK rhg cat ..
+  unsupported feature: `..` or `.` path segment
+  [252]
+  $ $NO_FALLBACK rhg cat ../..
+  unsupported feature: `..` or `.` path segment
+  [252]
+
+Fallback with filesets
+  $ $NO_FALLBACK rhg cat "set:c or b"
+  unsupported feature: fileset
   [252]
 
+Fallback with generic hooks
+  $ $NO_FALLBACK rhg cat original --config hooks.pre-cat=something
+  unsupported feature: pre-cat hook defined
+  [252]
+
+  $ $NO_FALLBACK rhg cat original --config hooks.post-cat=something
+  unsupported feature: post-cat hook defined
+  [252]
+
+  $ $NO_FALLBACK rhg cat original --config hooks.fail-cat=something
+  unsupported feature: fail-cat hook defined
+  [252]
+
+Fallback with [defaults]
+  $ $NO_FALLBACK rhg cat original --config "defaults.cat=-r null"
+  unsupported feature: `defaults` config set
+  [252]
+
+
 Requirements
   $ $NO_FALLBACK rhg debugrequirements
   dotencode
@@ -307,3 +372,12 @@
   $ cat .hg/blackbox.log.1
   ????/??/?? ??:??:??.??? * @d3873e73d99ef67873dac33fbcc66268d5d2b6f4 (*)> (rust) files (glob)
 
+Subrepos are not supported
+
+  $ touch .hgsub
+  $ $NO_FALLBACK rhg files
+  unsupported feature: subrepos (.hgsub is present)
+  [252]
+  $ rhg files
+  a
+  $ rm .hgsub
--- a/tests/test-share.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-share.t	Thu Oct 21 17:25:41 2021 +0200
@@ -160,7 +160,7 @@
 Cloning a shared repo via bundle2 results in a non-shared clone
 
   $ cd ..
-  $ hg clone -q --stream --config ui.ssh="\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/`pwd`/repo2 cloned-via-bundle2
+  $ hg clone -q --stream ssh://user@dummy/`pwd`/repo2 cloned-via-bundle2
   $ cat ./cloned-via-bundle2/.hg/requires | grep "shared"
   [1]
   $ hg id --cwd cloned-via-bundle2 -r tip
--- a/tests/test-sparse-clone.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-sparse-clone.t	Thu Oct 21 17:25:41 2021 +0200
@@ -2,7 +2,6 @@
 
   $ cat >> $HGRCPATH << EOF
   > [ui]
-  > ssh = "$PYTHON" "$RUNTESTDIR/dummyssh"
   > username = nobody <no.reply@fb.com>
   > [extensions]
   > sparse=
--- a/tests/test-ssh-batch.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-ssh-batch.t	Thu Oct 21 17:25:41 2021 +0200
@@ -9,7 +9,7 @@
 fails (thus causing the sshpeer to be stopped), the errors from the
 further lookups don't result in tracebacks.
 
-  $ hg pull -r b0 -r nosuchbookmark $(for i in $($TESTDIR/seq.py 1 20); do echo -r b$i; done) -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/$(pwd)/../a
+  $ hg pull -r b0 -r nosuchbookmark $(for i in $($TESTDIR/seq.py 1 20); do echo -r b$i; done) ssh://user@dummy/$(pwd)/../a
   pulling from ssh://user@dummy/$TESTTMP/b/../a
   abort: unknown revision 'nosuchbookmark'
   [255]
--- a/tests/test-ssh-bundle1.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-ssh-bundle1.t	Thu Oct 21 17:25:41 2021 +0200
@@ -52,7 +52,7 @@
 
 repo not found error
 
-  $ hg clone -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/nonexistent local
+  $ hg clone ssh://user@dummy/nonexistent local
   remote: abort: repository nonexistent not found
   abort: no suitable response from remote hg
   [255]
@@ -60,7 +60,7 @@
 non-existent absolute path
 
 #if no-msys
-  $ hg clone -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy//`pwd`/nonexistent local
+  $ hg clone ssh://user@dummy//`pwd`/nonexistent local
   remote: abort: repository /$TESTTMP/nonexistent not found
   abort: no suitable response from remote hg
   [255]
@@ -70,7 +70,7 @@
 
 #if no-reposimplestore
 
-  $ hg clone -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" --stream ssh://user@dummy/remote local-stream
+  $ hg clone --stream ssh://user@dummy/remote local-stream
   streaming all changes
   4 files to transfer, 602 bytes of data (no-zstd !)
   transferred 602 bytes in * seconds (*) (glob) (no-zstd !)
@@ -94,7 +94,7 @@
 clone bookmarks via stream
 
   $ hg -R local-stream book mybook
-  $ hg clone -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" --stream ssh://user@dummy/local-stream stream2
+  $ hg clone --stream ssh://user@dummy/local-stream stream2
   streaming all changes
   4 files to transfer, 602 bytes of data (no-zstd !)
   transferred 602 bytes in * seconds (*) (glob) (no-zstd !)
@@ -114,7 +114,7 @@
 
 clone remote via pull
 
-  $ hg clone -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/remote local
+  $ hg clone ssh://user@dummy/remote local
   requesting all changes
   adding changesets
   adding manifests
@@ -142,14 +142,14 @@
 
   $ hg paths
   default = ssh://user@dummy/remote
-  $ hg pull -e "\"$PYTHON\" \"$TESTDIR/dummyssh\""
+  $ hg pull
   pulling from ssh://user@dummy/remote
   searching for changes
   no changes found
 
 pull from wrong ssh URL
 
-  $ hg pull -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/doesnotexist
+  $ hg pull ssh://user@dummy/doesnotexist
   pulling from ssh://user@dummy/doesnotexist
   remote: abort: repository doesnotexist not found
   abort: no suitable response from remote hg
@@ -163,8 +163,6 @@
 updating rc
 
   $ echo "default-push = ssh://user@dummy/remote" >> .hg/hgrc
-  $ echo "[ui]" >> .hg/hgrc
-  $ echo "ssh = \"$PYTHON\" \"$TESTDIR/dummyssh\"" >> .hg/hgrc
 
 find outgoing
 
@@ -181,7 +179,7 @@
 
 find incoming on the remote side
 
-  $ hg incoming -R ../remote -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/local
+  $ hg incoming -R ../remote ssh://user@dummy/local
   comparing with ssh://user@dummy/local
   searching for changes
   changeset:   3:a28a9d1a809c
@@ -194,7 +192,7 @@
 
 find incoming on the remote side (using absolute path)
 
-  $ hg incoming -R ../remote -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" "ssh://user@dummy/`pwd`"
+  $ hg incoming -R ../remote "ssh://user@dummy/`pwd`"
   comparing with ssh://user@dummy/$TESTTMP/local
   searching for changes
   changeset:   3:a28a9d1a809c
@@ -241,7 +239,7 @@
 test pushkeys and bookmarks
 
   $ cd $TESTTMP/local
-  $ hg debugpushkey --config ui.ssh="\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/remote namespaces
+  $ hg debugpushkey ssh://user@dummy/remote namespaces
   bookmarks	
   namespaces	
   phases	
@@ -256,7 +254,7 @@
   no changes found
   exporting bookmark foo
   [1]
-  $ hg debugpushkey --config ui.ssh="\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/remote bookmarks
+  $ hg debugpushkey ssh://user@dummy/remote bookmarks
   foo	1160648e36cec0054048a7edc4110c6f84fde594
   $ hg book -f foo
   $ hg push --traceback
@@ -328,7 +326,7 @@
   $ hg -R ../remote bookmark test
   $ hg -R ../remote bookmarks
    * test                      4:6c0482d977a3
-  $ hg clone -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/remote local-bookmarks
+  $ hg clone ssh://user@dummy/remote local-bookmarks
   requesting all changes
   adding changesets
   adding manifests
@@ -356,21 +354,21 @@
 
 Test remote paths with spaces (issue2983):
 
-  $ hg init --ssh "\"$PYTHON\" \"$TESTDIR/dummyssh\"" "ssh://user@dummy/a repo"
+  $ hg init "ssh://user@dummy/a repo"
   $ touch "$TESTTMP/a repo/test"
   $ hg -R 'a repo' commit -A -m "test"
   adding test
   $ hg -R 'a repo' tag tag
-  $ hg id --ssh "\"$PYTHON\" \"$TESTDIR/dummyssh\"" "ssh://user@dummy/a repo"
+  $ hg id "ssh://user@dummy/a repo"
   73649e48688a
 
-  $ hg id --ssh "\"$PYTHON\" \"$TESTDIR/dummyssh\"" "ssh://user@dummy/a repo#noNoNO"
+  $ hg id "ssh://user@dummy/a repo#noNoNO"
   abort: unknown revision 'noNoNO'
   [255]
 
 Test (non-)escaping of remote paths with spaces when cloning (issue3145):
 
-  $ hg clone --ssh "\"$PYTHON\" \"$TESTDIR/dummyssh\"" "ssh://user@dummy/a repo"
+  $ hg clone "ssh://user@dummy/a repo"
   destination directory: a repo
   abort: destination 'a repo' is not empty
   [10]
@@ -462,8 +460,6 @@
   $ cat >> .hg/hgrc << EOF
   > [paths]
   > default-push = ssh://user@dummy/remote
-  > [ui]
-  > ssh = "$PYTHON" "$TESTDIR/dummyssh"
   > [extensions]
   > localwrite = localwrite.py
   > EOF
@@ -486,7 +482,7 @@
 
   $ hg pull --debug ssh://user@dummy/remote
   pulling from ssh://user@dummy/remote
-  running .* ".*/dummyssh" ['"]user@dummy['"] ('|")hg -R remote serve --stdio('|") (re)
+  running .* ".*[/\\]dummyssh" ['"]user@dummy['"] ['"]hg -R remote serve --stdio['"] (re)
   sending upgrade request: * proto=exp-ssh-v2-0003 (glob) (sshv2 !)
   sending hello command
   sending between command
@@ -583,11 +579,11 @@
 
   $ echo "pretxnchangegroup.fail = python:$TESTTMP/failhook:hook" >> remote/.hg/hgrc
 
-  $ hg -q --config ui.ssh="\"$PYTHON\" $TESTDIR/dummyssh" clone ssh://user@dummy/remote hookout
+  $ hg -q clone ssh://user@dummy/remote hookout
   $ cd hookout
   $ touch hookfailure
   $ hg -q commit -A -m 'remote hook failure'
-  $ hg --config ui.ssh="\"$PYTHON\" $TESTDIR/dummyssh" push
+  $ hg push
   pushing to ssh://user@dummy/remote
   searching for changes
   remote: adding changesets
@@ -607,7 +603,7 @@
   > [extensions]
   > crash = ${TESTDIR}/crashgetbundler.py
   > EOF
-  $ hg --config ui.ssh="\"$PYTHON\" $TESTDIR/dummyssh" pull
+  $ hg pull
   pulling from ssh://user@dummy/remote
   searching for changes
   adding changesets
--- a/tests/test-ssh-clone-r.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-ssh-clone-r.t	Thu Oct 21 17:25:41 2021 +0200
@@ -28,7 +28,7 @@
 clone remote via stream
 
   $ for i in 0 1 2 3 4 5 6 7 8; do
-  >    hg clone -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" --stream -r "$i" ssh://user@dummy/remote test-"$i"
+  >    hg clone --stream -r "$i" ssh://user@dummy/remote test-"$i"
   >    if cd test-"$i"; then
   >       hg verify
   >       cd ..
@@ -160,7 +160,7 @@
   checked 9 changesets with 7 changes to 4 files
   $ cd ..
   $ cd test-1
-  $ hg pull -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" -r 4 ssh://user@dummy/remote
+  $ hg pull -r 4 ssh://user@dummy/remote
   pulling from ssh://user@dummy/remote
   searching for changes
   adding changesets
@@ -175,7 +175,7 @@
   crosschecking files in changesets and manifests
   checking files
   checked 3 changesets with 2 changes to 1 files
-  $ hg pull -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/remote
+  $ hg pull ssh://user@dummy/remote
   pulling from ssh://user@dummy/remote
   searching for changes
   adding changesets
@@ -186,7 +186,7 @@
   (run 'hg update' to get a working copy)
   $ cd ..
   $ cd test-2
-  $ hg pull -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" -r 5 ssh://user@dummy/remote
+  $ hg pull -r 5 ssh://user@dummy/remote
   pulling from ssh://user@dummy/remote
   searching for changes
   adding changesets
@@ -201,7 +201,7 @@
   crosschecking files in changesets and manifests
   checking files
   checked 5 changesets with 3 changes to 1 files
-  $ hg pull -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/remote
+  $ hg pull ssh://user@dummy/remote
   pulling from ssh://user@dummy/remote
   searching for changes
   adding changesets
--- a/tests/test-ssh-proto.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-ssh-proto.t	Thu Oct 21 17:25:41 2021 +0200
@@ -28,8 +28,6 @@
   > }
 
   $ cat >> $HGRCPATH << EOF
-  > [ui]
-  > ssh = "$PYTHON" "$TESTDIR/dummyssh"
   > [devel]
   > debug.peer-request = true
   > [extensions]
@@ -65,8 +63,7 @@
   $ cd ..
 
   $ hg --debug debugpeer ssh://user@dummy/server
-  running * "*/tests/dummyssh" 'user@dummy' 'hg -R server serve --stdio' (glob) (no-windows !)
-  running * "*\tests/dummyssh" "user@dummy" "hg -R server serve --stdio" (glob) (windows !)
+  running .* ".*[/\\]dummyssh" ['"]user@dummy['"] ['"]hg -R server serve --stdio['"] (re)
   devel-peer-request: hello+between
   devel-peer-request:   pairs: 81 bytes
   sending hello command
@@ -178,8 +175,7 @@
 --debug will print the banner
 
   $ SSHSERVERMODE=banner hg --debug debugpeer ssh://user@dummy/server
-  running * "*/tests/dummyssh" 'user@dummy' 'hg -R server serve --stdio' (glob) (no-windows !)
-  running * "*\tests/dummyssh" "user@dummy" "hg -R server serve --stdio" (glob) (windows !)
+  running .* ".*[/\\]dummyssh" ['"]user@dummy['"] ['"]hg -R server serve --stdio['"] (re)
   devel-peer-request: hello+between
   devel-peer-request:   pairs: 81 bytes
   sending hello command
@@ -269,8 +265,7 @@
 servers.
 
   $ SSHSERVERMODE=no-hello hg --debug debugpeer ssh://user@dummy/server
-  running * "*/tests/dummyssh" 'user@dummy' 'hg -R server serve --stdio' (glob) (no-windows !)
-  running * "*\tests/dummyssh" "user@dummy" "hg -R server serve --stdio" (glob) (windows !)
+  running .* ".*[/\\]dummyssh" ['"]user@dummy['"] ['"]hg -R server serve --stdio['"] (re)
   devel-peer-request: hello+between
   devel-peer-request:   pairs: 81 bytes
   sending hello command
@@ -315,8 +310,7 @@
   o>     1\n
 
   $ hg --config sshpeer.mode=extra-handshake-commands --config sshpeer.handshake-mode=pre-no-args --debug debugpeer ssh://user@dummy/server
-  running * "*/tests/dummyssh" 'user@dummy' 'hg -R server serve --stdio' (glob) (no-windows !)
-  running * "*\tests/dummyssh" "user@dummy" "hg -R server serve --stdio" (glob) (windows !)
+  running .* ".*[/\\]dummyssh" ['"]user@dummy['"] ['"]hg -R server serve --stdio['"] (re)
   sending no-args command
   devel-peer-request: hello+between
   devel-peer-request:   pairs: 81 bytes
@@ -385,8 +379,7 @@
   o>     \n
 
   $ hg --config sshpeer.mode=extra-handshake-commands --config sshpeer.handshake-mode=pre-multiple-no-args --debug debugpeer ssh://user@dummy/server
-  running * "*/tests/dummyssh" 'user@dummy' 'hg -R server serve --stdio' (glob) (no-windows !)
-  running * "*\tests/dummyssh" "user@dummy" "hg -R server serve --stdio" (glob) (windows !)
+  running .* ".*[/\\]dummyssh" ['"]user@dummy['"] ['"]hg -R server serve --stdio['"] (re)
   sending unknown1 command
   sending unknown2 command
   sending unknown3 command
@@ -961,8 +954,7 @@
   $ cd ..
 
   $ hg --config experimental.sshpeer.advertise-v2=true --debug debugpeer ssh://user@dummy/server
-  running * "*/tests/dummyssh" 'user@dummy' 'hg -R server serve --stdio' (glob) (no-windows !)
-  running * "*\tests/dummyssh" "user@dummy" "hg -R server serve --stdio" (glob) (windows !)
+  running .* ".*[/\\]dummyssh" ['"]user@dummy['"] ['"]hg -R server serve --stdio['"] (re)
   sending upgrade request: * proto=exp-ssh-v2-0003 (glob)
   devel-peer-request: hello+between
   devel-peer-request:   pairs: 81 bytes
@@ -1019,8 +1011,7 @@
   $ cd ..
 
   $ hg --config experimental.sshpeer.advertise-v2=true --debug debugpeer ssh://user@dummy/server
-  running * "*/tests/dummyssh" 'user@dummy' 'hg -R server serve --stdio' (glob) (no-windows !)
-  running * "*\tests/dummyssh" "user@dummy" "hg -R server serve --stdio" (glob) (windows !)
+  running .* ".*[/\\]dummyssh" ['"]user@dummy['"] ['"]hg -R server serve --stdio['"] (re)
   sending upgrade request: * proto=exp-ssh-v2-0003 (glob)
   devel-peer-request: hello+between
   devel-peer-request:   pairs: 81 bytes
@@ -1038,8 +1029,7 @@
 Verify the peer has capabilities
 
   $ hg --config experimental.sshpeer.advertise-v2=true --debug debugcapabilities ssh://user@dummy/server
-  running * "*/tests/dummyssh" 'user@dummy' 'hg -R server serve --stdio' (glob) (no-windows !)
-  running * "*\tests/dummyssh" "user@dummy" "hg -R server serve --stdio" (glob) (windows !)
+  running .* ".*[/\\]dummyssh" ['"]user@dummy['"] ['"]hg -R server serve --stdio['"] (re)
   sending upgrade request: * proto=exp-ssh-v2-0003 (glob)
   devel-peer-request: hello+between
   devel-peer-request:   pairs: 81 bytes
--- a/tests/test-ssh-repoerror.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-ssh-repoerror.t	Thu Oct 21 17:25:41 2021 +0200
@@ -4,13 +4,6 @@
 `alias hg=rhg` by run-tests.py. With such alias removed, this test is revealed
 buggy. This need to be resolved sooner than later.
 
-initial setup
-
-  $ cat << EOF >> $HGRCPATH
-  > [ui]
-  > ssh="$PYTHON" "$TESTDIR/dummyssh"
-  > EOF
-
 repository itself is non-readable
 ---------------------------------
 
--- a/tests/test-ssh.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-ssh.t	Thu Oct 21 17:25:41 2021 +0200
@@ -42,18 +42,18 @@
 
 repo not found error
 
-  $ hg clone -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/nonexistent local
+  $ hg clone ssh://user@dummy/nonexistent local
   remote: abort: repository nonexistent not found
   abort: no suitable response from remote hg
   [255]
-  $ hg clone -q -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/nonexistent local
+  $ hg clone -q ssh://user@dummy/nonexistent local
   remote: abort: repository nonexistent not found
   abort: no suitable response from remote hg
   [255]
 
 non-existent absolute path
 
-  $ hg clone -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/`pwd`/nonexistent local
+  $ hg clone ssh://user@dummy/`pwd`/nonexistent local
   remote: abort: repository $TESTTMP/nonexistent not found
   abort: no suitable response from remote hg
   [255]
@@ -62,7 +62,7 @@
 
 #if no-reposimplestore
 
-  $ hg clone -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" --stream ssh://user@dummy/remote local-stream
+  $ hg clone --stream ssh://user@dummy/remote local-stream
   streaming all changes
   8 files to transfer, 827 bytes of data (no-zstd !)
   transferred 827 bytes in * seconds (*) (glob) (no-zstd !)
@@ -84,7 +84,7 @@
 clone bookmarks via stream
 
   $ hg -R local-stream book mybook
-  $ hg clone -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" --stream ssh://user@dummy/local-stream stream2
+  $ hg clone --stream ssh://user@dummy/local-stream stream2
   streaming all changes
   15 files to transfer, * of data (glob)
   transferred * in * seconds (*) (glob)
@@ -100,7 +100,7 @@
 
 clone remote via pull
 
-  $ hg clone -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/remote local
+  $ hg clone ssh://user@dummy/remote local
   requesting all changes
   adding changesets
   adding manifests
@@ -128,14 +128,14 @@
 
   $ hg paths
   default = ssh://user@dummy/remote
-  $ hg pull -e "\"$PYTHON\" \"$TESTDIR/dummyssh\""
+  $ hg pull
   pulling from ssh://user@dummy/remote
   searching for changes
   no changes found
 
 pull from wrong ssh URL
 
-  $ hg pull -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/doesnotexist
+  $ hg pull ssh://user@dummy/doesnotexist
   pulling from ssh://user@dummy/doesnotexist
   remote: abort: repository doesnotexist not found
   abort: no suitable response from remote hg
@@ -149,8 +149,6 @@
 updating rc
 
   $ echo "default-push = ssh://user@dummy/remote" >> .hg/hgrc
-  $ echo "[ui]" >> .hg/hgrc
-  $ echo "ssh = \"$PYTHON\" \"$TESTDIR/dummyssh\"" >> .hg/hgrc
 
 find outgoing
 
@@ -167,7 +165,7 @@
 
 find incoming on the remote side
 
-  $ hg incoming -R ../remote -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/local
+  $ hg incoming -R ../remote ssh://user@dummy/local
   comparing with ssh://user@dummy/local
   searching for changes
   changeset:   3:a28a9d1a809c
@@ -180,7 +178,7 @@
 
 find incoming on the remote side (using absolute path)
 
-  $ hg incoming -R ../remote -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" "ssh://user@dummy/`pwd`"
+  $ hg incoming -R ../remote "ssh://user@dummy/`pwd`"
   comparing with ssh://user@dummy/$TESTTMP/local
   searching for changes
   changeset:   3:a28a9d1a809c
@@ -227,7 +225,7 @@
 test pushkeys and bookmarks
 
   $ cd $TESTTMP/local
-  $ hg debugpushkey --config ui.ssh="\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/remote namespaces
+  $ hg debugpushkey ssh://user@dummy/remote namespaces
   bookmarks	
   namespaces	
   phases	
@@ -242,7 +240,7 @@
   no changes found
   exporting bookmark foo
   [1]
-  $ hg debugpushkey --config ui.ssh="\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/remote bookmarks
+  $ hg debugpushkey ssh://user@dummy/remote bookmarks
   foo	1160648e36cec0054048a7edc4110c6f84fde594
   $ hg book -f foo
   $ hg push --traceback
@@ -347,7 +345,7 @@
   $ hg -R ../remote bookmark test
   $ hg -R ../remote bookmarks
    * test                      4:6c0482d977a3
-  $ hg clone -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/remote local-bookmarks
+  $ hg clone ssh://user@dummy/remote local-bookmarks
   requesting all changes
   adding changesets
   adding manifests
@@ -375,21 +373,21 @@
 
 Test remote paths with spaces (issue2983):
 
-  $ hg init --ssh "\"$PYTHON\" \"$TESTDIR/dummyssh\"" "ssh://user@dummy/a repo"
+  $ hg init "ssh://user@dummy/a repo"
   $ touch "$TESTTMP/a repo/test"
   $ hg -R 'a repo' commit -A -m "test"
   adding test
   $ hg -R 'a repo' tag tag
-  $ hg id --ssh "\"$PYTHON\" \"$TESTDIR/dummyssh\"" "ssh://user@dummy/a repo"
+  $ hg id "ssh://user@dummy/a repo"
   73649e48688a
 
-  $ hg id --ssh "\"$PYTHON\" \"$TESTDIR/dummyssh\"" "ssh://user@dummy/a repo#noNoNO"
+  $ hg id "ssh://user@dummy/a repo#noNoNO"
   abort: unknown revision 'noNoNO'
   [255]
 
 Test (non-)escaping of remote paths with spaces when cloning (issue3145):
 
-  $ hg clone --ssh "\"$PYTHON\" \"$TESTDIR/dummyssh\"" "ssh://user@dummy/a repo"
+  $ hg clone "ssh://user@dummy/a repo"
   destination directory: a repo
   abort: destination 'a repo' is not empty
   [10]
@@ -515,8 +513,6 @@
   $ cat >> .hg/hgrc << EOF
   > [paths]
   > default-push = ssh://user@dummy/remote
-  > [ui]
-  > ssh = "$PYTHON" "$TESTDIR/dummyssh"
   > [extensions]
   > localwrite = localwrite.py
   > EOF
@@ -540,7 +536,7 @@
 
   $ hg pull --debug ssh://user@dummy/remote --config devel.debug.peer-request=yes
   pulling from ssh://user@dummy/remote
-  running .* ".*/dummyssh" ['"]user@dummy['"] ('|")hg -R remote serve --stdio('|") (re)
+  running .* ".*[/\\]dummyssh" ['"]user@dummy['"] ['"]hg -R remote serve --stdio['"] (re)
   sending upgrade request: * proto=exp-ssh-v2-0003 (glob) (sshv2 !)
   devel-peer-request: hello+between
   devel-peer-request:   pairs: 81 bytes
@@ -670,11 +666,11 @@
 
   $ echo "pretxnchangegroup.fail = python:$TESTTMP/failhook:hook" >> remote/.hg/hgrc
 
-  $ hg -q --config ui.ssh="\"$PYTHON\" $TESTDIR/dummyssh" clone ssh://user@dummy/remote hookout
+  $ hg -q clone ssh://user@dummy/remote hookout
   $ cd hookout
   $ touch hookfailure
   $ hg -q commit -A -m 'remote hook failure'
-  $ hg --config ui.ssh="\"$PYTHON\" $TESTDIR/dummyssh" push
+  $ hg push
   pushing to ssh://user@dummy/remote
   searching for changes
   remote: adding changesets
@@ -695,7 +691,7 @@
   > [extensions]
   > crash = ${TESTDIR}/crashgetbundler.py
   > EOF
-  $ hg --config ui.ssh="\"$PYTHON\" $TESTDIR/dummyssh" pull
+  $ hg pull
   pulling from ssh://user@dummy/remote
   searching for changes
   remote: abort: this is an exercise
@@ -704,14 +700,14 @@
 
 abort with no error hint when there is a ssh problem when pulling
 
-  $ hg pull ssh://brokenrepository -e "\"$PYTHON\" \"$TESTDIR/dummyssh\""
+  $ hg pull ssh://brokenrepository
   pulling from ssh://brokenrepository/
   abort: no suitable response from remote hg
   [255]
 
 abort with configured error hint when there is a ssh problem when pulling
 
-  $ hg pull ssh://brokenrepository -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" \
+  $ hg pull ssh://brokenrepository \
   > --config ui.ssherrorhint="Please see http://company/internalwiki/ssh.html"
   pulling from ssh://brokenrepository/
   abort: no suitable response from remote hg
--- a/tests/test-status.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-status.t	Thu Oct 21 17:25:41 2021 +0200
@@ -1,21 +1,12 @@
-#testcases dirstate-v1 dirstate-v1-tree dirstate-v2
-
-#if no-rust
-  $ hg init repo0 --config format.exp-dirstate-v2=1
-  abort: dirstate v2 format requested by config but not supported (requires Rust extensions)
-  [255]
-#endif
-
-#if dirstate-v1-tree
-#require rust
-  $ echo '[experimental]' >> $HGRCPATH
-  $ echo 'dirstate-tree.in-memory=1' >> $HGRCPATH
-#endif
+#testcases dirstate-v1 dirstate-v2
 
 #if dirstate-v2
-#require rust
-  $ echo '[format]' >> $HGRCPATH
-  $ echo 'exp-dirstate-v2=1' >> $HGRCPATH
+  $ cat >> $HGRCPATH << EOF
+  > [format]
+  > exp-dirstate-v2=1
+  > [storage]
+  > dirstate-v2.slow-path=allow
+  > EOF
 #endif
 
   $ hg init repo1
@@ -749,7 +740,7 @@
 if also listing unknowns.
 The tree-based dirstate and status algorithm fix this:
 
-#if symlink no-dirstate-v1
+#if symlink no-dirstate-v1 rust
 
   $ cd ..
   $ hg init issue6335
@@ -765,11 +756,11 @@
   ? bar/a
   ? foo
 
-  $ hg status -c  # incorrect output with `dirstate-v1`
+  $ hg status -c  # incorrect output without the Rust implementation
   $ hg status -cu
   ? bar/a
   ? foo
-  $ hg status -d  # incorrect output with `dirstate-v1`
+  $ hg status -d  # incorrect output without the Rust implementation
   ! foo/a
   $ hg status -du
   ! foo/a
@@ -916,7 +907,7 @@
   I B.hs
   I ignored-folder/ctest.hs
 
-#if dirstate-v2
+#if rust dirstate-v2
 
 Check read_dir caching
 
--- a/tests/test-stream-bundle-v2.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-stream-bundle-v2.t	Thu Oct 21 17:25:41 2021 +0200
@@ -14,7 +14,6 @@
   > evolution.exchange=True
   > bundle2-output-capture=True
   > [ui]
-  > ssh="$PYTHON" "$TESTDIR/dummyssh"
   > logtemplate={rev}:{node|short} {phase} {author} {bookmarks} {desc|firstline}
   > [web]
   > push_ssl = false
--- a/tests/test-strip.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-strip.t	Thu Oct 21 17:25:41 2021 +0200
@@ -709,7 +709,7 @@
   bookmark 'todelete' deleted
   $ hg id -ir dcbb326fdec2
   abort: unknown revision 'dcbb326fdec2'
-  [255]
+  [10]
   $ hg id -ir d62d843c9a01
   d62d843c9a01
   $ hg bookmarks
@@ -725,17 +725,17 @@
   bookmark 'multipledelete2' deleted
   $ hg id -ir e46a4836065c
   abort: unknown revision 'e46a4836065c'
-  [255]
+  [10]
   $ hg id -ir b4594d867745
   abort: unknown revision 'b4594d867745'
-  [255]
+  [10]
   $ hg strip -B singlenode1 -B singlenode2
   saved backup bundle to $TESTTMP/bookmarks/.hg/strip-backup/43227190fef8-8da858f2-backup.hg
   bookmark 'singlenode1' deleted
   bookmark 'singlenode2' deleted
   $ hg id -ir 43227190fef8
   abort: unknown revision '43227190fef8'
-  [255]
+  [10]
   $ hg strip -B unknownbookmark
   abort: bookmark 'unknownbookmark' not found
   [255]
@@ -750,7 +750,7 @@
   bookmark 'delete' deleted
   $ hg id -ir 6:2702dd0c91e7
   abort: unknown revision '2702dd0c91e7'
-  [255]
+  [10]
   $ hg update B
   0 files updated, 0 files merged, 0 files removed, 0 files unresolved
   (activating bookmark B)
--- a/tests/test-subrepo-relative-path.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-subrepo-relative-path.t	Thu Oct 21 17:25:41 2021 +0200
@@ -186,7 +186,7 @@
 
 subrepo paths with ssh urls
 
-  $ hg clone -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/cloned sshclone
+  $ hg clone ssh://user@dummy/cloned sshclone
   requesting all changes
   adding changesets
   adding manifests
@@ -203,7 +203,7 @@
   new changesets 863c1745b441
   3 files updated, 0 files merged, 0 files removed, 0 files unresolved
 
-  $ hg -R sshclone push -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" ssh://user@dummy/`pwd`/cloned
+  $ hg -R sshclone push ssh://user@dummy/`pwd`/cloned
   pushing to ssh://user@dummy/$TESTTMP/cloned
   pushing subrepo sub to ssh://user@dummy/$TESTTMP/sub
   searching for changes
--- a/tests/test-symlinks.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-symlinks.t	Thu Oct 21 17:25:41 2021 +0200
@@ -1,17 +1,14 @@
 #require symlink
 
-#testcases dirstate-v1 dirstate-v1-tree dirstate-v2
-
-#if dirstate-v1-tree
-#require rust
-  $ echo '[experimental]' >> $HGRCPATH
-  $ echo 'dirstate-tree.in-memory=1' >> $HGRCPATH
-#endif
+#testcases dirstate-v1 dirstate-v2
 
 #if dirstate-v2
-#require rust
-  $ echo '[format]' >> $HGRCPATH
-  $ echo 'exp-dirstate-v2=1' >> $HGRCPATH
+  $ cat >> $HGRCPATH << EOF
+  > [format]
+  > exp-dirstate-v2=1
+  > [storage]
+  > dirstate-v2.slow-path=allow
+  > EOF
 #endif
 
 == tests added in 0.7 ==
--- a/tests/test-transaction-rollback-on-revlog-split.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-transaction-rollback-on-revlog-split.t	Thu Oct 21 17:25:41 2021 +0200
@@ -82,15 +82,14 @@
   date:        Thu Jan 01 00:00:00 1970 +0000
   summary:     _
   
-  $ hg verify
-  checking changesets
-  checking manifests
-  crosschecking files in changesets and manifests
-  checking files
+  $ hg verify -q
    warning: revlog 'data/file.d' not in fncache!
-  checked 2 changesets with 2 changes to 1 files
   1 warnings encountered!
   hint: run "hg debugrebuildfncache" to recover from corrupt fncache
+  $ hg debugrebuildfncache --only-data
+  adding data/file.d
+  1 items added, 0 removed from fncache
+  $ hg verify -q
   $ cd ..
 
 
@@ -133,12 +132,7 @@
   date:        Thu Jan 01 00:00:00 1970 +0000
   summary:     _
   
-  $ hg verify
-  checking changesets
-  checking manifests
-  crosschecking files in changesets and manifests
-  checking files
-  checked 2 changesets with 2 changes to 1 files
+  $ hg verify -q
   $ cd ..
 
 
@@ -170,13 +164,8 @@
   date:        Thu Jan 01 00:00:00 1970 +0000
   summary:     _
   
-  $ hg verify
-  checking changesets
-  checking manifests
-  crosschecking files in changesets and manifests
-  checking files
+  $ hg verify -q
    warning: revlog 'data/file.d' not in fncache!
-  checked 2 changesets with 2 changes to 1 files
   1 warnings encountered!
   hint: run "hg debugrebuildfncache" to recover from corrupt fncache
   $ cd ..
--- a/tests/test-transaction-rollback-on-sigpipe.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-transaction-rollback-on-sigpipe.t	Thu Oct 21 17:25:41 2021 +0200
@@ -2,7 +2,7 @@
 the remote hg is able to successfully roll back the transaction.
 
   $ hg init -q remote
-  $ hg clone -e "\"$PYTHON\" \"$RUNTESTDIR/dummyssh\"" -q ssh://user@dummy/`pwd`/remote local
+  $ hg clone -q ssh://user@dummy/`pwd`/remote local
   $ SIGPIPE_REMOTE_DEBUG_FILE="$TESTTMP/DEBUGFILE"
   $ SYNCFILE1="$TESTTMP/SYNCFILE1"
   $ SYNCFILE2="$TESTTMP/SYNCFILE2"
@@ -36,7 +36,7 @@
 
 (use quiet to avoid flacky output from the server)
 
-  $ hg push --quiet -e "\"$PYTHON\" \"$TESTDIR/dummyssh\"" --remotecmd "$remotecmd"
+  $ hg push --quiet --remotecmd "$remotecmd"
   abort: stream ended unexpectedly (got 0 bytes, expected 4)
   [255]
   $ cat $SIGPIPE_REMOTE_DEBUG_FILE
--- a/tests/test-treemanifest.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-treemanifest.t	Thu Oct 21 17:25:41 2021 +0200
@@ -1,8 +1,3 @@
-  $ cat << EOF >> $HGRCPATH
-  > [ui]
-  > ssh="$PYTHON" "$TESTDIR/dummyssh"
-  > EOF
-
 Set up repo
 
   $ hg --config experimental.treemanifest=True init repo
--- a/tests/test-wireproto.py	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-wireproto.py	Thu Oct 21 17:25:41 2021 +0200
@@ -75,9 +75,7 @@
 
     @wireprotov1peer.batchable
     def greet(self, name):
-        f = wireprotov1peer.future()
-        yield {b'name': mangle(name)}, f
-        yield unmangle(f.value)
+        return {b'name': mangle(name)}, unmangle
 
 
 class serverrepo(object):
--- a/tests/test-wireproto.t	Thu Oct 21 14:03:33 2021 +0200
+++ b/tests/test-wireproto.t	Thu Oct 21 17:25:41 2021 +0200
@@ -142,13 +142,13 @@
 
 SSH (try to exercise the ssh functionality with a dummy script):
 
-  $ hg debugwireargs --ssh "\"$PYTHON\" $TESTDIR/dummyssh" ssh://user@dummy/repo uno due tre quattro
+  $ hg debugwireargs ssh://user@dummy/repo uno due tre quattro
   uno due tre quattro None
-  $ hg debugwireargs --ssh "\"$PYTHON\" $TESTDIR/dummyssh" ssh://user@dummy/repo eins zwei --four vier
+  $ hg debugwireargs ssh://user@dummy/repo eins zwei --four vier
   eins zwei None vier None
-  $ hg debugwireargs --ssh "\"$PYTHON\" $TESTDIR/dummyssh" ssh://user@dummy/repo eins zwei
+  $ hg debugwireargs ssh://user@dummy/repo eins zwei
   eins zwei None None None
-  $ hg debugwireargs --ssh "\"$PYTHON\" $TESTDIR/dummyssh" ssh://user@dummy/repo eins zwei --five fuenf
+  $ hg debugwireargs ssh://user@dummy/repo eins zwei --five fuenf
   eins zwei None None None
 
 Explicitly kill daemons to let the test exit on Windows