# HG changeset patch
# User Dan Villiom Podlaski Christiansen <danchr@gmail.com>
# Date 1637535242 -3600
#      Sun Nov 21 23:54:02 2021 +0100
# Node ID d00e6c90987d3f36b05eb2b3ba4c635b7bb4f6b6
# Parent  265a78f97b7bcce0f524200e5279a97734d55999
tests: sync files from mercurial

diff --git a/tests/heredoctest.py b/tests/heredoctest.py
--- a/tests/heredoctest.py
+++ b/tests/heredoctest.py
@@ -1,4 +1,4 @@
-from __future__ import generator_stop
+from __future__ import absolute_import, print_function
 
 import sys
 
diff --git a/tests/hghave.py b/tests/hghave.py
--- a/tests/hghave.py
+++ b/tests/hghave.py
@@ -1,4 +1,4 @@
-from __future__ import generator_stop
+from __future__ import absolute_import, print_function
 
 import distutils.version
 import os
@@ -14,6 +14,8 @@
 checks = {
     "true": (lambda: True, "yak shaving"),
     "false": (lambda: False, "nail clipper"),
+    "known-bad-output": (lambda: True, "use for currently known bad output"),
+    "missing-correct-output": (lambda: False, "use for missing good output"),
 }
 
 try:
@@ -27,7 +29,8 @@
 stdout = getattr(sys.stdout, 'buffer', sys.stdout)
 stderr = getattr(sys.stderr, 'buffer', sys.stderr)
 
-if sys.version_info[0] >= 3:
+is_not_python2 = sys.version_info[0] >= 3
+if is_not_python2:
 
     def _sys2bytes(p):
         if p is None:
@@ -102,8 +105,8 @@
         check, desc = checks[feature]
         try:
             available = check()
-        except Exception:
-            result['error'].append('hghave check failed: %s' % feature)
+        except Exception as e:
+            result['error'].append('hghave check %s failed: %r' % (feature, e))
             continue
 
         if not negate and not available:
@@ -138,9 +141,22 @@
     """Return the match object if cmd executes successfully and its output
     is matched by the supplied regular expression.
     """
+
+    # Tests on Windows have to fake USERPROFILE to point to the test area so
+    # that `~` is properly expanded on py3.8+.  However, some tools like black
+    # make calls that need the real USERPROFILE in order to run `foo --version`.
+    env = os.environ
+    if os.name == 'nt':
+        env = os.environ.copy()
+        env['USERPROFILE'] = env['REALUSERPROFILE']
+
     r = re.compile(regexp)
     p = subprocess.Popen(
-        cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
+        cmd,
+        shell=True,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+        env=env,
     )
     s = p.communicate()[0]
     ret = p.returncode
@@ -152,38 +168,40 @@
     return matchoutput('baz --version 2>&1', br'baz Bazaar version')
 
 
-@check("bzr", "Canonical's Bazaar client")
+@check("bzr", "Breezy library and executable version >= 3.1")
 def has_bzr():
+    if not is_not_python2:
+        return False
     try:
-        import bzrlib
-        import bzrlib.bzrdir
-        import bzrlib.errors
-        import bzrlib.revision
-        import bzrlib.revisionspec
+        # Test the Breezy python lib
+        import breezy
+        import breezy.bzr.bzrdir
+        import breezy.errors
+        import breezy.revision
+        import breezy.revisionspec
 
-        bzrlib.revisionspec.RevisionSpec
-        return bzrlib.__doc__ is not None
+        breezy.revisionspec.RevisionSpec
+        if breezy.__doc__ is None or breezy.version_info[:2] < (3, 1):
+            return False
     except (AttributeError, ImportError):
         return False
-
-
-@checkvers("bzr", "Canonical's Bazaar client >= %s", (1.14,))
-def has_bzr_range(v):
-    major, minor = v.split('rc')[0].split('.')[0:2]
-    try:
-        import bzrlib
-
-        return bzrlib.__doc__ is not None and bzrlib.version_info[:2] >= (
-            int(major),
-            int(minor),
-        )
-    except ImportError:
-        return False
+    # Test the executable
+    return matchoutput('brz --version 2>&1', br'Breezy \(brz\) ')
 
 
 @check("chg", "running with chg")
 def has_chg():
-    return 'CHGHG' in os.environ
+    return 'CHG_INSTALLED_AS_HG' in os.environ
+
+
+@check("rhg", "running with rhg as 'hg'")
+def has_rhg():
+    return 'RHG_INSTALLED_AS_HG' in os.environ
+
+
+@check("pyoxidizer", "running with pyoxidizer build as 'hg'")
+def has_rhg():
+    return 'PYOXIDIZED_INSTALLED_AS_HG' in os.environ
 
 
 @check("cvs", "cvs client/server")
@@ -246,6 +264,13 @@
     return not (new_file_has_exec or exec_flags_cannot_flip)
 
 
+@check("suidbit", "setuid and setgid bit")
+def has_suidbit():
+    if getattr(os, "statvfs", None) is None or getattr(os, "ST_NOSUID") is None:
+        return False
+    return bool(os.statvfs('.').f_flag & os.ST_NOSUID)
+
+
 @check("icasefs", "case insensitive file system")
 def has_icasefs():
     # Stolen from mercurial.util
@@ -612,14 +637,21 @@
 
 @check("pylint", "Pylint python linter")
 def has_pylint():
-    return matchoutput("pylint --help", br"Usage:  pylint", True)
+    return matchoutput("pylint --help", br"Usage:[ ]+pylint", True)
 
 
-@check("clang-format", "clang-format C code formatter")
+@check("clang-format", "clang-format C code formatter (>= 11)")
 def has_clang_format():
     m = matchoutput('clang-format --version', br'clang-format version (\d+)')
-    # style changed somewhere between 4.x and 6.x
-    return m and int(m.group(1)) >= 6
+    # style changed somewhere between 10.x and 11.x
+    if m:
+        return int(m.group(1)) >= 11
+    # Assist Googler contributors, they have a centrally-maintained version of
+    # clang-format that is generally very fresh, but unlike most builds (both
+    # official and unofficial), it does *not* include a version number.
+    return matchoutput(
+        'clang-format --version', br'clang-format .*google3-trunk \([0-9a-f]+\)'
+    )
 
 
 @check("jshint", "JSHint static code analysis tool")
@@ -726,17 +758,35 @@
     return os.path.isdir(os.path.join(t, "..", ".hg"))
 
 
-@check("tic", "terminfo compiler and curses module")
-def has_tic():
+@check("network-io", "whether tests are allowed to access 3rd party services")
+def has_test_repo():
+    t = os.environ.get("HGTESTS_ALLOW_NETIO")
+    return t == "1"
+
+
+@check("curses", "terminfo compiler and curses module")
+def has_curses():
     try:
         import curses
 
         curses.COLOR_BLUE
-        return matchoutput('test -x "`which tic`"', br'')
+
+        # Windows doesn't have a `tic` executable, but the windows_curses
+        # package is sufficient to run the tests without it.
+        if os.name == 'nt':
+            return True
+
+        return has_tic()
+
     except (ImportError, AttributeError):
         return False
 
 
+@check("tic", "terminfo compiler")
+def has_tic():
+    return matchoutput('test -x "`which tic`"', br'')
+
+
 @check("xz", "xz compression utility")
 def has_xz():
     # When Windows invokes a subprocess in shell mode, it uses `cmd.exe`, which
@@ -851,7 +901,10 @@
 
 @check("py3exe", "a Python 3.x interpreter is available")
 def has_python3exe():
-    return matchoutput('python3 -V', br'^Python 3.(5|6|7|8|9)')
+    py = 'python3'
+    if os.name == 'nt':
+        py = 'py -3'
+    return matchoutput('%s -V' % py, br'^Python 3.(5|6|7|8|9)')
 
 
 @check("pure", "running with pure Python code")
@@ -912,17 +965,16 @@
         return False
 
 
-@check("py2virtualenv", "Python2 virtualenv support")
-def has_py2virtualenv():
-    if sys.version_info[0] != 2:
-        return False
-
+@check("virtualenv", "virtualenv support")
+def has_virtualenv():
     try:
         import virtualenv
 
-        virtualenv.ACTIVATE_SH
-        return True
-    except ImportError:
+        # --no-site-package became the default in 1.7 (Nov 2011), and the
+        # argument was removed in 20.0 (Feb 2020).  Rather than make the
+        # script complicated, just ignore ancient versions.
+        return int(virtualenv.__version__.split('.')[0]) > 1
+    except (AttributeError, ImportError, IndexError):
         return False
 
 
@@ -1031,7 +1083,15 @@
     return 'fncache' in getrepofeatures()
 
 
-@check('sqlite', 'sqlite3 module is available')
+@check('dirstate-v2', 'using the v2 format of .hg/dirstate')
+def has_dirstate_v2():
+    # Keep this logic in sync with `newreporequirements()` in `mercurial/localrepo.py`
+    return has_rust() and matchoutput(
+        'hg config format.exp-rc-dirstate-v2', b'(?i)1|yes|true|on|always'
+    )
+
+
+@check('sqlite', 'sqlite3 module and matching cli is available')
 def has_sqlite():
     try:
         import sqlite3
@@ -1047,7 +1107,7 @@
     return matchoutput('sqlite3 -version', br'^3\.\d+')
 
 
-@check('vcr', 'vcr http mocking library')
+@check('vcr', 'vcr http mocking library (pytest-vcr)')
 def has_vcr():
     try:
         import vcr
@@ -1067,13 +1127,13 @@
     return matchoutput('emacs --version', b'GNU Emacs 2(4.4|4.5|5|6|7|8|9)')
 
 
-@check('black', 'the black formatter for python')
+@check('black', 'the black formatter for python (>= 20.8b1)')
 def has_black():
     blackcmd = 'black --version'
     version_regex = b'black, version ([0-9a-b.]+)'
     version = matchoutput(blackcmd, version_regex)
     sv = distutils.version.StrictVersion
-    return version and sv(_bytes2sys(version.group(1))) >= sv('19.10b0')
+    return version and sv(_bytes2sys(version.group(1))) >= sv('20.8b1')
 
 
 @check('pytype', 'the pytype type checker')
@@ -1084,14 +1144,20 @@
     return version and sv(_bytes2sys(version.group(0))) >= sv('2019.10.17')
 
 
-@check("rustfmt", "rustfmt tool")
+@check("rustfmt", "rustfmt tool at version nightly-2020-10-04")
 def has_rustfmt():
     # We use Nightly's rustfmt due to current unstable config options.
     return matchoutput(
-        '`rustup which --toolchain nightly rustfmt` --version', b'rustfmt'
+        '`rustup which --toolchain nightly-2020-10-04 rustfmt` --version',
+        b'rustfmt',
     )
 
 
+@check("cargo", "cargo tool")
+def has_cargo():
+    return matchoutput('`rustup which cargo` --version', b'cargo')
+
+
 @check("lzma", "python lzma module")
 def has_lzma():
     try:
@@ -1101,3 +1167,8 @@
         return True
     except ImportError:
         return False
+
+
+@check("bash", "bash shell")
+def has_bash():
+    return matchoutput("bash -c 'echo hi'", b'^hi$')
diff --git a/tests/killdaemons.py b/tests/killdaemons.py
--- a/tests/killdaemons.py
+++ b/tests/killdaemons.py
@@ -1,6 +1,6 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 
-from __future__ import generator_stop
+from __future__ import absolute_import
 import errno
 import os
 import signal
diff --git a/tests/run-tests.py b/tests/run-tests.py
--- a/tests/run-tests.py
+++ b/tests/run-tests.py
@@ -1,8 +1,8 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 #
 # run-tests.py - Run a set of tests on Mercurial
 #
-# Copyright 2006 Matt Mackall <mpm@selenic.com>
+# Copyright 2006 Olivia Mackall <olivia@selenic.com>
 #
 # This software may be used and distributed according to the terms of the
 # GNU General Public License version 2 or any later version.
@@ -43,10 +43,11 @@
 # completes fairly quickly, includes both shell and Python scripts, and
 # includes some scripts that run daemon processes.)
 
-from __future__ import generator_stop
+from __future__ import absolute_import, print_function
 
 import argparse
 import collections
+import contextlib
 import difflib
 import distutils.version as version
 import errno
@@ -69,6 +70,8 @@
 import uuid
 import xml.dom.minidom as minidom
 
+WINDOWS = os.name == r'nt'
+
 try:
     import Queue as queue
 except ImportError:
@@ -83,24 +86,35 @@
 
     shellquote = pipes.quote
 
+
 processlock = threading.Lock()
 
 pygmentspresent = False
-# ANSI color is unsupported prior to Windows 10
-if os.name != 'nt':
-    try:  # is pygments installed
-        import pygments
-        import pygments.lexers as lexers
-        import pygments.lexer as lexer
-        import pygments.formatters as formatters
-        import pygments.token as token
-        import pygments.style as style
-
-        pygmentspresent = True
-        difflexer = lexers.DiffLexer()
-        terminal256formatter = formatters.Terminal256Formatter()
-    except ImportError:
-        pass
+try:  # is pygments installed
+    import pygments
+    import pygments.lexers as lexers
+    import pygments.lexer as lexer
+    import pygments.formatters as formatters
+    import pygments.token as token
+    import pygments.style as style
+
+    if WINDOWS:
+        hgpath = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+        sys.path.append(hgpath)
+        try:
+            from mercurial import win32  # pytype: disable=import-error
+
+            # Don't check the result code because it fails on heptapod, but
+            # something is able to convert to color anyway.
+            win32.enablevtmode()
+        finally:
+            sys.path = sys.path[:-1]
+
+    pygmentspresent = True
+    difflexer = lexers.DiffLexer()
+    terminal256formatter = formatters.Terminal256Formatter()
+except ImportError:
+    pass
 
 if pygmentspresent:
 
@@ -140,6 +154,7 @@
 
 origenviron = os.environ.copy()
 
+
 if sys.version_info > (3, 5, 0):
     PYTHON3 = True
     xrange = range  # we use xrange in one place, and we'd rather not use range
@@ -192,7 +207,7 @@
         osenvironb = environbytes(os.environ)
 
     getcwdb = getattr(os, 'getcwdb')
-    if not getcwdb or os.name == 'nt':
+    if not getcwdb or WINDOWS:
         getcwdb = lambda: _sys2bytes(os.getcwd())
 
 elif sys.version_info >= (3, 0, 0):
@@ -215,6 +230,18 @@
     osenvironb = os.environ
     getcwdb = os.getcwd
 
+if WINDOWS:
+    _getcwdb = getcwdb
+
+    def getcwdb():
+        cwd = _getcwdb()
+        if re.match(b'^[a-z]:', cwd):
+            # os.getcwd() is inconsistent on the capitalization of the drive
+            # letter, so adjust it. see https://bugs.python.org/issue40368
+            cwd = cwd[0:1].upper() + cwd[1:]
+        return cwd
+
+
 # For Windows support
 wifexited = getattr(os, "WIFEXITED", lambda x: False)
 
@@ -255,11 +282,18 @@
     else:
         family = socket.AF_INET
     try:
-        s = socket.socket(family, socket.SOCK_STREAM)
-        s.bind(('localhost', port))
-        s.close()
+        with contextlib.closing(socket.socket(family, socket.SOCK_STREAM)) as s:
+            s.bind(('localhost', port))
         return True
     except socket.error as exc:
+        if WINDOWS and exc.errno == errno.WSAEACCES:
+            return False
+        elif PYTHON3:
+            # TODO: make a proper exception handler after dropping py2.  This
+            #       works because socket.error is an alias for OSError on py3,
+            #       which is also the baseclass of PermissionError.
+            if isinstance(exc, PermissionError):
+                return False
         if exc.errno not in (
             errno.EADDRINUSE,
             errno.EADDRNOTAVAIL,
@@ -299,6 +333,7 @@
             while time.time() - start < timeout and p.returncode is None:
                 time.sleep(0.1)
             p.timeout = True
+            vlog('# Timout reached for process %d' % p.pid)
             if p.returncode is None:
                 terminate(p)
 
@@ -323,7 +358,7 @@
 
 default_defaults = {
     'jobs': ('HGTEST_JOBS', multiprocessing.cpu_count()),
-    'timeout': ('HGTEST_TIMEOUT', 180),
+    'timeout': ('HGTEST_TIMEOUT', 360),
     'slowtimeout': ('HGTEST_SLOWTIMEOUT', 1500),
     'port': ('HGTEST_PORT', 20059),
     'shell': ('HGTEST_SHELL', 'sh'),
@@ -336,6 +371,21 @@
     return os.path.realpath(os.path.expanduser(path))
 
 
+def which(exe):
+    if PYTHON3:
+        # shutil.which only accept bytes from 3.8
+        cmd = _bytes2sys(exe)
+        real_exec = shutil.which(cmd)
+        return _sys2bytes(real_exec)
+    else:
+        # let us do the os work
+        for p in osenvironb[b'PATH'].split(os.pathsep):
+            f = os.path.join(p, exe)
+            if os.path.isfile(f):
+                return f
+        return None
+
+
 def parselistfiles(files, listtype, warn=True):
     entries = dict()
     for filename in files:
@@ -352,7 +402,8 @@
         for line in f.readlines():
             line = line.split(b'#', 1)[0].strip()
             if line:
-                entries[line] = filename
+                # Ensure path entries are compatible with os.path.relpath()
+                entries[os.path.normpath(line)] = filename
 
         f.close()
     return entries
@@ -534,7 +585,19 @@
         help="install and use chg wrapper in place of hg",
     )
     hgconf.add_argument(
-        "--chg-debug", action="store_true", help="show chg debug logs",
+        "--chg-debug",
+        action="store_true",
+        help="show chg debug logs",
+    )
+    hgconf.add_argument(
+        "--rhg",
+        action="store_true",
+        help="install and use rhg Rust implementation in place of hg",
+    )
+    hgconf.add_argument(
+        "--pyoxidized",
+        action="store_true",
+        help="build the hg binary using pyoxidizer",
     )
     hgconf.add_argument("--compiler", help="compiler to build with")
     hgconf.add_argument(
@@ -548,6 +611,7 @@
         "--local",
         action="store_true",
         help="shortcut for --with-hg=<testdir>/../hg, "
+        "--with-rhg=<testdir>/../rust/target/release/rhg if --rhg is set, "
         "and --with-chg=<testdir>/../contrib/chg/chg if --chg is set",
     )
     hgconf.add_argument(
@@ -576,6 +640,11 @@
         help="use specified chg wrapper in place of hg",
     )
     hgconf.add_argument(
+        "--with-rhg",
+        metavar="RHG",
+        help="use specified rhg Rust implementation in place of hg",
+    )
+    hgconf.add_argument(
         "--with-hg",
         metavar="HG",
         help="test using specified hg script rather than a "
@@ -663,16 +732,22 @@
         parser.error('--rust cannot be used with --no-rust')
 
     if options.local:
-        if options.with_hg or options.with_chg:
-            parser.error('--local cannot be used with --with-hg or --with-chg')
+        if options.with_hg or options.with_rhg or options.with_chg:
+            parser.error(
+                '--local cannot be used with --with-hg or --with-rhg or --with-chg'
+            )
+        if options.pyoxidized:
+            parser.error('--pyoxidized does not work with --local (yet)')
         testdir = os.path.dirname(_sys2bytes(canonpath(sys.argv[0])))
         reporootdir = os.path.dirname(testdir)
         pathandattrs = [(b'hg', 'with_hg')]
         if options.chg:
             pathandattrs.append((b'contrib/chg/chg', 'with_chg'))
+        if options.rhg:
+            pathandattrs.append((b'rust/target/release/rhg', 'with_rhg'))
         for relpath, attr in pathandattrs:
             binpath = os.path.join(reporootdir, relpath)
-            if os.name != 'nt' and not os.access(binpath, os.X_OK):
+            if not (WINDOWS or os.access(binpath, os.X_OK)):
                 parser.error(
                     '--local specified, but %r not found or '
                     'not executable' % binpath
@@ -687,11 +762,17 @@
         ):
             parser.error('--with-hg must specify an executable hg script')
         if os.path.basename(options.with_hg) not in [b'hg', b'hg.exe']:
-            sys.stderr.write('warning: --with-hg should specify an hg script\n')
+            msg = 'warning: --with-hg should specify an hg script, not: %s\n'
+            msg %= _bytes2sys(os.path.basename(options.with_hg))
+            sys.stderr.write(msg)
             sys.stderr.flush()
 
-    if (options.chg or options.with_chg) and os.name == 'nt':
+    if (options.chg or options.with_chg) and WINDOWS:
         parser.error('chg does not work on %s' % os.name)
+    if (options.rhg or options.with_rhg) and WINDOWS:
+        parser.error('rhg does not work on %s' % os.name)
+    if options.pyoxidized and not WINDOWS:
+        parser.error('--pyoxidized is currently Windows only')
     if options.with_chg:
         options.chg = False  # no installation to temporary location
         options.with_chg = canonpath(_sys2bytes(options.with_chg))
@@ -700,12 +781,28 @@
             and os.access(options.with_chg, os.X_OK)
         ):
             parser.error('--with-chg must specify a chg executable')
+    if options.with_rhg:
+        options.rhg = False  # no installation to temporary location
+        options.with_rhg = canonpath(_sys2bytes(options.with_rhg))
+        if not (
+            os.path.isfile(options.with_rhg)
+            and os.access(options.with_rhg, os.X_OK)
+        ):
+            parser.error('--with-rhg must specify a rhg executable')
     if options.chg and options.with_hg:
         # chg shares installation location with hg
         parser.error(
             '--chg does not work when --with-hg is specified '
             '(use --with-chg instead)'
         )
+    if options.rhg and options.with_hg:
+        # rhg shares installation location with hg
+        parser.error(
+            '--rhg does not work when --with-hg is specified '
+            '(use --with-rhg instead)'
+        )
+    if options.rhg and options.chg:
+        parser.error('--rhg and --chg do not work together')
 
     if options.color == 'always' and not pygmentspresent:
         sys.stderr.write(
@@ -967,6 +1064,7 @@
         if slowtimeout is None:
             slowtimeout = defaults['slowtimeout']
         self.path = path
+        self.relpath = os.path.relpath(path)
         self.bname = os.path.basename(path)
         self.name = _bytes2sys(self.bname)
         self._testdir = os.path.dirname(path)
@@ -1192,7 +1290,10 @@
         if self._keeptmpdir:
             log(
                 '\nKeeping testtmp dir: %s\nKeeping threadtmp dir: %s'
-                % (_bytes2sys(self._testtmp), _bytes2sys(self._threadtmp),)
+                % (
+                    _bytes2sys(self._testtmp),
+                    _bytes2sys(self._threadtmp),
+                )
             )
         else:
             try:
@@ -1249,6 +1350,11 @@
             (br'\bHG_TXNID=TXN:[a-f0-9]{40}\b', br'HG_TXNID=TXN:$ID$'),
         ]
         r.append((self._escapepath(self._testtmp), b'$TESTTMP'))
+        if WINDOWS:
+            # JSON output escapes backslashes in Windows paths, so also catch a
+            # double-escape.
+            replaced = self._testtmp.replace(b'\\', br'\\')
+            r.append((self._escapepath(replaced), b'$STR_REPR_TESTTMP'))
 
         replacementfile = os.path.join(self._testdir, b'common-pattern.py')
 
@@ -1267,7 +1373,7 @@
         return r
 
     def _escapepath(self, p):
-        if os.name == 'nt':
+        if WINDOWS:
             return b''.join(
                 c.isalpha()
                 and b'[%s%s]' % (c.lower(), c.upper())
@@ -1327,8 +1433,14 @@
         env['PYTHONUSERBASE'] = sysconfig.get_config_var('userbase') or ''
         env['HGEMITWARNINGS'] = '1'
         env['TESTTMP'] = _bytes2sys(self._testtmp)
+        uid_file = os.path.join(_bytes2sys(self._testtmp), 'UID')
+        env['HGTEST_UUIDFILE'] = uid_file
         env['TESTNAME'] = self.name
         env['HOME'] = _bytes2sys(self._testtmp)
+        if WINDOWS:
+            env['REALUSERPROFILE'] = env['USERPROFILE']
+            # py3.8+ ignores HOME: https://bugs.python.org/issue36264
+            env['USERPROFILE'] = env['HOME']
         formated_timeout = _bytes2sys(b"%d" % default_defaults['timeout'][1])
         env['HGTEST_TIMEOUT_DEFAULT'] = formated_timeout
         env['HGTEST_TIMEOUT'] = _bytes2sys(b"%d" % self._timeout)
@@ -1358,14 +1470,14 @@
 
         extraextensions = []
         for opt in self._extraconfigopts:
-            section, key = _sys2bytes(opt).split(b'.', 1)
+            section, key = opt.split('.', 1)
             if section != 'extensions':
                 continue
-            name = key.split(b'=', 1)[0]
+            name = key.split('=', 1)[0]
             extraextensions.append(name)
 
         if extraextensions:
-            env['HGTESTEXTRAEXTENSIONS'] = b' '.join(extraextensions)
+            env['HGTESTEXTRAEXTENSIONS'] = ' '.join(extraextensions)
 
         # LOCALIP could be ::1 or 127.0.0.1. Useful for tests that require raw
         # IP addresses.
@@ -1374,7 +1486,7 @@
         # This has the same effect as Py_LegacyWindowsStdioFlag in exewrapper.c,
         # but this is needed for testing python instances like dummyssh,
         # dummysmtpd.py, and dumbhttp.py.
-        if PYTHON3 and os.name == 'nt':
+        if PYTHON3 and WINDOWS:
             env['PYTHONLEGACYWINDOWSSTDIO'] = '1'
 
         # Modified HOME in test environment can confuse Rust tools. So set
@@ -1438,9 +1550,15 @@
             hgrc.write(b'[ui]\n')
             hgrc.write(b'slash = True\n')
             hgrc.write(b'interactive = False\n')
+            hgrc.write(b'detailed-exit-code = True\n')
             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')
             hgrc.write(b'[defaults]\n')
             hgrc.write(b'[devel]\n')
             hgrc.write(b'all-warnings = true\n')
@@ -1482,6 +1600,7 @@
             proc = subprocess.Popen(
                 _bytes2sys(cmd),
                 shell=True,
+                close_fds=closefds,
                 cwd=_bytes2sys(self._testtmp),
                 env=env,
             )
@@ -1537,8 +1656,7 @@
         # Quote the python(3) executable for Windows
         cmd = b'"%s" "%s"' % (PYTHON, self.path)
         vlog("# Running", cmd.decode("utf-8"))
-        normalizenewlines = os.name == 'nt'
-        result = self._runcommand(cmd, env, normalizenewlines=normalizenewlines)
+        result = self._runcommand(cmd, env, normalizenewlines=WINDOWS)
         if self._aborted:
             raise KeyboardInterrupt()
 
@@ -1752,8 +1870,6 @@
 
         if self._debug:
             script.append(b'set -x\n')
-        if self._hgcommand != b'hg':
-            script.append(b'alias hg="%s"\n' % self._hgcommand)
         if os.getenv('MSYSTEM'):
             script.append(b'alias pwd="pwd -W"\n')
 
@@ -2011,7 +2127,7 @@
             flags = flags or b''
             el = flags + b'(?:' + el + b')'
             # use \Z to ensure that the regex matches to the end of the string
-            if os.name == 'nt':
+            if WINDOWS:
                 return re.match(el + br'\r?\n\Z', l)
             return re.match(el + br'\n\Z', l)
         except re.error:
@@ -2072,7 +2188,7 @@
                 el = el.encode('latin-1')
             else:
                 el = el[:-7].decode('string-escape') + '\n'
-        if el == l or os.name == 'nt' and el[:-1] + b'\r\n' == l:
+        if el == l or WINDOWS and el[:-1] + b'\r\n' == l:
             return True, True
         if el.endswith(b" (re)\n"):
             return (TTest.rematch(el[:-6], l) or retry), False
@@ -2083,17 +2199,17 @@
             return (TTest.globmatch(el[:-8], l) or retry), False
         if os.altsep:
             _l = l.replace(b'\\', b'/')
-            if el == _l or os.name == 'nt' and el[:-1] + b'\r\n' == _l:
+            if el == _l or WINDOWS and el[:-1] + b'\r\n' == _l:
                 return True, True
         return retry, True
 
     @staticmethod
     def parsehghaveoutput(lines):
-        '''Parse hghave log lines.
+        """Parse hghave log lines.
 
         Return tuple of lists (missing, failed):
           * the missing/unknown features
-          * the features for which existence check failed'''
+          * the features for which existence check failed"""
         missing = []
         failed = []
         for line in lines:
@@ -2119,12 +2235,15 @@
 firstlock = threading.RLock()
 firsterror = False
 
-
-class TestResult(unittest._TextTestResult):
+if PYTHON3:
+    base_class = unittest.TextTestResult
+else:
+    base_class = unittest._TextTestResult
+
+
+class TestResult(base_class):
     """Holds results when executing via unittest."""
 
-    # Don't worry too much about accessing the non-public _TextTestResult.
-    # It is relatively common in Python testing tools.
     def __init__(self, options, *args, **kwargs):
         super(TestResult, self).__init__(*args, **kwargs)
 
@@ -2146,19 +2265,23 @@
         self.faildata = {}
 
         if options.color == 'auto':
-            self.color = pygmentspresent and self.stream.isatty()
+            isatty = self.stream.isatty()
+            # For some reason, redirecting stdout on Windows disables the ANSI
+            # color processing of stderr, which is what is used to print the
+            # output.  Therefore, both must be tty on Windows to enable color.
+            if WINDOWS:
+                isatty = isatty and sys.stdout.isatty()
+            self.color = pygmentspresent and isatty
         elif options.color == 'never':
             self.color = False
         else:  # 'always', for testing purposes
             self.color = pygmentspresent
 
     def onStart(self, test):
-        """ Can be overriden by custom TestResult
-        """
+        """Can be overriden by custom TestResult"""
 
     def onEnd(self):
-        """ Can be overriden by custom TestResult
-        """
+        """Can be overriden by custom TestResult"""
 
     def addFailure(self, test, reason):
         self.failures.append((test, reason))
@@ -2267,7 +2390,7 @@
                         if test.path.endswith(b'.t'):
                             rename(test.errpath, test.path)
                         else:
-                            rename(test.errpath, '%s.out' % test.path)
+                            rename(test.errpath, b'%s.out' % test.path)
                         accepted = True
             if not accepted:
                 self.faildata[test.name] = b''.join(lines)
@@ -2336,7 +2459,6 @@
         jobs=1,
         whitelist=None,
         blacklist=None,
-        retest=False,
         keywords=None,
         loop=False,
         runs_per_test=1,
@@ -2364,9 +2486,6 @@
         backwards compatible behavior which reports skipped tests as part
         of the results.
 
-        retest denotes whether to retest failed tests. This arguably belongs
-        outside of TestSuite.
-
         keywords denotes key words that will be used to filter which tests
         to execute. This arguably belongs outside of TestSuite.
 
@@ -2377,7 +2496,6 @@
         self._jobs = jobs
         self._whitelist = whitelist
         self._blacklist = blacklist
-        self._retest = retest
         self._keywords = keywords
         self._loop = loop
         self._runs_per_test = runs_per_test
@@ -2402,15 +2520,17 @@
                 result.addSkip(test, "Doesn't exist")
                 continue
 
-            if not (self._whitelist and test.bname in self._whitelist):
-                if self._blacklist and test.bname in self._blacklist:
+            is_whitelisted = self._whitelist and (
+                test.relpath in self._whitelist or test.bname in self._whitelist
+            )
+            if not is_whitelisted:
+                is_blacklisted = self._blacklist and (
+                    test.relpath in self._blacklist
+                    or test.bname in self._blacklist
+                )
+                if is_blacklisted:
                     result.addSkip(test, 'blacklisted')
                     continue
-
-                if self._retest and not os.path.exists(test.errpath):
-                    result.addIgnore(test, 'not retesting')
-                    continue
-
                 if self._keywords:
                     with open(test.path, 'rb') as f:
                         t = f.read().lower() + test.bname.lower()
@@ -2564,7 +2684,7 @@
     )
     with os.fdopen(fd, 'w') as fp:
         for name, ts in sorted(saved.items()):
-            fp.write('%s %s\n' % (name, ' '.join('%.3f' % (t,) for t in ts)))
+            fp.write('%s %s\n' % (name, ' '.join(['%.3f' % (t,) for t in ts])))
     timepath = os.path.join(outputdir, b'.testtimes')
     try:
         os.unlink(timepath)
@@ -2945,8 +3065,11 @@
         self._hgtmp = None
         self._installdir = None
         self._bindir = None
-        self._tmpbinddir = None
+        # a place for run-tests.py to generate executable it needs
+        self._custom_bin_dir = None
         self._pythondir = None
+        # True if we had to infer the pythondir from --with-hg
+        self._pythondir_inferred = False
         self._coveragefile = None
         self._createdfiles = []
         self._hgcommand = None
@@ -2984,7 +3107,6 @@
 
     def _run(self, testdescs):
         testdir = getcwdb()
-        self._testdir = osenvironb[b'TESTDIR'] = getcwdb()
         # assume all tests in same folder for now
         if testdescs:
             pathname = os.path.dirname(testdescs[0]['path'])
@@ -3026,7 +3148,7 @@
             os.makedirs(tmpdir)
         else:
             d = None
-            if os.name == 'nt':
+            if WINDOWS:
                 # without this, we get the default temp dir location, but
                 # in all lowercase, which causes troubles with paths (issue3490)
                 d = osenvironb.get(b'TMP', None)
@@ -3034,14 +3156,15 @@
 
         self._hgtmp = osenvironb[b'HGTMP'] = os.path.realpath(tmpdir)
 
+        self._custom_bin_dir = os.path.join(self._hgtmp, b'custom-bin')
+        os.makedirs(self._custom_bin_dir)
+
         if self.options.with_hg:
             self._installdir = None
             whg = self.options.with_hg
             self._bindir = os.path.dirname(os.path.realpath(whg))
             assert isinstance(self._bindir, bytes)
             self._hgcommand = os.path.basename(whg)
-            self._tmpbindir = os.path.join(self._hgtmp, b'install', b'bin')
-            os.makedirs(self._tmpbindir)
 
             normbin = os.path.normpath(os.path.abspath(whg))
             normbin = normbin.replace(_sys2bytes(os.sep), b'/')
@@ -3063,33 +3186,71 @@
             # Fall back to the legacy behavior.
             else:
                 self._pythondir = self._bindir
+            self._pythondir_inferred = True
 
         else:
             self._installdir = os.path.join(self._hgtmp, b"install")
             self._bindir = os.path.join(self._installdir, b"bin")
             self._hgcommand = b'hg'
-            self._tmpbindir = self._bindir
             self._pythondir = os.path.join(self._installdir, b"lib", b"python")
 
         # Force the use of hg.exe instead of relying on MSYS to recognize hg is
         # a python script and feed it to python.exe.  Legacy stdio is force
         # enabled by hg.exe, and this is a more realistic way to launch hg
         # anyway.
-        if os.name == 'nt' and not self._hgcommand.endswith(b'.exe'):
+        if WINDOWS and not self._hgcommand.endswith(b'.exe'):
             self._hgcommand += b'.exe'
 
+        real_hg = os.path.join(self._bindir, self._hgcommand)
+        osenvironb[b'HGTEST_REAL_HG'] = real_hg
         # set CHGHG, then replace "hg" command by "chg"
         chgbindir = self._bindir
         if self.options.chg or self.options.with_chg:
-            osenvironb[b'CHGHG'] = os.path.join(self._bindir, self._hgcommand)
+            osenvironb[b'CHG_INSTALLED_AS_HG'] = b'1'
+            osenvironb[b'CHGHG'] = real_hg
         else:
-            osenvironb.pop(b'CHGHG', None)  # drop flag for hghave
+            # drop flag for hghave
+            osenvironb.pop(b'CHG_INSTALLED_AS_HG', None)
         if self.options.chg:
             self._hgcommand = b'chg'
         elif self.options.with_chg:
             chgbindir = os.path.dirname(os.path.realpath(self.options.with_chg))
             self._hgcommand = os.path.basename(self.options.with_chg)
 
+        # configure fallback and replace "hg" command by "rhg"
+        rhgbindir = self._bindir
+        if self.options.rhg or self.options.with_rhg:
+            # Affects hghave.py
+            osenvironb[b'RHG_INSTALLED_AS_HG'] = b'1'
+            # Affects configuration. Alternatives would be setting configuration through
+            # `$HGRCPATH` but some tests override that, or changing `_hgcommand` to include
+            # `--config` but that disrupts tests that print command lines and check expected
+            # output.
+            osenvironb[b'RHG_ON_UNSUPPORTED'] = b'fallback'
+            osenvironb[b'RHG_FALLBACK_EXECUTABLE'] = real_hg
+        else:
+            # drop flag for hghave
+            osenvironb.pop(b'RHG_INSTALLED_AS_HG', None)
+        if self.options.rhg:
+            self._hgcommand = b'rhg'
+        elif self.options.with_rhg:
+            rhgbindir = os.path.dirname(os.path.realpath(self.options.with_rhg))
+            self._hgcommand = os.path.basename(self.options.with_rhg)
+
+        if self.options.pyoxidized:
+            testdir = os.path.dirname(_sys2bytes(canonpath(sys.argv[0])))
+            reporootdir = os.path.dirname(testdir)
+            # XXX we should ideally install stuff instead of using the local build
+            bin_path = (
+                b'build/pyoxidizer/x86_64-pc-windows-msvc/release/app/hg.exe'
+            )
+            full_path = os.path.join(reporootdir, bin_path)
+            self._hgcommand = full_path
+            # Affects hghave.py
+            osenvironb[b'PYOXIDIZED_INSTALLED_AS_HG'] = b'1'
+        else:
+            osenvironb.pop(b'PYOXIDIZED_INSTALLED_AS_HG', None)
+
         osenvironb[b"BINDIR"] = self._bindir
         osenvironb[b"PYTHON"] = PYTHON
 
@@ -3108,10 +3269,11 @@
             path.insert(2, realdir)
         if chgbindir != self._bindir:
             path.insert(1, chgbindir)
+        if rhgbindir != self._bindir:
+            path.insert(1, rhgbindir)
         if self._testdir != runtestdir:
             path = [self._testdir] + path
-        if self._tmpbindir != self._bindir:
-            path = [self._tmpbindir] + path
+        path = [self._custom_bin_dir] + path
         osenvironb[b"PATH"] = sepb.join(path)
 
         # Include TESTDIR in PYTHONPATH so that out-of-tree extensions
@@ -3169,7 +3331,9 @@
         vlog("# Using HGTMP", _bytes2sys(self._hgtmp))
         vlog("# Using PATH", os.environ["PATH"])
         vlog(
-            "# Using", _bytes2sys(IMPL_PATH), _bytes2sys(osenvironb[IMPL_PATH]),
+            "# Using",
+            _bytes2sys(IMPL_PATH),
+            _bytes2sys(osenvironb[IMPL_PATH]),
         )
         vlog("# Writing to directory", _bytes2sys(self._outputdir))
 
@@ -3253,6 +3417,14 @@
                     tests.append({'path': t})
             else:
                 tests.append({'path': t})
+
+        if self.options.retest:
+            retest_args = []
+            for test in tests:
+                errpath = self._geterrpath(test)
+                if os.path.exists(errpath):
+                    retest_args.append(test)
+            tests = retest_args
         return tests
 
     def _runtests(self, testdescs):
@@ -3269,13 +3441,7 @@
                 orig = list(testdescs)
                 while testdescs:
                     desc = testdescs[0]
-                    # desc['path'] is a relative path
-                    if 'case' in desc:
-                        casestr = b'#'.join(desc['case'])
-                        errpath = b'%s#%s.err' % (desc['path'], casestr)
-                    else:
-                        errpath = b'%s.err' % desc['path']
-                    errpath = os.path.join(self._outputdir, errpath)
+                    errpath = self._geterrpath(desc)
                     if os.path.exists(errpath):
                         break
                     testdescs.pop(0)
@@ -3298,7 +3464,6 @@
                 jobs=jobs,
                 whitelist=self.options.whitelisted,
                 blacklist=self.options.blacklist,
-                retest=self.options.retest,
                 keywords=kws,
                 loop=self.options.loop,
                 runs_per_test=self.options.runs_per_test,
@@ -3316,14 +3481,19 @@
             if self.options.list_tests:
                 result = runner.listtests(suite)
             else:
+                self._usecorrectpython()
                 if self._installdir:
                     self._installhg()
                     self._checkhglib("Testing")
-                else:
-                    self._usecorrectpython()
                 if self.options.chg:
                     assert self._installdir
                     self._installchg()
+                if self.options.rhg:
+                    assert self._installdir
+                    self._installrhg()
+                elif self.options.pyoxidized:
+                    self._build_pyoxidized()
+                self._use_correct_mercurial()
 
                 log(
                     'running %d tests using %d parallel processes'
@@ -3346,6 +3516,19 @@
         if failed:
             return 1
 
+    def _geterrpath(self, test):
+        # test['path'] is a relative path
+        if 'case' in test:
+            # for multiple dimensions test cases
+            casestr = b'#'.join(test['case'])
+            errpath = b'%s#%s.err' % (test['path'], casestr)
+        else:
+            errpath = b'%s.err' % test['path']
+        if self.options.outputdir:
+            self._outputdir = canonpath(_sys2bytes(self.options.outputdir))
+            errpath = os.path.join(self._outputdir, errpath)
+        return errpath
+
     def _getport(self, count):
         port = self._ports.get(count)  # do we have a cached entry?
         if port is None:
@@ -3421,43 +3604,101 @@
     def _usecorrectpython(self):
         """Configure the environment to use the appropriate Python in tests."""
         # Tests must use the same interpreter as us or bad things will happen.
-        pyexename = sys.platform == 'win32' and b'python.exe' or b'python'
+        if WINDOWS and PYTHON3:
+            pyexe_names = [b'python', b'python3', b'python.exe']
+        elif WINDOWS:
+            pyexe_names = [b'python', b'python.exe']
+        elif PYTHON3:
+            pyexe_names = [b'python', b'python3']
+        else:
+            pyexe_names = [b'python', b'python2']
 
         # os.symlink() is a thing with py3 on Windows, but it requires
         # Administrator rights.
-        if getattr(os, 'symlink', None) and os.name != 'nt':
-            vlog(
-                "# Making python executable in test path a symlink to '%s'"
-                % sysexecutable
-            )
-            mypython = os.path.join(self._tmpbindir, pyexename)
-            try:
-                if os.readlink(mypython) == sysexecutable:
-                    return
-                os.unlink(mypython)
-            except OSError as err:
-                if err.errno != errno.ENOENT:
-                    raise
-            if self._findprogram(pyexename) != sysexecutable:
+        if not WINDOWS and getattr(os, 'symlink', None):
+            msg = "# Making python executable in test path a symlink to '%s'"
+            msg %= sysexecutable
+            vlog(msg)
+            for pyexename in pyexe_names:
+                mypython = os.path.join(self._custom_bin_dir, pyexename)
                 try:
-                    os.symlink(sysexecutable, mypython)
-                    self._createdfiles.append(mypython)
+                    if os.readlink(mypython) == sysexecutable:
+                        continue
+                    os.unlink(mypython)
                 except OSError as err:
-                    # child processes may race, which is harmless
-                    if err.errno != errno.EEXIST:
+                    if err.errno != errno.ENOENT:
                         raise
+                if self._findprogram(pyexename) != sysexecutable:
+                    try:
+                        os.symlink(sysexecutable, mypython)
+                        self._createdfiles.append(mypython)
+                    except OSError as err:
+                        # child processes may race, which is harmless
+                        if err.errno != errno.EEXIST:
+                            raise
+        elif WINDOWS and not os.getenv('MSYSTEM'):
+            raise AssertionError('cannot run test on Windows without MSYSTEM')
         else:
-            exedir, exename = os.path.split(sysexecutable)
-            vlog(
-                "# Modifying search path to find %s as %s in '%s'"
-                % (exename, pyexename, exedir)
-            )
-            path = os.environ['PATH'].split(os.pathsep)
-            while exedir in path:
-                path.remove(exedir)
-            os.environ['PATH'] = os.pathsep.join([exedir] + path)
-            if not self._findprogram(pyexename):
-                print("WARNING: Cannot find %s in search path" % pyexename)
+            # Generate explicit file instead of symlink
+            #
+            # This is especially important as Windows doesn't have
+            # `python3.exe`, and MSYS cannot understand the reparse point with
+            # that name provided by Microsoft.  Create a simple script on PATH
+            # with that name that delegates to the py3 launcher so the shebang
+            # lines work.
+            esc_executable = _sys2bytes(shellquote(sysexecutable))
+            for pyexename in pyexe_names:
+                stub_exec_path = os.path.join(self._custom_bin_dir, pyexename)
+                with open(stub_exec_path, 'wb') as f:
+                    f.write(b'#!/bin/sh\n')
+                    f.write(b'%s "$@"\n' % esc_executable)
+
+            if WINDOWS:
+                if not PYTHON3:
+                    # lets try to build a valid python3 executable for the
+                    # scrip that requires it.
+                    py3exe_name = os.path.join(self._custom_bin_dir, b'python3')
+                    with open(py3exe_name, 'wb') as f:
+                        f.write(b'#!/bin/sh\n')
+                        f.write(b'py -3 "$@"\n')
+
+                # adjust the path to make sur the main python finds it own dll
+                path = os.environ['PATH'].split(os.pathsep)
+                main_exec_dir = os.path.dirname(sysexecutable)
+                extra_paths = [_bytes2sys(self._custom_bin_dir), main_exec_dir]
+
+                # Binaries installed by pip into the user area like pylint.exe may
+                # not be in PATH by default.
+                appdata = os.environ.get('APPDATA')
+                vi = sys.version_info
+                if appdata is not None:
+                    python_dir = 'Python%d%d' % (vi[0], vi[1])
+                    scripts_path = [appdata, 'Python', python_dir, 'Scripts']
+                    if not PYTHON3:
+                        scripts_path = [appdata, 'Python', 'Scripts']
+                    scripts_dir = os.path.join(*scripts_path)
+                    extra_paths.append(scripts_dir)
+
+                os.environ['PATH'] = os.pathsep.join(extra_paths + path)
+
+    def _use_correct_mercurial(self):
+        target_exec = os.path.join(self._custom_bin_dir, b'hg')
+        if self._hgcommand != b'hg':
+            # shutil.which only accept bytes from 3.8
+            real_exec = which(self._hgcommand)
+            if real_exec is None:
+                raise ValueError('could not find exec path for "%s"', real_exec)
+            if real_exec == target_exec:
+                # do not overwrite something with itself
+                return
+            if WINDOWS:
+                with open(target_exec, 'wb') as f:
+                    f.write(b'#!/bin/sh\n')
+                    escaped_exec = shellquote(_bytes2sys(real_exec))
+                    f.write(b'%s "$@"\n' % _sys2bytes(escaped_exec))
+            else:
+                os.symlink(real_exec, target_exec)
+            self._createdfiles.append(target_exec)
 
     def _installhg(self):
         """Install hg into the test environment.
@@ -3488,7 +3729,7 @@
         self._hgroot = hgroot
         os.chdir(hgroot)
         nohome = b'--home=""'
-        if os.name == 'nt':
+        if WINDOWS:
             # The --home="" trick works only on OS where os.sep == '/'
             # because of a distutils convert_path() fast-path. Avoid it at
             # least on Windows for now, deal with .pydistutils.cfg bugs
@@ -3542,8 +3783,6 @@
             sys.exit(1)
         os.chdir(self._testdir)
 
-        self._usecorrectpython()
-
         hgbat = os.path.join(self._bindir, b'hg.bat')
         if os.path.isfile(hgbat):
             # hg.bat expects to be put in bin/scripts while run-tests.py
@@ -3582,9 +3821,7 @@
     def _checkhglib(self, verb):
         """Ensure that the 'mercurial' package imported by python is
         the one we expect it to be.  If not, print a warning to stderr."""
-        if (self._bindir == self._pythondir) and (
-            self._bindir != self._tmpbindir
-        ):
+        if self._pythondir_inferred:
             # The pythondir has been inferred from --with-hg flag.
             # We cannot expect anything sensible here.
             return
@@ -3641,6 +3878,64 @@
                 sys.stdout.write(out)
             sys.exit(1)
 
+    def _installrhg(self):
+        """Install rhg into the test environment"""
+        vlog('# Performing temporary installation of rhg')
+        assert os.path.dirname(self._bindir) == self._installdir
+        assert self._hgroot, 'must be called after _installhg()'
+        cmd = b'"%(make)s" install-rhg PREFIX="%(prefix)s"' % {
+            b'make': b'make',  # TODO: switch by option or environment?
+            b'prefix': self._installdir,
+        }
+        cwd = self._hgroot
+        vlog("# Running", cmd)
+        proc = subprocess.Popen(
+            cmd,
+            shell=True,
+            cwd=cwd,
+            stdin=subprocess.PIPE,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.STDOUT,
+        )
+        out, _err = proc.communicate()
+        if proc.returncode != 0:
+            if PYTHON3:
+                sys.stdout.buffer.write(out)
+            else:
+                sys.stdout.write(out)
+            sys.exit(1)
+
+    def _build_pyoxidized(self):
+        """build a pyoxidized version of mercurial into the test environment
+
+        Ideally this function would be `install_pyoxidier` and would both build
+        and install pyoxidier. However we are starting small to get pyoxidizer
+        build binary to testing quickly.
+        """
+        vlog('# build a pyoxidized version of Mercurial')
+        assert os.path.dirname(self._bindir) == self._installdir
+        assert self._hgroot, 'must be called after _installhg()'
+        cmd = b'"%(make)s" pyoxidizer-windows-tests' % {
+            b'make': b'make',
+        }
+        cwd = self._hgroot
+        vlog("# Running", cmd)
+        proc = subprocess.Popen(
+            _bytes2sys(cmd),
+            shell=True,
+            cwd=_bytes2sys(cwd),
+            stdin=subprocess.PIPE,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.STDOUT,
+        )
+        out, _err = proc.communicate()
+        if proc.returncode != 0:
+            if PYTHON3:
+                sys.stdout.buffer.write(out)
+            else:
+                sys.stdout.write(out)
+            sys.exit(1)
+
     def _outputcoverage(self):
         """Produce code coverage output."""
         import coverage
@@ -3680,14 +3975,14 @@
         sepb = _sys2bytes(os.pathsep)
         for p in osenvironb.get(b'PATH', dpb).split(sepb):
             name = os.path.join(p, program)
-            if os.name == 'nt' or os.access(name, os.X_OK):
+            if WINDOWS or os.access(name, os.X_OK):
                 return _bytes2sys(name)
         return None
 
     def _checktools(self):
         """Ensure tools required to run tests are present."""
         for p in self.REQUIREDTOOLS:
-            if os.name == 'nt' and not p.endswith(b'.exe'):
+            if WINDOWS and not p.endswith(b'.exe'):
                 p += b'.exe'
             found = self._findprogram(p)
             p = p.decode("utf-8")
@@ -3754,6 +4049,15 @@
 
 
 if __name__ == '__main__':
+    if WINDOWS and not os.getenv('MSYSTEM'):
+        print('cannot run test on Windows without MSYSTEM', file=sys.stderr)
+        print(
+            '(if you need to do so contact the mercurial devs: '
+            'mercurial@mercurial-scm.org)',
+            file=sys.stderr,
+        )
+        sys.exit(255)
+
     runner = TestRunner()
 
     try:
diff --git a/tests/sitecustomize.py b/tests/sitecustomize.py
new file mode 100644
--- /dev/null
+++ b/tests/sitecustomize.py
@@ -0,0 +1,17 @@
+from __future__ import absolute_import
+import os
+
+if os.environ.get('COVERAGE_PROCESS_START'):
+    try:
+        import coverage
+        import uuid
+
+        covpath = os.path.join(
+            os.environ['COVERAGE_DIR'], 'cov.%s' % uuid.uuid1()
+        )
+        cov = coverage.coverage(data_file=covpath, auto_data=True)
+        cov._warn_no_data = False
+        cov._warn_unimported_source = False
+        cov.start()
+    except ImportError:
+        pass
diff --git a/tests/test-ambiguousprefix.t b/tests/test-ambiguousprefix.t
--- a/tests/test-ambiguousprefix.t
+++ b/tests/test-ambiguousprefix.t
@@ -31,4 +31,4 @@
 
   $ hg log -r 'gitnode(7e)'
   abort: git-mapfile@7e: ambiguous identifier!? (re)
-  [255]
+  [50]
diff --git a/tests/test-doctest.py b/tests/test-doctest.py
--- a/tests/test-doctest.py
+++ b/tests/test-doctest.py
@@ -1,29 +1,41 @@
 # this is hack to make sure no escape characters are inserted into the output
 
-from __future__ import generator_stop
+from __future__ import absolute_import
+from __future__ import print_function
 
 import doctest
 import os
 import re
+import subprocess
 import sys
 
 # add hggit/ to sys.path
 sys.path.insert(0, os.path.join(os.environ["TESTDIR"], ".."))
 
+ispy3 = sys.version_info[0] >= 3
+
 if 'TERM' in os.environ:
     del os.environ['TERM']
 
+
 class py3docchecker(doctest.OutputChecker):
     def check_output(self, want, got, optionflags):
         want2 = re.sub(r'''\bu(['"])(.*?)\1''', r'\1\2\1', want)  # py2: u''
         got2 = re.sub(r'''\bb(['"])(.*?)\1''', r'\1\2\1', got)  # py3: b''
         # py3: <exc.name>: b'<msg>' -> <name>: <msg>
         #      <exc.name>: <others> -> <name>: <others>
-        got2 = re.sub(r'''^hggit\.\w+\.(\w+): (['"])(.*?)\2''', r'\1: \3',
-                      got2, re.MULTILINE)
-        got2 = re.sub(r'^hggit\.\w+\.(\w+): ', r'\1: ', got2, re.MULTILINE)
-        return any(doctest.OutputChecker.check_output(self, w, g, optionflags)
-                   for w, g in [(want, got), (want2, got2)])
+        got2 = re.sub(
+            r'''^mercurial\.\w+\.(\w+): (['"])(.*?)\2''',
+            r'\1: \3',
+            got2,
+            re.MULTILINE,
+        )
+        got2 = re.sub(r'^mercurial\.\w+\.(\w+): ', r'\1: ', got2, re.MULTILINE)
+        return any(
+            doctest.OutputChecker.check_output(self, w, g, optionflags)
+            for w, g in [(want, got), (want2, got2)]
+        )
+
 
 def testmod(name, optionflags=0, testtarget=None):
     __import__(name)
@@ -33,13 +45,90 @@
 
     # minimal copy of doctest.testmod()
     finder = doctest.DocTestFinder()
-    checker = py3docchecker()
+    checker = None
+    if ispy3:
+        checker = py3docchecker()
     runner = doctest.DocTestRunner(checker=checker, optionflags=optionflags)
     for test in finder.find(mod, name):
         runner.run(test)
     runner.summarize()
 
-testmod('hggit.compat')
-testmod('hggit.hg2git')
-testmod('hggit.util')
-testmod('hggit.git_handler')
+
+DONT_RUN = []
+
+# Exceptions to the defaults for a given detected module. The value for each
+# module name is a list of dicts that specify the kwargs to pass to testmod.
+# testmod is called once per item in the list, so an empty list will cause the
+# module to not be tested.
+testmod_arg_overrides = {}
+
+fileset = 'set:(**.py)'
+
+cwd = os.path.dirname(os.environ["TESTDIR"])
+
+if not os.path.isdir(os.path.join(cwd, ".hg")):
+    sys.exit(0)
+
+files = subprocess.check_output(
+    "hg files --print0 \"%s\"" % fileset,
+    shell=True,
+    cwd=cwd,
+    stderr=subprocess.DEVNULL,
+).split(b'\0')
+
+if sys.version_info[0] >= 3:
+    cwd = os.fsencode(cwd)
+
+mods_tested = set()
+for f in files:
+    if not f:
+        continue
+
+    with open(os.path.join(cwd, f), "rb") as fh:
+        if not re.search(br'\n\s*>>>', fh.read()):
+            continue
+
+    if ispy3:
+        f = f.decode()
+
+    modname = f.replace('.py', '').replace('\\', '.').replace('/', '.')
+
+    # Third-party modules aren't our responsibility to test, and the modules in
+    # contrib generally do not have doctests in a good state, plus they're hard
+    # to import if this test is running with py2, so we just skip both for now.
+    if modname.startswith('mercurial.thirdparty.') or modname.startswith(
+        'contrib.'
+    ):
+        continue
+
+    for kwargs in testmod_arg_overrides.get(modname, [{}]):
+        mods_tested.add((modname, '%r' % (kwargs,)))
+        if modname.startswith('tests.'):
+            # On py2, we can't import from tests.foo, but it works on both py2
+            # and py3 with the way that PYTHONPATH is setup to import without
+            # the 'tests.' prefix, so we do that.
+            modname = modname[len('tests.') :]
+
+        testmod(modname, **kwargs)
+
+# Meta-test: let's make sure that we actually ran what we expected to, above.
+# Each item in the set is a 2-tuple of module name and stringified kwargs passed
+# to testmod.
+expected_mods_tested = set(
+    [
+        ('hggit.git_handler', '{}'),
+        ('hggit.util', '{}'),
+    ]
+)
+
+unexpectedly_run = mods_tested.difference(expected_mods_tested)
+not_run = expected_mods_tested.difference(mods_tested)
+
+if unexpectedly_run:
+    print('Unexpectedly ran (probably need to add to list):')
+    for r in sorted(unexpectedly_run):
+        print('  %r' % (r,))
+if not_run:
+    print('Expected to run, but was not run (doctest removed?):')
+    for r in sorted(not_run):
+        print('  %r' % (r,))
diff --git a/tests/test-git-tags.t b/tests/test-git-tags.t
--- a/tests/test-git-tags.t
+++ b/tests/test-git-tags.t
@@ -59,7 +59,7 @@
   [255]
   $ hg tag --git beta --remove -r 0
   abort: cannot specify both --rev and --remove
-  [255]
+  [10]
   $ hg tag --git alpha
   abort: git tags require an explicit revision
   (please specify -r/--rev)
@@ -69,16 +69,16 @@
   [255]
   $ hg tag --git alpha -r 0 -e
   abort: cannot specify both --git and --edit
-  [255]
+  [10]
   $ hg tag --git alpha -r 0 -m 42
   abort: cannot specify both --git and --message
-  [255]
+  [10]
   $ hg tag --git alpha -r 0 -d 42
   abort: cannot specify both --git and --date
-  [255]
+  [10]
   $ hg tag --git alpha -r 0 -u user@example.com
   abort: cannot specify both --git and --user
-  [255]
+  [10]
   $ hg tag --git 'with space' -r 0
   abort: the name 'with space' is not a valid git tag
   [255]
@@ -90,7 +90,7 @@
   [255]
   $ hg tag --git tip -r 0
   abort: the name 'tip' is reserved
-  [255]
+  [10]
 
 Create a git tag from hg
 
@@ -250,7 +250,7 @@
 #endif
   $ hg tag beta
   abort: tag 'beta' already exists (use -f to force)
-  [255]
+  [10]
   $ hg tag -f beta
 #if secret
   $ hg phase -d
diff --git a/tests/test-keywords.t b/tests/test-keywords.t
--- a/tests/test-keywords.t
+++ b/tests/test-keywords.t
@@ -46,5 +46,5 @@
   gitnode_existsB 0
   $ hg log --rev "gitnode(7e)"
   abort: git-mapfile@7e: ambiguous identifier!? (re)
-  [255]
+  [50]
   $ hg log --template "gitnode_notexists {rev}\n" --rev "gitnode(1234567890ab)"
diff --git a/tests/test-transactions.t b/tests/test-transactions.t
--- a/tests/test-transactions.t
+++ b/tests/test-transactions.t
@@ -71,10 +71,10 @@
   $ git init -q hgrepo
   $ hg clone gitrepo --config git.intree=yes hgrepo
   abort: destination 'hgrepo' is not empty
-  [255]
+  [10]
   $ hg clone $TESTTMP/gitrepo --config git.intree=yes --cwd hgrepo .
   abort: destination '.' is not empty
-  [255]
+  [10]
   $ rm -rf hgrepo
 
 Map saving