Repository: pypa/setuptools_scm
Pull request #823 (2023-03-20 18:22:34)

assorted cleanups


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 6541a0c..6a36020 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -44,3 +44,4 @@ repos:
             - pytest == 7.1
             - importlib_metadata
             - typing-extensions>=4.5
+            - rich
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 71e528a..72efc1c 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -8,7 +8,12 @@ breaking
 * turn Configuration into a dataclass
 * require configuration to always pass into helpers
 * hide file-finders implementation in private module
-* define own build backend to have setuptools work
+* migrate to hatchling
+* renamed setuptools_scm.hacks to setuptools_scm.fallbacks and drop support for pip-egg-info
+* remove trace function and use logging instead
+* unify distance=None and distance=0 they should mean the same
+  and where hiding dirty states that are now explicitly dirty
+* depend on later importlib for the full selectable api
 
 features
 --------
@@ -27,6 +32,9 @@ features
   * pre-compiled regex
   * move helpers to private modules
 
+* support passing log levels to SETUPTOOLS_SCM_DEBUG
+* support using rich.logging as console log handler if installed
+
 
 v7.1.0
 ======
diff --git a/_own_version_helper.py b/_own_version_helper.py
index b7069ba..e40cb63 100644
--- a/_own_version_helper.py
+++ b/_own_version_helper.py
@@ -4,21 +4,41 @@ to use the attribute for the versions
 """
 from __future__ import annotations
 
+import logging
+from typing import Callable
+
+from setuptools_scm import _types as _t
 from setuptools_scm import Configuration
 from setuptools_scm import get_version
 from setuptools_scm import git
 from setuptools_scm import hg
-from setuptools_scm.hacks import parse_pkginfo
+from setuptools_scm.fallbacks import parse_pkginfo
 from setuptools_scm.version import get_local_node_and_date
 from setuptools_scm.version import guess_next_dev_version
 from setuptools_scm.version import ScmVersion
 
+log = logging.getLogger("setuptools_scm")
+# todo: take fake entrypoints from pyproject.toml
+try_parse: list[Callable[[_t.PathT, Configuration], ScmVersion | None]] = [
+    parse_pkginfo,
+    git.parse,
+    hg.parse,
+    git.parse_archival,
+    hg.parse_archival,
+]
+
 
 def parse(root: str, config: Configuration) -> ScmVersion | None:
-    try:
-        return parse_pkginfo(root, config)
-    except OSError:
-        return git.parse(root, config=config) or hg.parse(root, config=config)
+    for maybe_parse in try_parse:
+        try:
+            parsed = maybe_parse(root, config)
+        except OSError as e:
+            log.warning("parse with %s failed with: %s", maybe_parse, e)
+        else:
+            if parsed is not None:
+                return parsed
+    else:
+        return None
 
 
 def scm_version() -> str:
diff --git a/pyproject.toml b/pyproject.toml
index 1ae670b..86a7402 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,6 +4,7 @@
 build-backend = "hatchling.build"
 requires = [
   "hatchling>=1.10",
+  'importlib-metadata>=4.6; python_version < "3.10"',
   "packaging>=20",
   "setuptools>=55",
   'tomli; python_version < "3.11"',
@@ -40,15 +41,19 @@ dynamic = [
   "version",
 ]
 dependencies = [
-  'importlib-metadata; python_version < "3.8"',
+  'importlib-metadata>=4.6; python_version < "3.10"',
   "packaging>=20",
   "setuptools",
   'tomli>=1; python_version < "3.11"',
   "typing-extensions",
 ]
 [project.optional-dependencies]
+rich = [
+  "rich",
+]
 test = [
   "pytest",
+  "rich",
   "virtualenv>20",
 ]
 toml = [
@@ -78,10 +83,9 @@ node-and-timestamp = "setuptools_scm.version:get_local_node_and_timestamp"
 [project.entry-points."setuptools_scm.parse_scm_fallback"]
 ".git_archival.txt" = "setuptools_scm.git:parse_archival"
 ".hg_archival.txt" = "setuptools_scm.hg:parse_archival"
-PKG-INFO = "setuptools_scm.hacks:parse_pkginfo"
-pip-egg-info = "setuptools_scm.hacks:parse_pip_egg_info"
-"pyproject.toml" = "setuptools_scm.hacks:fallback_version"
-"setup.py" = "setuptools_scm.hacks:fallback_version"
+PKG-INFO = "setuptools_scm.fallbacks:parse_pkginfo"
+"pyproject.toml" = "setuptools_scm.fallbacks:fallback_version"
+"setup.py" = "setuptools_scm.fallbacks:fallback_version"
 [project.entry-points."setuptools_scm.version_scheme"]
 "calver-by-date" = "setuptools_scm.version:calver_by_date"
 "guess-next-dev" = "setuptools_scm.version:guess_next_dev_version"
diff --git a/src/setuptools_scm/__init__.py b/src/setuptools_scm/__init__.py
index eccc68f..b29273c 100644
--- a/src/setuptools_scm/__init__.py
+++ b/src/setuptools_scm/__init__.py
@@ -28,6 +28,7 @@ if TYPE_CHECKING:
     from typing import NoReturn
     from . import _types as _t
 
+
 TEMPLATES = {
     ".py": """\
 # file generated by setuptools_scm
@@ -49,9 +50,9 @@ def dump_version(
     target = os.path.normpath(os.path.join(root, write_to))
     ext = os.path.splitext(target)[1]
     template = template or TEMPLATES.get(ext)
-    from ._trace import trace
+    from ._log import log
 
-    trace("dump", write_to, version)
+    log.debug("dump %s into %s", version, write_to)
     if template is None:
         raise ValueError(
             "bad file format: '{}' (of {}) \nonly *.txt and *.py are supported".format(
diff --git a/src/setuptools_scm/_config.py b/src/setuptools_scm/_config.py
index 34745ae..108c86e 100644
--- a/src/setuptools_scm/_config.py
+++ b/src/setuptools_scm/_config.py
@@ -9,17 +9,19 @@ from typing import Any
 from typing import Callable
 from typing import Pattern
 
+from . import _log
 from . import _types as _t
 from ._integration.pyproject_reading import (
     get_args_for_pyproject as _get_args_for_pyproject,
 )
 from ._integration.pyproject_reading import read_pyproject as _read_pyproject
 from ._overrides import read_toml_overrides
-from ._trace import trace
 from ._version_cls import _validate_version_cls
 from ._version_cls import _VersionT
 from ._version_cls import Version as _Version
 
+log = _log.log.getChild("config")
+
 DEFAULT_TAG_REGEX = re.compile(
     r"^(?:[\w-]+-)?(?P<version>[vV]?\d+(?:\.\d+){0,2}[^\+]*)(?:\+.*)?$"
 )
@@ -44,7 +46,7 @@ def _check_tag_regex(value: str | Pattern[str] | None) -> Pattern[str]:
 
 
 def _check_absolute_root(root: _t.PathT, relative_to: _t.PathT | None) -> str:
-    trace("abs root", repr(locals()))
+    log.debug("check absolute root=%s relative_to=%s", root, relative_to)
     if relative_to:
         if (
             os.path.isabs(root)
@@ -61,10 +63,10 @@ def _check_absolute_root(root: _t.PathT, relative_to: _t.PathT | None) -> str:
                 " its the directory %r\n"
                 "assuming the parent directory was passed" % (relative_to,)
             )
-            trace("dir", relative_to)
+            log.debug("dir %s", relative_to)
             root = os.path.join(relative_to, root)
         else:
-            trace("file", relative_to)
+            log.debug("file %s", relative_to)
             root = os.path.join(os.path.dirname(relative_to), root)
     return os.path.abspath(root)
 
diff --git a/src/setuptools_scm/_entrypoints.py b/src/setuptools_scm/_entrypoints.py
index 9b5b093..01b48b1 100644
--- a/src/setuptools_scm/_entrypoints.py
+++ b/src/setuptools_scm/_entrypoints.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import warnings
 from typing import Any
 from typing import Callable
 from typing import cast
@@ -8,17 +7,23 @@ from typing import Iterator
 from typing import overload
 from typing import TYPE_CHECKING
 
+from typing_extensions import Protocol
+
+from . import _log
 from . import version
-from ._trace import trace
 
 if TYPE_CHECKING:
     from ._config import Configuration
-    from typing_extensions import Protocol
     from . import _types as _t
-else:
-    Configuration = Any
 
-    class Protocol:
+
+log = _log.log.getChild("entrypoints")
+
+
+class EntrypointProtocol(Protocol):
+    name: str
+
+    def load(self) -> Any:
         pass
 
 
@@ -34,64 +39,52 @@ def _version_from_entrypoints(
 
     from .discover import iter_matching_entrypoints
 
-    trace("version_from_ep", entrypoint, root)
+    log.debug("version_from_ep %s in %s", entrypoint, root)
     for ep in iter_matching_entrypoints(root, entrypoint, config):
         fn = ep.load()
         maybe_version: version.ScmVersion | None = fn(root, config=config)
-        trace(ep, version)
+        log.debug("%s found %r", ep, maybe_version)
         if maybe_version is not None:
             return maybe_version
     return None
 
 
 try:
-    from importlib.metadata import entry_points  # type: ignore
-    from importlib.metadata import EntryPoint
+    from importlib_metadata import entry_points
+    from importlib_metadata import EntryPoint
 except ImportError:
-    try:
-        from importlib_metadata import entry_points
-        from importlib_metadata import EntryPoint
-    except ImportError:
-        from collections import defaultdict
-
-        def entry_points() -> dict[str, list[_t.EntrypointProtocol]]:
-            warnings.warn(
-                "importlib metadata missing, "
-                "this may happen at build time for python3.7"
-            )
-            return defaultdict(list)
-
-        class EntryPoint:  # type: ignore
-            def __init__(self, *args: Any, **kwargs: Any):
-                pass  # entry_points() already provides the warning
+    from importlib.metadata import entry_points  # type: ignore [no-redef, import]
+    from importlib.metadata import EntryPoint  # type: ignore [no-redef]
 
 
 def iter_entry_points(
     group: str, name: str | None = None
-) -> Iterator[_t.EntrypointProtocol]:
-    all_eps = entry_points()
-    if hasattr(all_eps, "select"):
-        eps = all_eps.select(group=group)
-    else:
-        eps = all_eps[group]
-    if name is None:
-        return iter(eps)
-    return (ep for ep in eps if ep.name == name)
+) -> Iterator[EntrypointProtocol]:
+    eps = entry_points(group=group)
+    res = (
+        eps
+        if name is None
+        else eps.select(  # type: ignore [no-untyped-call]
+            name=name,
+        )
+    )
+    return cast(Iterator[EntrypointProtocol], iter(res))
 
 
 def _get_ep(group: str, name: str) -> Any | None:
-    from ._entrypoints import iter_entry_points
-
     for ep in iter_entry_points(group, name):
-        trace("ep found:", ep.name)
+        log.debug("ep found: %s", ep.name)
         return ep.load()
     else:
         return None
 
 
 def _get_from_object_reference_str(path: str) -> Any | None:
+    ep: EntrypointProtocol = EntryPoint(
+        path, path, None
+    )  # type: ignore [no-untyped-call]
     try:
-        return EntryPoint(path, path, None).load()
+        return ep.load()
     except (AttributeError, ModuleNotFoundError):
         return None
 
@@ -121,22 +114,22 @@ def _iter_version_schemes(
 
 @overload
 def _call_version_scheme(
-    version: version.ScmVersion, entypoint: str, given_value: str, default: str
+    version: version.ScmVersion, entrypoint: str, given_value: str, default: str
 ) -> str:
     ...
 
 
 @overload
 def _call_version_scheme(
-    version: version.ScmVersion, entypoint: str, given_value: str, default: None
+    version: version.ScmVersion, entrypoint: str, given_value: str, default: None
 ) -> str | None:
     ...
 
 
 def _call_version_scheme(
-    version: version.ScmVersion, entypoint: str, given_value: str, default: str | None
+    version: version.ScmVersion, entrypoint: str, given_value: str, default: str | None
 ) -> str | None:
-    for scheme in _iter_version_schemes(entypoint, given_value):
+    for scheme in _iter_version_schemes(entrypoint, given_value):
         result = scheme(version)
         if result is not None:
             return result
diff --git a/src/setuptools_scm/_file_finders/__init__.py b/src/setuptools_scm/_file_finders/__init__.py
index 5a7a1c0..5e3304a 100644
--- a/src/setuptools_scm/_file_finders/__init__.py
+++ b/src/setuptools_scm/_file_finders/__init__.py
@@ -6,9 +6,11 @@ from typing import Callable
 
 from typing_extensions import TypeGuard
 
-from setuptools_scm import _types as _t
-from setuptools_scm._entrypoints import iter_entry_points
-from setuptools_scm._trace import trace
+from .. import _log
+from .. import _types as _t
+from .._entrypoints import iter_entry_points
+
+log = _log.log.getChild("file_finder")
 
 
 def scm_find_files(
@@ -84,7 +86,7 @@ def is_toplevel_acceptable(toplevel: str | None) -> TypeGuard[str]:
     )
     ignored = [os.path.normcase(p) for p in ignored]
 
-    trace(toplevel, ignored)
+    log.debug("toplevel: %r\n    ignored %s", toplevel, ignored)
 
     return toplevel not in ignored
 
diff --git a/src/setuptools_scm/_file_finders/git.py b/src/setuptools_scm/_file_finders/git.py
index f007d5b..873b4ba 100644
--- a/src/setuptools_scm/_file_finders/git.py
+++ b/src/setuptools_scm/_file_finders/git.py
@@ -10,8 +10,7 @@ from . import is_toplevel_acceptable
 from . import scm_find_files
 from .. import _types as _t
 from .._run_cmd import run as _run
-from .._trace import trace
-from ..utils import data_from_mime
+from ..integration import data_from_mime
 
 log = logging.getLogger(__name__)
 
@@ -43,7 +42,7 @@ def _git_toplevel(path: str) -> str | None:
             # for this assertion to work. Length of string isn't changed by replace
             # ``\\`` is just and escape for `\`
             out = cwd[: -len(out)]
-        trace("find files toplevel", out)
+        log.debug("find files toplevel %s", out)
         return os.path.normcase(os.path.realpath(out.strip()))
     except subprocess.CalledProcessError:
         # git returned error, we are not in a git repo
@@ -94,7 +93,7 @@ def git_find_files(path: _t.PathT = "") -> list[str]:
         return []
     fullpath = os.path.abspath(os.path.normpath(path))
     if not fullpath.startswith(toplevel):
-        trace("toplevel mismatch", toplevel, fullpath)
+        log.warning("toplevel mismatch computed %s vs resolved %s ", toplevel, fullpath)
     git_files, git_dirs = _git_ls_files_and_dirs(toplevel)
     return scm_find_files(path, git_files, git_dirs)
 
@@ -112,5 +111,5 @@ def git_archive_find_files(path: _t.PathT = "") -> list[str]:
         # Substitutions have not been performed, so not a reliable archive
         return []
 
-    trace("git archive detected - fallback to listing all files")
+    log.warning("git archive detected - fallback to listing all files")
     return scm_find_files(path, set(), set(), force_all_files=True)
diff --git a/src/setuptools_scm/_file_finders/hg.py b/src/setuptools_scm/_file_finders/hg.py
index 8f777ea..ec8604a 100644
--- a/src/setuptools_scm/_file_finders/hg.py
+++ b/src/setuptools_scm/_file_finders/hg.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import logging
 import os
 import subprocess
 
@@ -7,8 +8,9 @@ from .. import _types as _t
 from .._file_finders import is_toplevel_acceptable
 from .._file_finders import scm_find_files
 from .._run_cmd import run as _run
-from .._trace import trace
-from ..utils import data_from_mime
+from ..integration import data_from_mime
+
+log = logging.getLogger(__name__)
 
 
 def _hg_toplevel(path: str) -> str | None:
@@ -66,5 +68,5 @@ def hg_archive_find_files(path: _t.PathT = "") -> list[str]:
         # Ensure file is valid
         return []
 
-    trace("hg archive detected - fallback to listing all files")
+    log.warning("hg archive detected - fallback to listing all files")
     return scm_find_files(path, set(), set(), force_all_files=True)
diff --git a/src/setuptools_scm/_log.py b/src/setuptools_scm/_log.py
new file mode 100644
index 0000000..35c050d
--- /dev/null
+++ b/src/setuptools_scm/_log.py
@@ -0,0 +1,94 @@
+"""
+logging helpers, supports vendoring
+"""
+from __future__ import annotations
+
+import contextlib
+import logging
+import os
+import sys
+from typing import IO
+from typing import Iterator
+
+log = logging.getLogger(__name__.rsplit(".", 1)[0])
+log.propagate = False
+
+
+class AlwaysStdErrHandler(logging.StreamHandler):  # type: ignore[type-arg]
+    def __init___(self) -> None:
+        super().__init__(sys.stderr)
+
+    @property  # type: ignore [override]
+    def stream(self) -> IO[str]:
+        return sys.stderr
+
+    @stream.setter
+    def stream(self, value: IO[str]) -> None:
+        assert value is sys.stderr
+
+
+def make_default_handler() -> logging.Handler:
+    try:
+        from rich.console import Console
+
+        console = Console(stderr=True)
+        from rich.logging import RichHandler
+
+        return RichHandler(console=console)
+    except ImportError:
+        handler = AlwaysStdErrHandler()
+        handler.setFormatter(logging.Formatter("%(levelname)s %(name)s %(message)s"))
+        return handler
+
+
+_default_handler = make_default_handler()
+
+log.addHandler(_default_handler)
+
+
+def _default_log_level() -> str | int:
+    val: str = os.environ.get("SETUPTOOLS_SCM_DEBUG", "")
+    level: str | int
+    if val:
+        level = logging.DEBUG
+        levelname: str | int = logging.getLevelName(val)
+        if isinstance(levelname, int):
+            level = val
+        else:
+            level = logging.WARNING
+    else:
+        level = logging.WARNING
+    return level
+
+
+log.setLevel(_default_log_level())
+
+
+@contextlib.contextmanager
+def defer_to_pytest() -> Iterator[None]:
+    log.propagate = True
+    old_level = log.level
+    log.setLevel(logging.NOTSET)
+    log.removeHandler(_default_handler)
+    try:
+        yield
+    finally:
+        log.addHandler(_default_handler)
+        log.propagate = False
+        log.setLevel(old_level)
+
+
+@contextlib.contextmanager
+def enable_debug(handler: logging.Handler = _default_handler) -> Iterator[None]:
+    log.addHandler(handler)
+    old_level = log.level
+    log.setLevel(logging.DEBUG)
+    old_handler_level = handler.level
+    handler.setLevel(logging.DEBUG)
+    try:
+        yield
+    finally:
+        log.setLevel(old_level)
+        handler.setLevel(old_handler_level)
+        if handler is not _default_handler:
+            log.removeHandler(handler)
diff --git a/src/setuptools_scm/_overrides.py b/src/setuptools_scm/_overrides.py
index f08f170..6279377 100644
--- a/src/setuptools_scm/_overrides.py
+++ b/src/setuptools_scm/_overrides.py
@@ -4,9 +4,11 @@ import os
 from typing import Any
 
 from . import _config
+from . import _log
 from . import version
 from ._integration.pyproject_reading import lazy_toml_load
-from ._trace import trace
+
+log = _log.log.getChild("overrides")
 
 PRETEND_KEY = "SETUPTOOLS_SCM_PRETEND_VERSION"
 PRETEND_KEY_NAMED = PRETEND_KEY + "_FOR_{name}"
@@ -30,7 +32,7 @@ def _read_pretended_version_for(
     tries ``SETUPTOOLS_SCM_PRETEND_VERSION``
     and ``SETUPTOOLS_SCM_PRETEND_VERSION_FOR_$UPPERCASE_DIST_NAME``
     """
-    trace("dist name:", config.dist_name)
+    log.debug("dist name: %s", config.dist_name)
 
     pretended = read_named_env(name="PRETEND_VERSION", dist_name=config.dist_name)
 
diff --git a/src/setuptools_scm/_run_cmd.py b/src/setuptools_scm/_run_cmd.py
index b528504..b1e5f1a 100644
--- a/src/setuptools_scm/_run_cmd.py
+++ b/src/setuptools_scm/_run_cmd.py
@@ -3,11 +3,73 @@ from __future__ import annotations
 import os
 import shlex
 import subprocess
+import textwrap
+import warnings
+from typing import Callable
 from typing import Mapping
+from typing import overload
+from typing import Sequence
+from typing import TYPE_CHECKING
+from typing import TypeVar
 
-from . import _trace
+from . import _log
 from . import _types as _t
 
+if TYPE_CHECKING:
+    BaseCompletedProcess = subprocess.CompletedProcess[str]
+else:
+    BaseCompletedProcess = subprocess.CompletedProcess
+
+
+log = _log.log.getChild("run_cmd")
+
+PARSE_RESULT = TypeVar("PARSE_RESULT")
+T = TypeVar("T")
+
+
+class CompletedProcess(BaseCompletedProcess):
+    @classmethod
+    def from_raw(
+        cls, input: BaseCompletedProcess, strip: bool = True
+    ) -> CompletedProcess:
+        return cls(
+            args=input.args,
+            returncode=input.returncode,
+            stdout=input.stdout.strip() if strip and input.stdout else input.stdout,
+            stderr=input.stderr.strip() if strip and input.stderr else input.stderr,
+        )
+
+    @overload
+    def parse_success(
+        self,
+        parse: Callable[[str], PARSE_RESULT],
+        default: None = None,
+        error_msg: str | None = None,
+    ) -> PARSE_RESULT | None:
+        ...
+
+    @overload
+    def parse_success(
+        self,
+        parse: Callable[[str], PARSE_RESULT],
+        default: T,
+        error_msg: str | None = None,
+    ) -> PARSE_RESULT | T:
+        ...
+
+    def parse_success(
+        self,
+        parse: Callable[[str], PARSE_RESULT],
+        default: T | None = None,
+        error_msg: str | None = None,
+    ) -> PARSE_RESULT | T | None:
+        if self.returncode:
+            if error_msg:
+                log.warning("%s %s", error_msg, self)
+            return default
+        else:
+            return parse(self.stdout)
+
 
 def no_git_env(env: Mapping[str, str]) -> dict[str, str]:
     # adapted from pre-commit
@@ -21,7 +83,7 @@ def no_git_env(env: Mapping[str, str]) -> dict[str, str]:
     # GIT_INDEX_FILE: Causes 'error invalid object ...' during commit
     for k, v in env.items():
         if k.startswith("GIT_"):
-            _trace.trace(k, v)
+            log.debug("%s: %s", k, v)
     return {
         k: v
         for k, v in env.items()
@@ -66,17 +128,17 @@ def run(
     trace: bool = True,
     timeout: int = 20,
     check: bool = False,
-) -> subprocess.CompletedProcess[str]:
+) -> CompletedProcess:
     if isinstance(cmd, str):
         cmd = shlex.split(cmd)
     else:
         cmd = [os.fspath(x) for x in cmd]
-    if trace:
-        _trace.trace_command(cmd, cwd)
+    cmd_4_trace = " ".join(map(_unsafe_quote_for_display, cmd))
+    log.debug("at %s\n    $ %s ", cwd, cmd_4_trace)
     res = subprocess.run(
         cmd,
         capture_output=True,
-        cwd=str(cwd),
+        cwd=os.fspath(cwd),
         env=dict(
             avoid_pip_isolation(no_git_env(os.environ)),
             # os.environ,
@@ -88,17 +150,45 @@ def run(
         text=True,
         timeout=timeout,
     )
-    if strip:
-        if res.stdout:
-            res.stdout = ensure_stripped_str(res.stdout)
-            res.stderr = ensure_stripped_str(res.stderr)
+
+    res = CompletedProcess.from_raw(res, strip=strip)
     if trace:
         if res.stdout:
-            _trace.trace("out:\n", res.stdout, indent=True)
+            log.debug("out:\n%s", textwrap.indent(res.stdout, "    "))
         if res.stderr:
-            _trace.trace("err:\n", res.stderr, indent=True)
+            log.debug("err:\n%s", textwrap.indent(res.stderr, "    "))
         if res.returncode:
-            _trace.trace("ret:", res.returncode)
+            log.debug("ret: %s", res.returncode)
     if check:
         res.check_returncode()
     return res
+
+
+def _unsafe_quote_for_display(item: _t.PathT) -> str:
+    # give better results than shlex.join in our cases
+    text = os.fspath(item)
+    return text if all(c not in text for c in " {[:") else f'"{text}"'
+
+
+def has_command(
+    name: str, args: Sequence[str] = ["version"], warn: bool = True
+) -> bool:
+    try:
+        p = run([name, *args], cwd=".", timeout=5)
+    except OSError as e:
+        log.warning("command %s missing: %s", name, e)
+        res = False
+    except subprocess.TimeoutExpired as e:
+        log.warning("command %s timed out %s", name, e)
+        res = False
+
+    else:
+        res = not p.returncode
+    if not res and warn:
+        warnings.warn("%r was not found" % name, category=RuntimeWarning)
+    return res
+
+
+def require_command(name: str) -> None:
+    if not has_command(name, warn=False):
+        raise OSError(f"{name!r} was not found")
diff --git a/src/setuptools_scm/_trace.py b/src/setuptools_scm/_trace.py
deleted file mode 100644
index cf5fcfa..0000000
--- a/src/setuptools_scm/_trace.py
+++ /dev/null
@@ -1,30 +0,0 @@
-from __future__ import annotations
-
-import os
-import sys
-import textwrap
-from typing import Sequence
-
-from . import _types as _t
-
-DEBUG: bool = bool(os.environ.get("SETUPTOOLS_SCM_DEBUG"))
-
-
-def trace(*k: object, indent: bool = False) -> None:
-    if not DEBUG:
-        if indent and len(k) > 1:
-            k = (k[0],) + tuple(textwrap.indent(str(s), "    ") for s in k[1:])
-        print(*k, file=sys.stderr, flush=True)
-
-
-def _unsafe_quote_for_display(item: _t.PathT) -> str:
-    # give better results than shlex.join in our cases
-    text = os.fspath(item)
-    return text if all(c not in text for c in " {[:") else f'"{text}"'
-
-
-def trace_command(cmd: Sequence[_t.PathT], cwd: _t.PathT) -> None:
-    if not DEBUG:
-        return
-    cmd_4_trace = " ".join(map(_unsafe_quote_for_display, cmd))
-    trace(f"---\n > {cwd}\\$ ", cmd_4_trace, indent=True)
diff --git a/src/setuptools_scm/_types.py b/src/setuptools_scm/_types.py
index 7cd57a1..fdcd2dd 100644
--- a/src/setuptools_scm/_types.py
+++ b/src/setuptools_scm/_types.py
@@ -1,19 +1,17 @@
 from __future__ import annotations
 
 import os
-from typing import Any
 from typing import Callable
 from typing import List
 from typing import Sequence
 from typing import Tuple
-from typing import TypeVar
+from typing import TYPE_CHECKING
 from typing import Union
 
-from typing_extensions import ParamSpec
-from typing_extensions import Protocol
 from typing_extensions import TypeAlias
 
-from . import version
+if TYPE_CHECKING:
+    from . import version
 
 PathT: TypeAlias = Union["os.PathLike[str]", str]
 
@@ -22,24 +20,3 @@ CMD_TYPE: TypeAlias = Union[Sequence[PathT], str]
 VERSION_SCHEME: TypeAlias = Union[str, Callable[["version.ScmVersion"], str]]
 VERSION_SCHEMES: TypeAlias = Union[List[str], Tuple[str, ...], VERSION_SCHEME]
 SCMVERSION: TypeAlias = "version.ScmVersion"
-
-
-class EntrypointProtocol(Protocol):
-    name: str
-
-    def load(self) -> Any:
-        pass
-
-
-T = TypeVar("T")
-T2 = TypeVar("T2")
-P = ParamSpec("P")
-
-
-def transfer_input_args(
-    template: Callable[P, T],
-) -> Callable[[Callable[..., T]], Callable[P, T]]:
-    def decorate(func: Callable[..., T2]) -> Callable[P, T2]:
-        return func
-
-    return decorate
diff --git a/src/setuptools_scm/_version_cls.py b/src/setuptools_scm/_version_cls.py
index f6e87a8..e62c9fa 100644
--- a/src/setuptools_scm/_version_cls.py
+++ b/src/setuptools_scm/_version_cls.py
@@ -38,8 +38,9 @@ def _version_as_tuple(version_str: str) -> tuple[int | str, ...]:
     try:
         parsed_version = Version(version_str)
     except InvalidVersion:
-        log = getLogger("setuptools_scm")
-        log.exception("failed to parse version %s", version_str)
+        log = getLogger(__name__).parent
+        assert log is not None
+        log.error("failed to parse version %s", version_str)
         return (version_str,)
     else:
         version_fields: tuple[int | str, ...] = parsed_version.release
diff --git a/src/setuptools_scm/discover.py b/src/setuptools_scm/discover.py
index 2bd01e9..85dd31f 100644
--- a/src/setuptools_scm/discover.py
+++ b/src/setuptools_scm/discover.py
@@ -1,32 +1,28 @@
 from __future__ import annotations
 
 import os
+from pathlib import Path
 from typing import Iterable
 from typing import Iterator
 
+from . import _entrypoints
+from . import _log
 from . import _types as _t
 from ._config import Configuration
-from ._trace import trace
 
+log = _log.log.getChild("discover")
 
-def walk_potential_roots(
-    root: _t.PathT, search_parents: bool = True
-) -> Iterator[_t.PathT]:
+
+def walk_potential_roots(root: _t.PathT, search_parents: bool = True) -> Iterator[Path]:
     """
     Iterate though a path and each of its parents.
     :param root: File path.
     :param search_parents: If ``False`` the parents are not considered.
     """
-
-    if not search_parents:
-        yield root
-        return
-
-    tail = root
-
-    while tail:
-        yield root
-        root, tail = os.path.split(root)
+    root = Path(root)
+    yield root
+    if search_parents:
+        yield from root.parents
 
 
 def match_entrypoint(root: _t.PathT, name: str) -> bool:
@@ -40,14 +36,14 @@ def match_entrypoint(root: _t.PathT, name: str) -> bool:
     if os.path.exists(os.path.join(root, name)):
         if not os.path.isabs(name):
             return True
-        trace("ignoring bad ep", name)
+        log.debug("ignoring bad ep %s", name)
 
     return False
 
 
 def iter_matching_entrypoints(
     root: _t.PathT, entrypoint: str, config: Configuration
-) -> Iterable[_t.EntrypointProtocol]:
+) -> Iterable[_entrypoints.EntrypointProtocol]:
     """
     Consider different entry-points in ``root`` and optionally its parents.
     :param root: File path.
@@ -56,12 +52,12 @@ def iter_matching_entrypoints(
         read ``search_parent_directories``, write found parent to ``parent``.
     """
 
-    trace("looking for ep", entrypoint, root)
+    log.debug("looking for ep %s in %s", entrypoint, root)
     from ._entrypoints import iter_entry_points
 
     for wd in walk_potential_roots(root, config.search_parent_directories):
         for ep in iter_entry_points(entrypoint):
             if match_entrypoint(wd, ep.name):
-                trace("found ep", ep, "in", wd)
+                log.debug("found ep %s in %s", ep, wd)
                 config.parent = wd
                 yield ep
diff --git a/src/setuptools_scm/hacks.py b/src/setuptools_scm/fallbacks.py
similarity index 69%
rename from src/setuptools_scm/hacks.py
rename to src/setuptools_scm/fallbacks.py
index 5aaa0db..e1ea60c 100644
--- a/src/setuptools_scm/hacks.py
+++ b/src/setuptools_scm/fallbacks.py
@@ -1,23 +1,26 @@
 from __future__ import annotations
 
+import logging
 import os
+from pathlib import Path
 from typing import TYPE_CHECKING
 
 if TYPE_CHECKING:
     from . import _types as _t
 from . import Configuration
-from .utils import data_from_mime
-from ._trace import trace
+from .integration import data_from_mime
 from .version import meta
 from .version import ScmVersion
 from .version import tag_to_version
 
+log = logging.getLogger(__name__)
+
 _UNKNOWN = "UNKNOWN"
 
 
 def parse_pkginfo(root: _t.PathT, config: Configuration) -> ScmVersion | None:
-    pkginfo = os.path.join(root, "PKG-INFO")
-    trace("pkginfo", pkginfo)
+    pkginfo = Path(root) / "PKG-INFO"
+    log.debug("pkginfo %s", pkginfo)
     data = data_from_mime(pkginfo)
     version = data.get("Version", _UNKNOWN)
     if version != _UNKNOWN:
@@ -26,17 +29,6 @@ def parse_pkginfo(root: _t.PathT, config: Configuration) -> ScmVersion | None:
         return None
 
 
-def parse_pip_egg_info(root: _t.PathT, config: Configuration) -> ScmVersion | None:
-    pipdir = os.path.join(root, "pip-egg-info")
-    if not os.path.isdir(pipdir):
-        return None
-    items = os.listdir(pipdir)
-    trace("pip-egg-info", pipdir, items)
-    if not items:
-        return None
-    return parse_pkginfo(os.path.join(pipdir, items[0]), config=config)
-
-
 def fallback_version(root: _t.PathT, config: Configuration) -> ScmVersion | None:
     if config.parentdir_prefix_version is not None:
         _, parent_name = os.path.split(os.path.abspath(root))
@@ -47,6 +39,6 @@ def fallback_version(root: _t.PathT, config: Configuration) -> ScmVersion | None
             if version is not None:
                 return meta(str(version), preformatted=True, config=config)
     if config.fallback_version is not None:
-        trace("FALLBACK")
+        log.debug("FALLBACK %s", config.fallback_version)
         return meta(config.fallback_version, preformatted=True, config=config)
     return None
diff --git a/src/setuptools_scm/git.py b/src/setuptools_scm/git.py
index 484bd4d..d1da7f8 100644
--- a/src/setuptools_scm/git.py
+++ b/src/setuptools_scm/git.py
@@ -1,31 +1,34 @@
 from __future__ import annotations
 
+import dataclasses
+import logging
 import os
 import re
+import shlex
 import warnings
 from datetime import date
 from datetime import datetime
-from os.path import isfile
-from os.path import join
 from os.path import samefile
+from pathlib import Path
 from typing import Callable
+from typing import Sequence
 from typing import TYPE_CHECKING
 
 from . import _types as _t
 from . import Configuration
-from ._trace import trace
+from . import discover
+from ._run_cmd import CompletedProcess as _CompletedProcess
+from ._run_cmd import require_command as _require_command
+from ._run_cmd import run as _run
+from .integration import data_from_mime
 from .scm_workdir import Workdir
-from .utils import _CmdResult
-from .utils import data_from_mime
-from .utils import do_ex
-from .utils import require_command
 from .version import meta
 from .version import ScmVersion
 from .version import tag_to_version
 
 if TYPE_CHECKING:
     from . import hg_git
-
+log = logging.getLogger(__name__)
 
 REF_TAG_RE = re.compile(r"(?<=\btag: )([^,]+)\b")
 DESCRIBE_UNSUPPORTED = "%(describe"
@@ -44,89 +47,105 @@ DEFAULT_DESCRIBE = [
 ]
 
 
+def run_git(
+    args: Sequence[str | os.PathLike[str]], repo: Path, *, check: bool = False
+) -> _CompletedProcess:
+    return _run(["git", "--git-dir", repo / ".git", *args], cwd=repo, check=check)
+
+
 class GitWorkdir(Workdir):
     """experimental, may change at any time"""
 
-    COMMAND = "git"
-
     @classmethod
     def from_potential_worktree(cls, wd: _t.PathT) -> GitWorkdir | None:
-        require_command(cls.COMMAND)
-        wd = os.path.abspath(wd)
-        git_dir = join(wd, ".git")
-        real_wd, _, ret = do_ex(
-            ["git", "--git-dir", git_dir, "rev-parse", "--show-prefix"], wd
-        )
-        real_wd = real_wd[:-1]  # remove the trailing pathsep
-        if ret:
+        wd = Path(wd).resolve()
+        real_wd = run_git(["rev-parse", "--show-prefix"], wd).parse_success(parse=str)
+        if real_wd is None:
             return None
+        else:
+            real_wd = real_wd[:-1]  # remove the trailing pathsep
+
         if not real_wd:
-            real_wd = wd
+            real_wd = os.fspath(wd)
         else:
-            assert wd.replace("\\", "/").endswith(real_wd)
+            str_wd = os.fspath(wd)
+            assert str_wd.replace("\\", "/").endswith(real_wd)
             # In windows wd contains ``\`` which should be replaced by ``/``
             # for this assertion to work.  Length of string isn't changed by replace
             # ``\\`` is just and escape for `\`
-            real_wd = wd[: -len(real_wd)]
-        trace("real root", real_wd)
+            real_wd = str_wd[: -len(real_wd)]
+        log.debug("real root %s", real_wd)
         if not samefile(real_wd, wd):
             return None
 
-        return cls(real_wd)
-
-    def do_ex_git(self, cmd: list[str]) -> _CmdResult:
-        return self.do_ex(["git", "--git-dir", join(self.path, ".git")] + cmd)
+        return cls(Path(real_wd))
 
     def is_dirty(self) -> bool:
-        out, _, _ = self.do_ex_git(["status", "--porcelain", "--untracked-files=no"])
-        return bool(out)
+        return run_git(
+            ["status", "--porcelain", "--untracked-files=no"], self.path
+        ).parse_success(
+            parse=bool,
+            default=False,
+        )
 
     def get_branch(self) -> str | None:
-        branch, err, ret = self.do_ex_git(["rev-parse", "--abbrev-ref", "HEAD"])
-        if ret:
-            trace("branch err", branch, err, ret)
-            branch, err, ret = self.do_ex_git(["symbolic-ref", "--short", "HEAD"])
-            if ret:
-                trace("branch err (symbolic-ref)", branch, err, ret)
-                return None
-        return branch
+        return run_git(
+            ["rev-parse", "--abbrev-ref", "HEAD"],
+            self.path,
+        ).parse_success(
+            parse=str,
+            error_msg="branch err (abbrev-err)",
+        ) or run_git(
+            ["symbolic-ref", "--short", "HEAD"],
+            self.path,
+        ).parse_success(
+            parse=str,
+            error_msg="branch err (symbolic-ref)",
+        )
 
     def get_head_date(self) -> date | None:
-        timestamp, err, ret = self.do_ex_git(
-            ["-c", "log.showSignature=false", "log", "-n", "1", "HEAD", "--format=%cI"]
+        def parse_timestamp(timestamp_text: str) -> date | None:
+            if "%c" in timestamp_text:
+                log.warning("git too old -> timestamp is %r", timestamp_text)
+                return None
+            return datetime.fromisoformat(timestamp_text).date()
+
+        res = run_git(
+            [
+                *("-c", "log.showSignature=false"),
+                *("log", "-n", "1", "HEAD"),
+                "--format=%cI",
+            ],
+            self.path,
+        )
+        return res.parse_success(
+            parse=parse_timestamp,
+            error_msg="logging the iso date for head failed",
+            default=None,
         )
-        if ret:
-            trace("timestamp err", timestamp, err, ret)
-            return None
-        # TODO, when dropping python3.6 use fromiso
-        date_part = timestamp.split("T")[0]
-        if "%c" in date_part:
-            trace("git too old -> timestamp is ", timestamp)
-            return None
-        return datetime.strptime(date_part, r"%Y-%m-%d").date()
 
     def is_shallow(self) -> bool:
-        return isfile(join(self.path, ".git/shallow"))
+        return self.path.joinpath(".git/shallow").is_file()
 
     def fetch_shallow(self) -> None:
-        self.do_ex_git(["fetch", "--unshallow"])
+        run_git(["fetch", "--unshallow"], self.path, check=True)
 
     def node(self) -> str | None:
-        node, _, ret = self.do_ex_git(["rev-parse", "--verify", "--quiet", "HEAD"])
-        if not ret:
+        def _unsafe_short_node(node: str) -> str:
             return node[:7]
-        else:
-            return None
+
+        return run_git(
+            ["rev-parse", "--verify", "--quiet", "HEAD"], self.path
+        ).parse_success(
+            parse=_unsafe_short_node,
+        )
 
     def count_all_nodes(self) -> int:
-        revs, _, _ = self.do_ex_git(["rev-list", "HEAD"])
-        return revs.count("\n") + 1
+        res = run_git(["rev-list", "HEAD"], self.path)
+        return res.stdout.count("\n") + 1
 
-    def default_describe(self) -> _CmdResult:
-        git_dir = join(self.path, ".git")
-        return self.do_ex(
-            DEFAULT_DESCRIBE[:1] + ["--git-dir", git_dir] + DEFAULT_DESCRIBE[1:]
-        )
+    def default_describe(self) -> _CompletedProcess:
+        return run_git(DEFAULT_DESCRIBE[1:], self.path)
 
 
 def warn_on_shallow(wd: GitWorkdir) -> None:
@@ -150,7 +169,7 @@ def fail_on_shallow(wd: GitWorkdir) -> None:
         )
 
 
-def get_working_directory(config: Configuration, root: str) -> GitWorkdir | None:
+def get_working_directory(config: Configuration, root: _t.PathT) -> GitWorkdir | None:
     """
     Return the working directory (``GitWorkdir``).
     """
@@ -158,14 +177,18 @@ def get_working_directory(config: Configuration, root: str) -> GitWorkdir | None
     if config.parent:  # todo broken
         return GitWorkdir.from_potential_worktree(config.parent)
 
-    if config.search_parent_directories:
-        return search_parent(root)
+    for potential_root in discover.walk_potential_roots(
+        root, search_parents=config.search_parent_directories
+    ):
+        potential_wd = GitWorkdir.from_potential_worktree(potential_root)
+        if potential_wd is not None:
+            return potential_wd
 
     return GitWorkdir.from_potential_worktree(root)
 
 
 def parse(
-    root: str,
+    root: _t.PathT,
     config: Configuration,
     describe_command: str | list[str] | None = None,
     pre_parse: Callable[[GitWorkdir], None] = warn_on_shallow,
@@ -173,6 +196,7 @@ def parse(
     """
     :param pre_parse: experimental pre_parse action, may change at any time
     """
+    _require_command("git")
     wd = get_working_directory(config, root)
     if wd:
         return _git_parse_inner(
@@ -182,6 +206,34 @@ def parse(
         return None
 
 
+def version_from_describe(
+    wd: GitWorkdir | hg_git.GitWorkdirHgClient,
+    config: Configuration,
+    describe_command: _t.CMD_TYPE | None,
+) -> ScmVersion | None:
+    pass
+
+    if config.git_describe_command is not None:
+        describe_command = config.git_describe_command
+
+    if describe_command is not None:
+        if isinstance(describe_command, str):
+            describe_command = shlex.split(describe_command)
+            # todo: figure how ot ensure git with gitdir gets correctly invoked
+        if describe_command[0] == "git":
+            describe_res = run_git(describe_command[1:], wd.path)
+        else:
+            describe_res = _run(describe_command, wd.path)
+    else:
+        describe_res = wd.default_describe()
+
+    def parse_describe(output: str) -> ScmVersion:
+        tag, distance, node, dirty = _git_parse_describe(output)
+        return meta(tag=tag, distance=distance, dirty=dirty, node=node, config=config)
+
+    return describe_res.parse_success(parse=parse_describe)
+
+
 def _git_parse_inner(
     config: Configuration,
     wd: GitWorkdir | hg_git.GitWorkdirHgClient,
@@ -191,47 +243,30 @@ def _git_parse_inner(
     if pre_parse:
         pre_parse(wd)
 
-    if config.git_describe_command is not None:
-        describe_command = config.git_describe_command
+    version = version_from_describe(wd, config, describe_command)
 
-    if describe_command is not None:
-        out, _, ret = wd.do_ex(describe_command)
-    else:
-        out, _, ret = wd.default_describe()
-    distance: int | None
-    node: str | None
-    if ret == 0:
-        tag, distance, node, dirty = _git_parse_describe(out)
-        if distance == 0 and not dirty:
-            distance = None
-    else:
+    if version is None:
         # If 'git git_describe_command' failed, try to get the information otherwise.
-        tag = "0.0"
+        tag = config.version_cls("0.0")
         node = wd.node()
         if node is None:
             distance = 0
+            dirty = True
         else:
             distance = wd.count_all_nodes()
             node = "g" + node
-        dirty = wd.is_dirty()
-
+            dirty = wd.is_dirty()
+        version = meta(
+            tag=tag, distance=distance, dirty=dirty, node=node, config=config
+        )
     branch = wd.get_branch()
     node_date = wd.get_head_date() or date.today()
-
-    return meta(
-        tag,
-        branch=branch,
-        node=node,
-        node_date=node_date,
-        distance=distance,
-        dirty=dirty,
-        config=config,
-    )
+    return dataclasses.replace(version, branch=branch, node_date=node_date)
 
 
 def _git_parse_describe(
     describe_output: str,
-) -> tuple[str, int | None, str | None, bool]:
+) -> tuple[str, int, str | None, bool]:
     # 'describe_output' looks e.g. like 'v1.5.0-0-g4060507' or
     # 'v1.15.1rc1-37-g9bd1298-dirty'.
     # It may also just be a bare tag name if this is a tagged commit and we are
@@ -246,7 +281,7 @@ def _git_parse_describe(
     split = describe_output.rsplit("-", 2)
     if len(split) < 3:  # probably a tagged commit
         tag = describe_output
-        number = None
+        number = 0
         node = None
     else:
         tag, number_, node = split
@@ -254,38 +289,11 @@ def _git_parse_describe(
     return tag, number, node, dirty
 
 
-def search_parent(dirname: _t.PathT) -> GitWorkdir | None:
-    """
-    Walk up the path to find the `.git` directory.
-    :param dirname: Directory from which to start searching.
-    """
-
-    # Code based on:
-    # https://github.com/gitpython-developers/GitPython/blob/main/git/repo/base.py
-
-    curpath = os.path.abspath(dirname)
-
-    while curpath:
-        try:
-            wd = GitWorkdir.from_potential_worktree(curpath)
-        except Exception:
-            wd = None
-
-        if wd is not None:
-            return wd
-
-        curpath, tail = os.path.split(curpath)
-
-        if not tail:
-            return None
-    return None
-
-
 def archival_to_version(
     data: dict[str, str], config: Configuration
 ) -> ScmVersion | None:
     node: str | None
-    trace("data", data)
+    log.debug("data %s", data)
     archival_describe = data.get("describe-name", DESCRIBE_UNSUPPORTED)
     if DESCRIBE_UNSUPPORTED in archival_describe:
         warnings.warn("git archive did not support describe output")
@@ -294,7 +302,7 @@ def archival_to_version(
         return meta(
             tag,
             config=config,
-            distance=None if number == 0 else number,
+            distance=number,
             node=node,
         )
 
@@ -307,7 +315,7 @@ def archival_to_version(
         if node is None:
             return None
         elif "$FORMAT" in node.upper():
-            warnings.warn("unexported git archival found")
+            warnings.warn("unprocessed git archival found (no export subst applied)")
             return None
         else:
             return meta("0.0", node=node, config=config)
diff --git a/src/setuptools_scm/hg.py b/src/setuptools_scm/hg.py
index e7295bc..522dfb6 100644
--- a/src/setuptools_scm/hg.py
+++ b/src/setuptools_scm/hg.py
@@ -1,16 +1,15 @@
 from __future__ import annotations
 
 import datetime
+import logging
 import os
 from pathlib import Path
 from typing import TYPE_CHECKING
 
 from . import Configuration
-from ._trace import trace
 from ._version_cls import Version
+from .integration import data_from_mime
 from .scm_workdir import Workdir
-from .utils import data_from_mime
-from .utils import require_command
 from .version import meta
 from .version import ScmVersion
 from .version import tag_to_version
@@ -18,19 +17,18 @@ from .version import tag_to_version
 if TYPE_CHECKING:
     from . import _types as _t
 
-from ._run_cmd import run as _run
+from ._run_cmd import run as _run, require_command as _require_command
 
+log = logging.getLogger(__name__)
 
-class HgWorkdir(Workdir):
-    COMMAND = "hg"
 
+class HgWorkdir(Workdir):
     @classmethod
     def from_potential_worktree(cls, wd: _t.PathT) -> HgWorkdir | None:
-        require_command(cls.COMMAND)
-        res = _run("hg root", wd)
+        res = _run(["hg", "root"], wd)
         if res.returncode:
             return None
-        return cls(res.stdout)
+        return cls(Path(res.stdout))
 
     def get_meta(self, config: Configuration) -> ScmVersion | None:
         node: str
@@ -45,16 +43,22 @@ class HgWorkdir(Workdir):
         # mainly used to emulate Git branches, which is already supported with
         # the dedicated class GitWorkdirHgClient)
 
-        branch, dirty_str, dirty_date = self.do(
-            ["hg", "id", "-T", "{branch}\n{if(dirty, 1, 0)}\n{date|shortdate}"]
-        ).split("\n")
+        branch, dirty_str, dirty_date = _run(
+            ["hg", "id", "-T", "{branch}\n{if(dirty, 1, 0)}\n{date|shortdate}"],
+            cwd=self.path,
+            check=True,
+        ).stdout.split("\n")
         dirty = bool(int(dirty_str))
         node_date = datetime.date.fromisoformat(dirty_date if dirty else node_date_str)
 
-        if node.count("0") == len(node):
-            trace("initial node", self.path)
+        if node == "0" * len(node):
+            log.debug("initial node %s", self.path)
             return meta(
-                "0.0", config=config, dirty=dirty, branch=branch, node_date=node_date
+                Version("0.0"),
+                config=config,
+                dirty=dirty,
+                branch=branch,
+                node_date=node_date,
             )
 
         node = "h" + node[:7]
@@ -97,7 +101,7 @@ class HgWorkdir(Workdir):
                 return meta(tag, config=config, node_date=node_date)
 
         except ValueError as e:
-            trace("error", e)
+            log.exception("error %s", e)
             pass  # unpacking failed, old hg
 
         return None
@@ -139,6 +143,7 @@ class HgWorkdir(Workdir):
 
 
 def parse(root: _t.PathT, config: Configuration) -> ScmVersion | None:
+    _require_command("hg")
     if os.path.exists(os.path.join(root, ".hg/git")):
         res = _run(["hg", "path"], root)
         if not res.returncode:
@@ -162,7 +167,7 @@ def parse(root: _t.PathT, config: Configuration) -> ScmVersion | None:
 
 
 def archival_to_version(data: dict[str, str], config: Configuration) -> ScmVersion:
-    trace("data", data)
+    log.debug("data %s", data)
     node = data.get("node", "")[:12]
     if node:
         node = "h" + node
@@ -176,7 +181,7 @@ def archival_to_version(data: dict[str, str], config: Configuration) -> ScmVersi
             config=config,
         )
     else:
-        return meta("0.0", node=node, config=config)
+        return meta(config.version_cls("0.0"), node=node, config=config)
 
 
 def parse_archival(root: _t.PathT, config: Configuration) -> ScmVersion:
diff --git a/src/setuptools_scm/hg_git.py b/src/setuptools_scm/hg_git.py
index f7ea883..b6c3036 100644
--- a/src/setuptools_scm/hg_git.py
+++ b/src/setuptools_scm/hg_git.py
@@ -1,19 +1,25 @@
 from __future__ import annotations
 
+import logging
 import os
 from contextlib import suppress
 from datetime import date
-from datetime import datetime
+from pathlib import Path
 
 from . import _types as _t
-from ._trace import trace
+from ._run_cmd import CompletedProcess as _CompletedProcess
+from ._run_cmd import require_command
+from ._run_cmd import run as _run
 from .git import GitWorkdir
 from .hg import HgWorkdir
-from .utils import _CmdResult
-from .utils import do_ex
-from .utils import require_command
 
-_FAKE_GIT_DESCRIBE_ERROR = _CmdResult("<>hg git failed", "", 1)
+log = logging.getLogger(__name__)
+
+_FAKE_GIT_DESCRIBE_ERROR = _CompletedProcess(
+    "fake git describe output for hg",
+    1,
+    "<>hg git failed to describe",
+)
 
 
 class GitWorkdirHgClient(GitWorkdir, HgWorkdir):
@@ -21,29 +27,27 @@ class GitWorkdirHgClient(GitWorkdir, HgWorkdir):
 
     @classmethod
     def from_potential_worktree(cls, wd: _t.PathT) -> GitWorkdirHgClient | None:
-        require_command(cls.COMMAND)
-        root, _, ret = do_ex(["hg", "root"], wd)
-        if ret:
+        require_command("hg")
+        res = _run(["hg", "root"], cwd=wd).parse_success(parse=Path)
+        if res is None:
             return None
-        return cls(root)
+        return cls(res)
 
     def is_dirty(self) -> bool:
-        out, _, _ = self.do_ex('hg id -T "{dirty}"')
-        return bool(out)
+        res = _run(["hg", "id", "-T", "{dirty}"], cwd=self.path, check=True)
+        return bool(res.stdout)
 
     def get_branch(self) -> str | None:
-        res = self.do_ex('hg id -T "{bookmarks}"')
+        res = _run(["hg", "id", "-T", "{bookmarks}"], cwd=self.path)
         if res.returncode:
-            trace("branch err", res)
+            log.info("branch err %s", res)
             return None
-        return res.out
+        return res.stdout
 
     def get_head_date(self) -> date | None:
-        date_part, err, ret = self.do_ex('hg log -r . -T "{shortdate(date)}"')
-        if ret:
-            trace("head date err", date_part, err, ret)
-            return None
-        return datetime.strptime(date_part, r"%Y-%m-%d").date()
+        return _run('hg log -r . -T "{shortdate(date)}"', cwd=self.path).parse_success(
+            parse=date.fromisoformat, error_msg="head date err"
+        )
 
     def is_shallow(self) -> bool:
         return False
@@ -52,11 +56,11 @@ class GitWorkdirHgClient(GitWorkdir, HgWorkdir):
         pass
 
     def get_hg_node(self) -> str | None:
-        node, _, ret = self.do_ex('hg log -r . -T "{node}"')
-        if not ret:
-            return node
-        else:
+        res = _run('hg log -r . -T "{node}"', cwd=self.path)
+        if res.returncode:
             return None
+        else:
+            return res.stdout
 
     def _hg2git(self, hg_node: str) -> str | None:
         with suppress(FileNotFoundError):
@@ -76,11 +80,11 @@ class GitWorkdirHgClient(GitWorkdir, HgWorkdir):
 
         if git_node is None:
             # trying again after hg -> git
-            self.do_ex("hg gexport")
+            _run(["hg", "gexport"], cwd=self.path)
             git_node = self._hg2git(hg_node)
 
             if git_node is None:
-                trace("Cannot get git node so we use hg node", hg_node)
+                log.debug("Cannot get git node so we use hg node %s", hg_node)
 
                 if hg_node == "0" * len(hg_node):
                     # mimic Git behavior
@@ -91,17 +95,17 @@ class GitWorkdirHgClient(GitWorkdir, HgWorkdir):
         return git_node[:7]
 
     def count_all_nodes(self) -> int:
-        revs, _, _ = self.do_ex(["hg", "log", "-r", "ancestors(.)", "-T", "."])
-        return len(revs)
+        res = _run(["hg", "log", "-r", "ancestors(.)", "-T", "."], cwd=self.path)
+        return len(res.stdout)
 
-    def default_describe(self) -> _CmdResult:
+    def default_describe(self) -> _CompletedProcess:
         """
         Tentative to reproduce the output of
 
         `git describe --dirty --tags --long --match *[0-9]*`
 
         """
-        hg_tags_str, _, ret = self.do_ex(
+        res = _run(
             [
                 "hg",
                 "log",
@@ -109,16 +113,17 @@ class GitWorkdirHgClient(GitWorkdir, HgWorkdir):
                 "(reverse(ancestors(.)) and tag(r're:v?[0-9].*'))",
                 "-T",
                 "{tags}{if(tags, ' ', '')}",
-            ]
+            ],
+            cwd=self.path,
         )
-        if ret:
+        if res.returncode:
             return _FAKE_GIT_DESCRIBE_ERROR
-        hg_tags: list[str] = hg_tags_str.split()
+        hg_tags: list[str] = res.stdout.split()
 
         if not hg_tags:
             return _FAKE_GIT_DESCRIBE_ERROR
 
-        with open(os.path.join(self.path, ".hg/git-tags")) as fp:
+        with self.path.joinpath(".hg/git-tags").open() as fp:
             git_tags: dict[str, str] = dict(line.split()[::-1] for line in fp)
 
         tag: str
@@ -127,13 +132,13 @@ class GitWorkdirHgClient(GitWorkdir, HgWorkdir):
                 tag = hg_tag
                 break
         else:
-            trace("tag not found", hg_tags, git_tags)
+            logging.warning("tag not found hg=%s git=%s", hg_tags, git_tags)
             return _FAKE_GIT_DESCRIBE_ERROR
 
-        out, _, ret = self.do_ex(["hg", "log", "-r", f"'{tag}'::.", "-T", "."])
-        if ret:
+        res = _run(["hg", "log", "-r", f"'{tag}'::.", "-T", "."], cwd=self.path)
+        if res.returncode:
             return _FAKE_GIT_DESCRIBE_ERROR
-        distance = len(out) - 1
+        distance = len(res.stdout) - 1
 
         node = self.node()
         assert node is not None
@@ -141,5 +146,10 @@ class GitWorkdirHgClient(GitWorkdir, HgWorkdir):
 
         if self.is_dirty():
             desc += "-dirty"
-        trace("desc", desc)
-        return _CmdResult(desc, "", 0)
+        log.debug("faked describe %r", desc)
+        return _CompletedProcess(
+            ["setuptools-scm", "faked", "describe"],
+            returncode=0,
+            stdout=desc,
+            stderr="",
+        )
diff --git a/src/setuptools_scm/integration.py b/src/setuptools_scm/integration.py
index 1d600e2..9df4f3e 100644
--- a/src/setuptools_scm/integration.py
+++ b/src/setuptools_scm/integration.py
@@ -1,7 +1,10 @@
 from __future__ import annotations
 
+import logging
 import os
+import textwrap
 import warnings
+from pathlib import Path
 from typing import Any
 from typing import Callable
 from typing import TYPE_CHECKING
@@ -9,14 +12,15 @@ from typing import TYPE_CHECKING
 import setuptools
 
 from . import _get_version
+from . import _types as _t
 from . import _version_missing
 from . import Configuration
 from ._integration.setuptools import (
     read_dist_name_from_setup_cfg as _read_dist_name_from_setup_cfg,
 )
-from ._trace import trace
 from ._version_cls import _validate_version_cls
 
+log = logging.getLogger(__name__)
 if TYPE_CHECKING:
     pass
 
@@ -82,11 +86,11 @@ def version_keyword(
     if dist.metadata.version is not None:
         warnings.warn(f"version of {dist_name} already set")
         return
-    trace(
-        "version keyword",
+    log.debug(
+        "version keyword %r",
         vars(dist.metadata),
     )
-    trace("dist", id(dist), id(dist.metadata))
+    log.debug("dist %s %s", id(dist), id(dist.metadata))
 
     if dist_name is None:
         dist_name = _read_dist_name_from_setup_cfg()
@@ -98,11 +102,11 @@ def version_keyword(
 
 
 def infer_version(dist: setuptools.Distribution) -> None:
-    trace(
-        "finalize hook",
+    log.debug(
+        "finalize hook %r",
         vars(dist.metadata),
     )
-    trace("dist", id(dist), id(dist.metadata))
+    log.debug("dist %s %s", id(dist), id(dist.metadata))
     if dist.metadata.version is not None:
         return  # metadata already added by hook
     dist_name = dist.metadata.name
@@ -115,6 +119,16 @@ def infer_version(dist: setuptools.Distribution) -> None:
     try:
         config = Configuration.from_file(dist_name=dist_name)
     except LookupError as e:
-        trace(e)
+        log.exception(e)
     else:
         _assign_version(dist, config)
+
+
+def data_from_mime(path: _t.PathT) -> dict[str, str]:
+    content = Path(path).read_text(encoding="utf-8")
+    log.debug("mime %s content:\n%s", path, textwrap.indent(content, "    "))
+    # the complex conditions come from reading pseudo-mime-messages
+    data = dict(x.split(": ", 1) for x in content.splitlines() if ": " in x)
+
+    log.debug("mime %s data:\n%s", path, data)
+    return data
diff --git a/src/setuptools_scm/scm_workdir.py b/src/setuptools_scm/scm_workdir.py
index 113f68a..9879549 100644
--- a/src/setuptools_scm/scm_workdir.py
+++ b/src/setuptools_scm/scm_workdir.py
@@ -1,26 +1,15 @@
 from __future__ import annotations
 
-from typing import ClassVar
-from typing import TYPE_CHECKING
+from dataclasses import dataclass
+from pathlib import Path
 
-from .utils import _CmdResult
-from .utils import do
-from .utils import do_ex
-from .utils import require_command
-
-if TYPE_CHECKING:
-    from . import _types as _t
+from ._config import Configuration
+from .version import ScmVersion
 
 
+@dataclass()
 class Workdir:
-    COMMAND: ClassVar[str]
-
-    def __init__(self, path: _t.PathT):
-        require_command(self.COMMAND)
-        self.path = path
-
-    def do_ex(self, cmd: _t.CMD_TYPE) -> _CmdResult:
-        return do_ex(cmd, cwd=self.path)
+    path: Path
 
-    def do(self, cmd: _t.CMD_TYPE) -> str:
-        return do(cmd, cwd=self.path)
+    def run_describe(self, config: Configuration) -> ScmVersion:
+        raise NotImplementedError(self.run_describe)
diff --git a/src/setuptools_scm/utils.py b/src/setuptools_scm/utils.py
deleted file mode 100644
index 6788f23..0000000
--- a/src/setuptools_scm/utils.py
+++ /dev/null
@@ -1,79 +0,0 @@
-"""
-utils
-"""
-from __future__ import annotations
-
-import logging
-import subprocess
-import sys
-import warnings
-from types import CodeType
-from types import FunctionType
-from typing import NamedTuple
-from typing import TYPE_CHECKING
-
-from . import _run_cmd
-from . import _trace
-
-if TYPE_CHECKING:
-    from . import _types as _t
-
-log = logging.getLogger(__name__)
-
-
-class _CmdResult(NamedTuple):
-    out: str
-    err: str
-    returncode: int
-
-
-def do_ex(cmd: _t.CMD_TYPE, cwd: _t.PathT = ".") -> _CmdResult:
-    res = _run_cmd.run(cmd, cwd)
-    return _CmdResult(res.stdout, res.stderr, res.returncode)
-
-
-def do(cmd: _t.CMD_TYPE, cwd: _t.PathT = ".") -> str:
-    out, err, ret = do_ex(cmd, cwd)
-    if ret and not _trace.DEBUG:
-        print(err)
-    return out
-
-
-def data_from_mime(path: _t.PathT) -> dict[str, str]:
-    with open(path, encoding="utf-8") as fp:
-        content = fp.read()
-    _trace.trace("content", repr(content))
-    # the complex conditions come from reading pseudo-mime-messages
-    data = dict(x.split(": ", 1) for x in content.splitlines() if ": " in x)
-    _trace.trace("data", data)
-    return data
-
-
-def function_has_arg(fn: object | FunctionType, argname: str) -> bool:
-    assert isinstance(fn, FunctionType)
-    code: CodeType = fn.__code__
-    return argname in code.co_varnames
-
-
-def has_command(name: str, args: list[str] | None = None, warn: bool = True) -> bool:
-    try:
-        cmd = [name, "help"] if args is None else [name, *args]
-        p = _run_cmd.run(cmd, cwd=".", timeout=5)
-    except OSError:
-        _trace.trace(*sys.exc_info())
-        res = False
-    except subprocess.TimeoutExpired as e:
-        log.info(e)
-        _trace.trace(e)
-        res = False
-
-    else:
-        res = not p.returncode
-    if not res and warn:
-        warnings.warn("%r was not found" % name, category=RuntimeWarning)
-    return res
-
-
-def require_command(name: str) -> None:
-    if not has_command(name, warn=False):
-        raise OSError(f"{name!r} was not found")
diff --git a/src/setuptools_scm/version.py b/src/setuptools_scm/version.py
index 36bc7df..128533c 100644
--- a/src/setuptools_scm/version.py
+++ b/src/setuptools_scm/version.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import dataclasses
+import logging
 import os
 import re
 import warnings
@@ -13,22 +14,21 @@ from typing import Match
 from typing import TYPE_CHECKING
 
 from . import _entrypoints
-from ._modify_version import _bump_dev
-from ._modify_version import _bump_regex
-from ._modify_version import _dont_guess_next_version
-from ._modify_version import _format_local_with_time
-from ._modify_version import _strip_local
+from . import _modify_version
 
 if TYPE_CHECKING:
     from typing_extensions import Concatenate
+    from typing_extensions import ParamSpec
 
-    from . import _types as _t
+    _P = ParamSpec("_P")
 
 
 from ._version_cls import Version as PkgVersion, _VersionT
 from . import _version_cls as _v
 from . import _config
-from ._trace import trace
+
+log = logging.getLogger(__name__)
+
 
 SEMVER_MINOR = 2
 SEMVER_PATCH = 3
@@ -55,19 +55,19 @@ def _parse_version_tag(
             "suffix": match.group(0)[match.end(key) :],
         }
 
-    trace(f"tag '{tag}' parsed to {result}")
+    log.debug(f"tag '{tag}' parsed to {result}")
     return result
 
 
 def callable_or_entrypoint(group: str, callable_or_name: str | Any) -> Any:
-    trace("ep", (group, callable_or_name))
+    log.debug("ep %r %r", group, callable_or_name)
 
     if callable(callable_or_name):
         return callable_or_name
     from ._entrypoints import iter_entry_points
 
     for ep in iter_entry_points(group, callable_or_name):
-        trace("ep found:", ep.name)
+        log.debug("ep found: %s", ep.name)
         return ep.load()
 
 
@@ -77,7 +77,7 @@ def tag_to_version(
     """
     take a tag that might be prefixed with a keyword and return only the version part
     """
-    trace("tag", tag)
+    log.debug("tag %s", tag)
 
     tagdict = _parse_version_tag(tag, config)
     if not isinstance(tagdict, dict) or not tagdict.get("version", None):
@@ -85,7 +85,7 @@ def tag_to_version(
         return None
 
     version_str = tagdict["version"]
-    trace("version pre parse", version_str)
+    log.debug("version pre parse %s", version_str)
 
     if tagdict.get("suffix", ""):
         warnings.warn(
@@ -95,7 +95,7 @@ def tag_to_version(
         )
 
     version: _VersionT = config.version_cls(version_str)
-    trace("version", repr(version))
+    log.debug("version=%r", version)
 
     return version
 
@@ -112,7 +112,7 @@ def _source_epoch_or_utc_now() -> datetime:
 class ScmVersion:
     tag: _v.Version | _v.NonNormalizedVersion | str
     config: _config.Configuration
-    distance: int | None = None
+    distance: int = 0
     node: str | None = None
     dirty: bool = False
     preformatted: bool = False
@@ -122,13 +122,9 @@ class ScmVersion:
         init=False, default_factory=_source_epoch_or_utc_now
     )
 
-    def __post_init__(self) -> None:
-        if self.dirty and self.distance is None:
-            self.distance = 0
-
     @property
     def exact(self) -> bool:
-        return self.distance is None
+        return self.distance == 0 and not self.dirty
 
     def __repr__(self) -> str:
         return self.format_with(
@@ -153,10 +149,10 @@ class ScmVersion:
 
     def format_next_version(
         self,
-        guess_next: Callable[Concatenate[ScmVersion, _t.P], str],
+        guess_next: Callable[Concatenate[ScmVersion, _P], str],
         fmt: str = "{guessed}.dev{distance}",
-        *k: _t.P.args,
-        **kw: _t.P.kwargs,
+        *k: _P.args,
+        **kw: _P.kwargs,
     ) -> str:
         guessed = guess_next(self, *k, **kw)
         return self.format_with(fmt, guessed=guessed)
@@ -178,7 +174,7 @@ def _parse_tag(
 def meta(
     tag: str | _VersionT,
     *,
-    distance: int | None = None,
+    distance: int = 0,
     dirty: bool = False,
     node: str | None = None,
     preformatted: bool = False,
@@ -187,7 +183,7 @@ def meta(
     node_date: date | None = None,
 ) -> ScmVersion:
     parsed_version = _parse_tag(tag, preformatted, config)
-    trace("version", tag, "->", parsed_version)
+    log.info("version %s -> %s", tag, parsed_version)
     assert parsed_version is not None, "Can't parse version %s" % tag
     return ScmVersion(
         parsed_version,
@@ -202,8 +198,8 @@ def meta(
 
 
 def guess_next_version(tag_version: ScmVersion) -> str:
-    version = _strip_local(str(tag_version.tag))
-    return _bump_dev(version) or _bump_regex(version)
+    version = _modify_version._strip_local(str(tag_version.tag))
+    return _modify_version._bump_dev(version) or _modify_version._bump_regex(version)
 
 
 def guess_next_dev_version(version: ScmVersion) -> str:
@@ -282,7 +278,7 @@ def no_guess_dev_version(version: ScmVersion) -> str:
     if version.exact:
         return version.format_with("{tag}")
     else:
-        return version.format_next_version(_dont_guess_next_version)
+        return version.format_next_version(_modify_version._dont_guess_next_version)
 
 
 _DATE_REGEX = re.compile(
@@ -366,11 +362,11 @@ def calver_by_date(version: ScmVersion) -> str:
 
 
 def get_local_node_and_date(version: ScmVersion) -> str:
-    return _format_local_with_time(version, time_format="%Y%m%d")
+    return _modify_version._format_local_with_time(version, time_format="%Y%m%d")
 
 
 def get_local_node_and_timestamp(version: ScmVersion, fmt: str = "%Y%m%d%H%M%S") -> str:
-    return _format_local_with_time(version, time_format=fmt)
+    return _modify_version._format_local_with_time(version, time_format=fmt)
 
 
 def get_local_dirty_tag(version: ScmVersion) -> str:
@@ -389,18 +385,18 @@ def postrelease_version(version: ScmVersion) -> str:
 
 
 def format_version(version: ScmVersion, **config: Any) -> str:
-    trace("scm version", version)
-    trace("config", config)
+    log.debug("scm version %s", version)
+    log.debug("config %s", config)
     if version.preformatted:
         assert isinstance(version.tag, str)
         return version.tag
     main_version = _entrypoints._call_version_scheme(
         version, "setuptools_scm.version_scheme", config["version_scheme"], None
     )
-    trace("version", main_version)
+    log.debug("version %s", main_version)
     assert main_version is not None
     local_version = _entrypoints._call_version_scheme(
         version, "setuptools_scm.local_scheme", config["local_scheme"], "+unknown"
     )
-    trace("local_version", local_version)
+    log.debug("local_version %s", local_version)
     return main_version + local_version
diff --git a/tox.ini b/tox.ini
index b183b09..b7c0123 100644
--- a/tox.ini
+++ b/tox.ini
@@ -7,6 +7,8 @@ filterwarnings=
     error
     ignore:.*tool\.setuptools_scm.*
     ignore:.*git archive did not support describe output.*:UserWarning
+log_level = debug
+log_cli_level = info
 markers=
     issue(id): reference to github issue
     skip_commit: allows to skip committing in the helpers
@@ -37,8 +39,8 @@ deps=
     check-manifest
     docutils
     pygments
-    setuptools>45
     typing_extensions
+    hatchling
 commands=
     rst2html.py README.rst {envlogdir}/README.html --strict []
     check-manifest --no-build-isolation


Test patch:
diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml
index a6fc97f..f0029c9 100644
--- a/.github/workflows/python-tests.yml
+++ b/.github/workflows/python-tests.yml
@@ -90,21 +90,10 @@ jobs:
       - run: |
           $(hg debuginstall --template "{pythonexe}") -m pip install hg-git --user
         if: matrix.os == 'ubuntu-latest'
+      # this hopefull helps with os caches, hg init sometimes gets 20s timeouts
+      - run: hg version
       - run: pytest
 
-  test_legacy_setuptools:
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v3
-      - name: Setup python
-        uses: actions/setup-python@v4
-        with:
-          python-version: "3.8"
-          architecture: x64
-      - run: pip install -e .[toml,test] pytest virtualenv
-      - run: pytest --test-legacy testing/test_setuptools_support.py || true # ignore fail flaky on ci
-
-
   dist_upload:
 
     runs-on: ubuntu-latest
diff --git a/testing/conftest.py b/testing/conftest.py
index e1c8160..ef5883c 100644
--- a/testing/conftest.py
+++ b/testing/conftest.py
@@ -1,12 +1,14 @@
 from __future__ import annotations
 
+import contextlib
 import os
 from pathlib import Path
+from types import TracebackType
 from typing import Any
+from typing import Iterator
 
 import pytest
 
-import setuptools_scm.utils
 from .wd_wrapper import WorkDir
 from setuptools_scm._run_cmd import run
 
@@ -40,25 +42,35 @@ def pytest_addoption(parser: Any) -> None:
     )
 
 
-class DebugMode:
-    def __init__(self, monkeypatch: pytest.MonkeyPatch):
-        self.__monkeypatch = monkeypatch
-        self.__module = setuptools_scm._trace
+class DebugMode(contextlib.AbstractContextManager):  # type: ignore[type-arg]
+    from setuptools_scm import _log as __module
 
-    __monkeypatch: pytest.MonkeyPatch
+    def __init__(self) -> None:
+        self.__stack = contextlib.ExitStack()
+
+    def __enter__(self) -> DebugMode:
+        self.enable()
+        return self
+
+    def __exit__(
+        self,
+        exc_type: type[BaseException] | None,
+        exc_val: BaseException | None,
+        exc_tb: TracebackType | None,
+    ) -> None:
+        self.disable()
 
     def enable(self) -> None:
-        self.__monkeypatch.setattr(self.__module, "DEBUG", True)
+        self.__stack.enter_context(self.__module.defer_to_pytest())
 
     def disable(self) -> None:
-        self.__monkeypatch.setattr(self.__module, "DEBUG", False)
+        self.__stack.close()
 
 
 @pytest.fixture(autouse=True)
-def debug_mode(monkeypatch: pytest.MonkeyPatch) -> DebugMode:
-    debug_mode = DebugMode(monkeypatch)
-    debug_mode.enable()
-    return debug_mode
+def debug_mode() -> Iterator[DebugMode]:
+    with DebugMode() as debug_mode:
+        yield debug_mode
 
 
 @pytest.fixture
diff --git a/testing/test_basic_api.py b/testing/test_basic_api.py
index bf21a47..f1b4c4f 100644
--- a/testing/test_basic_api.py
+++ b/testing/test_basic_api.py
@@ -1,7 +1,6 @@
 from __future__ import annotations
 
 import os
-import shutil
 import sys
 from pathlib import Path
 
@@ -11,17 +10,13 @@ import setuptools_scm
 from setuptools_scm import Configuration
 from setuptools_scm import dump_version
 from setuptools_scm._run_cmd import run
-from setuptools_scm.utils import data_from_mime
-from setuptools_scm.utils import do
+from setuptools_scm.integration import data_from_mime
 from setuptools_scm.version import ScmVersion
 from testing.wd_wrapper import WorkDir
 
 
-@pytest.mark.parametrize("cmd", ["ls", "dir"])
-def test_do(cmd: str, tmp_path: Path) -> None:
-    if not shutil.which(cmd):
-        pytest.skip(f"{cmd} not found")
-    do(cmd, cwd=tmp_path)
+def test_run_plain(tmp_path: Path) -> None:
+    run([sys.executable, "-c", "print(1)"], cwd=tmp_path)
 
 
 def test_data_from_mime(tmp_path: Path) -> None:
@@ -35,7 +30,7 @@ def test_data_from_mime(tmp_path: Path) -> None:
 def test_version_from_pkginfo(wd: WorkDir) -> None:
     wd.write("PKG-INFO", "Version: 0.1")
 
-    assert wd.version == "0.1"
+    assert wd.get_version() == "0.1"
 
     # replicate issue 167
     assert wd.get_version(version_scheme="1.{0.distance}.0".format) == "0.1"
diff --git a/testing/test_functions.py b/testing/test_functions.py
index ceb6cd3..6b0bd39 100644
--- a/testing/test_functions.py
+++ b/testing/test_functions.py
@@ -8,7 +8,7 @@ from setuptools_scm import Configuration
 from setuptools_scm import dump_version
 from setuptools_scm import get_version
 from setuptools_scm import PRETEND_KEY
-from setuptools_scm.utils import has_command
+from setuptools_scm._run_cmd import has_command
 from setuptools_scm.version import format_version
 from setuptools_scm.version import guess_next_version
 from setuptools_scm.version import meta
@@ -38,36 +38,37 @@ def test_next_tag(tag: str, expected: str) -> None:
 
 
 VERSIONS = {
-    "exact": meta("1.1", distance=None, dirty=False, config=c),
-    "zerodistance": meta("1.1", distance=0, dirty=False, config=c),
-    "dirty": meta("1.1", distance=None, dirty=True, config=c),
-    "distance": meta("1.1", distance=3, dirty=False, config=c),
-    "distancedirty": meta("1.1", distance=3, dirty=True, config=c),
+    "exact": meta("1.1", distance=0, dirty=False, config=c),
+    "dirty": meta("1.1", distance=0, dirty=True, config=c),
+    "distance-clean": meta("1.1", distance=3, dirty=False, config=c),
+    "distance-dirty": meta("1.1", distance=3, dirty=True, config=c),
 }
 
 
 @pytest.mark.parametrize(
-    "version,scheme,expected",
+    "version,version_scheme, local_scheme,expected",
     [
-        ("exact", "guess-next-dev node-and-date", "1.1"),
-        ("zerodistance", "guess-next-dev node-and-date", "1.2.dev0"),
-        ("zerodistance", "guess-next-dev no-local-version", "1.2.dev0"),
-        ("dirty", "guess-next-dev node-and-date", "1.2.dev0+d20090213"),
-        ("dirty", "guess-next-dev no-local-version", "1.2.dev0"),
-        ("distance", "guess-next-dev node-and-date", "1.2.dev3"),
-        ("distancedirty", "guess-next-dev node-and-date", "1.2.dev3+d20090213"),
-        ("distancedirty", "guess-next-dev no-local-version", "1.2.dev3"),
-        ("exact", "post-release node-and-date", "1.1"),
-        ("zerodistance", "post-release node-and-date", "1.1.post0"),
-        ("dirty", "post-release node-and-date", "1.1.post0+d20090213"),
-        ("distance", "post-release node-and-date", "1.1.post3"),
-        ("distancedirty", "post-release node-and-date", "1.1.post3+d20090213"),
+        ("exact", "guess-next-dev", "node-and-date", "1.1"),
+        ("dirty", "guess-next-dev", "node-and-date", "1.2.dev0+d20090213"),
+        ("dirty", "guess-next-dev", "no-local-version", "1.2.dev0"),
+        ("distance-clean", "guess-next-dev", "node-and-date", "1.2.dev3"),
+        ("distance-dirty", "guess-next-dev", "node-and-date", "1.2.dev3+d20090213"),
+        ("exact", "post-release", "node-and-date", "1.1"),
+        ("dirty", "post-release", "node-and-date", "1.1.post0+d20090213"),
+        ("distance-clean", "post-release", "node-and-date", "1.1.post3"),
+        ("distance-dirty", "post-release", "node-and-date", "1.1.post3+d20090213"),
     ],
 )
-def test_format_version(version: str, scheme: str, expected: str) -> None:
+def test_format_version(
+    version: str, version_scheme: str, local_scheme: str, expected: str
+) -> None:
     scm_version = VERSIONS[version]
-    vs, ls = scheme.split()
-    assert format_version(scm_version, version_scheme=vs, local_scheme=ls) == expected
+    assert (
+        format_version(
+            scm_version, version_scheme=version_scheme, local_scheme=local_scheme
+        )
+        == expected
+    )
 
 
 def test_dump_version_doesnt_bail_on_value_error(tmp_path: Path) -> None:
diff --git a/testing/test_git.py b/testing/test_git.py
index 3e1ee67..9e2e9b5 100644
--- a/testing/test_git.py
+++ b/testing/test_git.py
@@ -22,9 +22,10 @@ from setuptools_scm import Configuration
 from setuptools_scm import git
 from setuptools_scm import NonNormalizedVersion
 from setuptools_scm._file_finders.git import git_find_files
+from setuptools_scm._run_cmd import CompletedProcess
+from setuptools_scm._run_cmd import has_command
 from setuptools_scm._run_cmd import run
 from setuptools_scm.git import archival_to_version
-from setuptools_scm.utils import has_command
 from setuptools_scm.version import format_version
 
 pytestmark = pytest.mark.skipif(
@@ -70,7 +71,7 @@ setup(use_scm_version={"root": "../..",
 """
     )
     res = run([sys.executable, "setup.py", "--version"], p)
-    assert res.stdout == "0.1.dev0"
+    assert res.stdout == "0.1.dev0+d20090213"
 
 
 def test_root_search_parent_directories(
@@ -85,7 +86,7 @@ setup(use_scm_version={"search_parent_directories": True})
 """
     )
     res = run([sys.executable, "setup.py", "--version"], p)
-    assert res.stdout == "0.1.dev0"
+    assert res.stdout == "0.1.dev0+d20090213"
 
 
 def test_git_gone(wd: WorkDir, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -146,37 +147,37 @@ def test_not_owner(wd: WorkDir) -> None:
 
 
 def test_version_from_git(wd: WorkDir) -> None:
-    assert wd.version == "0.1.dev0"
+    assert wd.get_version() == "0.1.dev0+d20090213"
 
     parsed = git.parse(str(wd.cwd), Configuration(), git.DEFAULT_DESCRIBE)
     assert parsed is not None and parsed.branch in ("master", "main")
 
     wd.commit_testfile()
-    assert wd.version.startswith("0.1.dev1+g")
-    assert not wd.version.endswith("1-")
+    assert wd.get_version().startswith("0.1.dev1+g")
+    assert not wd.get_version().endswith("1-")
 
     wd("git tag v0.1")
-    assert wd.version == "0.1"
+    assert wd.get_version() == "0.1"
 
     wd.write("test.txt", "test2")
-    assert wd.version.startswith("0.2.dev0+g")
+    assert wd.get_version().startswith("0.2.dev0+g")
 
     wd.commit_testfile()
-    assert wd.version.startswith("0.2.dev1+g")
+    assert wd.get_version().startswith("0.2.dev1+g")
 
     wd("git tag version-0.2")
-    assert wd.version.startswith("0.2")
+    assert wd.get_version().startswith("0.2")
 
     wd.commit_testfile()
     wd("git tag version-0.2.post210+gbe48adfpost3+g0cc25f2")
     with pytest.warns(
         UserWarning, match="tag '.*' will be stripped of its suffix '.*'"
     ):
-        assert wd.version.startswith("0.2")
+        assert wd.get_version().startswith("0.2")
 
     wd.commit_testfile()
     wd("git tag 17.33.0-rc")
-    assert wd.version == "17.33.0rc0"
+    assert wd.get_version() == "17.33.0rc0"
 
     # custom normalization
     assert wd.get_version(normalize=False) == "17.33.0-rc"
@@ -253,9 +254,10 @@ def test_unicode_version_scheme(wd: WorkDir) -> None:
 def test_git_worktree(wd: WorkDir) -> None:
     wd.write("test.txt", "test2")
     # untracked files dont change the state
-    assert wd.version == "0.1.dev0"
+    assert wd.get_version() == "0.1.dev0+d20090213"
+
     wd("git add test.txt")
-    assert wd.version.startswith("0.1.dev0+d")
+    assert wd.get_version().startswith("0.1.dev0+d")
 
 
 @pytest.mark.issue(86)
@@ -268,14 +270,14 @@ def test_git_dirty_notag(
     wd.commit_testfile()
     wd.write("test.txt", "test2")
     wd("git add test.txt")
-    assert wd.version.startswith("0.1.dev1")
+    version = wd.get_version()
+
     if today:
         # the date on the tag is in UTC
         tag = datetime.now(timezone.utc).date().strftime(".d%Y%m%d")
     else:
         tag = ".d20090213"
-    # we are dirty, check for the tag
-    assert tag in wd.version
+    assert version.startswith("0.1.dev1+g") and version.endswith(tag)
 
 
 @pytest.mark.issue(193)
@@ -303,7 +305,8 @@ def shallow_wd(wd: WorkDir, tmp_path: Path) -> Path:
 def test_git_parse_shallow_warns(
     shallow_wd: Path, recwarn: pytest.WarningsRecorder
 ) -> None:
-    git.parse(str(shallow_wd), Configuration())
+    git.parse(shallow_wd, Configuration())
+    print(list(recwarn))
     msg = recwarn.pop()
     assert "is shallow and may cause errors" in str(msg.message)
 
@@ -339,7 +342,7 @@ def test_parse_no_worktree(tmp_path: Path) -> None:
 def test_alphanumeric_tags_match(wd: WorkDir) -> None:
     wd.commit_testfile()
     wd("git tag newstyle-development-started")
-    assert wd.version.startswith("0.1.dev1+g")
+    assert wd.get_version().startswith("0.1.dev1+g")
 
 
 def test_git_archive_export_ignore(
@@ -387,7 +390,7 @@ def test_git_archive_run_from_subdirectory(
 def test_git_branch_names_correct(wd: WorkDir) -> None:
     wd.commit_testfile()
     wd("git checkout -b test/fun")
-    wd_git = git.GitWorkdir(os.fspath(wd.cwd))
+    wd_git = git.GitWorkdir(wd.cwd)
     assert wd_git.get_branch() == "test/fun"
 
 
@@ -443,10 +446,10 @@ def test_non_dotted_tag_no_version_match(wd: WorkDir) -> None:
 def test_gitdir(monkeypatch: pytest.MonkeyPatch, wd: WorkDir) -> None:
     """ """
     wd.commit_testfile()
-    normal = wd.version
+    normal = wd.get_version()
     # git hooks set this and break subsequent setuptools_scm unless we clean
     monkeypatch.setenv("GIT_DIR", __file__)
-    assert wd.version == normal
+    assert wd.get_version() == normal
 
 
 def test_git_getdate(wd: WorkDir) -> None:
@@ -459,7 +462,7 @@ def test_git_getdate(wd: WorkDir) -> None:
         assert parsed.node_date is not None
         return parsed.node_date
 
-    git_wd = git.GitWorkdir(os.fspath(wd.cwd))
+    git_wd = git.GitWorkdir(wd.cwd)
     assert git_wd.get_head_date() is None
     assert parse_date() == today
 
@@ -468,10 +471,17 @@ def test_git_getdate(wd: WorkDir) -> None:
     assert parse_date() == today
 
 
-def test_git_getdate_badgit(wd: WorkDir) -> None:
+def test_git_getdate_badgit(
+    wd: WorkDir, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
+) -> None:
     wd.commit_testfile()
-    git_wd = git.GitWorkdir(os.fspath(wd.cwd))
-    with patch.object(git_wd, "do_ex", Mock(return_value=("%cI", "", 0))):
+    git_wd = git.GitWorkdir(wd.cwd)
+    fake_date_result = CompletedProcess(args=[], stdout="%cI", stderr="", returncode=0)
+    with patch.object(
+        git,
+        "run_git",
+        Mock(return_value=fake_date_result),
+    ):
         assert git_wd.get_head_date() is None
 
 
@@ -504,7 +514,7 @@ Expire-Date: 0
 def test_git_getdate_signed_commit(signed_commit_wd: WorkDir) -> None:
     today = date.today()
     signed_commit_wd.commit_testfile(signed=True)
-    git_wd = git.GitWorkdir(os.fspath(signed_commit_wd.cwd))
+    git_wd = git.GitWorkdir(signed_commit_wd.cwd)
     assert git_wd.get_head_date() == today
 
 
@@ -551,6 +561,8 @@ def test_git_archival_node_missing_no_version() -> None:
 def test_git_archival_from_unfiltered() -> None:
     config = Configuration()
 
-    with pytest.warns(UserWarning, match="unexported git archival found"):
+    with pytest.warns(
+        UserWarning, match=r"unprocessed git archival found \(no export subst applied\)"
+    ):
         version = archival_to_version({"node": "$Format:%H$"}, config=config)
     assert version is None
diff --git a/testing/test_hg_git.py b/testing/test_hg_git.py
index 476016f..9527cb0 100644
--- a/testing/test_hg_git.py
+++ b/testing/test_hg_git.py
@@ -2,8 +2,8 @@ from __future__ import annotations
 
 import pytest
 
-from setuptools_scm.utils import do_ex
-from setuptools_scm.utils import has_command
+from setuptools_scm._run_cmd import has_command
+from setuptools_scm._run_cmd import run
 from testing.wd_wrapper import WorkDir
 
 
@@ -12,13 +12,13 @@ def _check_hg_git() -> None:
     if not has_command("hg", warn=False):
         pytest.skip("hg executable not found")
 
-    python_hg, err, ret = do_ex("hg debuginstall --template {pythonexe}")
+    res = run("hg debuginstall --template {pythonexe}", cwd=".")
 
-    if ret:
+    if res.returncode:
         skip_no_hggit = True
     else:
-        out, err, ret = do_ex([python_hg.strip(), "-c", "import hggit"])
-    skip_no_hggit = bool(ret)
+        res = run([res.stdout, "-c", "import hggit"], cwd=".")
+        skip_no_hggit = bool(res.returncode)
     if skip_no_hggit:
         pytest.skip("hg-git not installed")
 
@@ -26,15 +26,15 @@ def _check_hg_git() -> None:
 def test_base(repositories_hg_git: tuple[WorkDir, WorkDir]) -> None:
     wd, wd_git = repositories_hg_git
 
-    assert wd_git.version == "0.1.dev0"
-    assert wd.version == "0.1.dev0"
+    assert wd_git.get_version() == "0.1.dev0+d20090213"
+    assert wd.get_version() == "0.1.dev0+d20090213"
 
     wd_git.commit_testfile()
-    version_git = wd_git.version
+    version_git = wd_git.get_version()
 
     wd("hg pull -u")
 
-    version = wd.version
+    version = wd.get_version()
 
     assert version_git.startswith("0.1.dev1+g")
     assert version.startswith("0.1.dev1+g")
@@ -44,24 +44,24 @@ def test_base(repositories_hg_git: tuple[WorkDir, WorkDir]) -> None:
 
     wd_git("git tag v0.1")
     wd("hg pull -u")
-    assert wd_git.version == "0.1"
-    assert wd.version == "0.1"
+    assert wd_git.get_version() == "0.1"
+    assert wd.get_version() == "0.1"
 
     wd_git.write("test.txt", "test2")
     wd.write("test.txt", "test2")
-    assert wd_git.version.startswith("0.2.dev0+g")
-    assert wd.version.startswith("0.2.dev0+g")
+    assert wd_git.get_version().startswith("0.2.dev0+g")
+    assert wd.get_version().startswith("0.2.dev0+g")
 
     wd_git.commit_testfile()
     wd("hg pull")
     wd("hg up -C")
-    assert wd_git.version.startswith("0.2.dev1+g")
-    assert wd.version.startswith("0.2.dev1+g")
+    assert wd_git.get_version().startswith("0.2.dev1+g")
+    assert wd.get_version().startswith("0.2.dev1+g")
 
     wd_git("git tag version-0.2")
     wd("hg pull -u")
-    assert wd_git.version.startswith("0.2")
-    assert wd.version.startswith("0.2")
+    assert wd_git.get_version().startswith("0.2")
+    assert wd.get_version().startswith("0.2")
 
     wd_git.commit_testfile()
     wd_git("git tag version-0.2.post210+gbe48adfpost3+g0cc25f2")
@@ -69,15 +69,15 @@ def test_base(repositories_hg_git: tuple[WorkDir, WorkDir]) -> None:
     with pytest.warns(
         UserWarning, match="tag '.*' will be stripped of its suffix '.*'"
     ):
-        assert wd_git.version.startswith("0.2")
+        assert wd_git.get_version().startswith("0.2")
 
     with pytest.warns(
         UserWarning, match="tag '.*' will be stripped of its suffix '.*'"
     ):
-        assert wd.version.startswith("0.2")
+        assert wd.get_version().startswith("0.2")
 
     wd_git.commit_testfile()
     wd_git("git tag 17.33.0-rc")
     wd("hg pull -u")
-    assert wd_git.version == "17.33.0rc0"
-    assert wd.version == "17.33.0rc0"
+    assert wd_git.get_version() == "17.33.0rc0"
+    assert wd.get_version() == "17.33.0rc0"
diff --git a/testing/test_integration.py b/testing/test_integration.py
index cba0f98..c0594ad 100644
--- a/testing/test_integration.py
+++ b/testing/test_integration.py
@@ -91,7 +91,7 @@ def test_pyproject_support_with_git(wd: WorkDir, metadata_in: str) -> None:
     wd.write("setup.py", SETUP_PY_FILES[metadata_in])
     wd.write("setup.cfg", SETUP_CFG_FILES[metadata_in])
     res = wd([sys.executable, "setup.py", "--version"])
-    assert res.endswith("0.1.dev0")
+    assert res.endswith("0.1.dev0+d20090213")
 
 
 def test_pretend_version(monkeypatch: pytest.MonkeyPatch, wd: WorkDir) -> None:
@@ -152,7 +152,7 @@ def test_distribution_provides_extras() -> None:
         from importlib_metadata import distribution
 
     dist = distribution("setuptools_scm")
-    assert sorted(dist.metadata.get_all("Provides-Extra")) == ["test", "toml"]
+    assert sorted(dist.metadata.get_all("Provides-Extra")) == ["rich", "test", "toml"]
 
 
 @pytest.mark.issue(760)
diff --git a/testing/test_mercurial.py b/testing/test_mercurial.py
index 7de7ca2..1b35d11 100644
--- a/testing/test_mercurial.py
+++ b/testing/test_mercurial.py
@@ -7,9 +7,9 @@ import pytest
 
 import setuptools_scm._file_finders
 from setuptools_scm import Configuration
+from setuptools_scm._run_cmd import has_command
 from setuptools_scm.hg import archival_to_version
 from setuptools_scm.hg import parse
-from setuptools_scm.utils import has_command
 from setuptools_scm.version import format_version
 from testing.wd_wrapper import WorkDir
 
@@ -76,31 +76,31 @@ def test_find_files_stop_at_root_hg(
 
 # XXX: better tests for tag prefixes
 def test_version_from_hg_id(wd: WorkDir) -> None:
-    assert wd.version == "0.0"
+    assert wd.get_version() == "0.0"
 
     wd.commit_testfile()
-    assert wd.version.startswith("0.1.dev1+")
+    assert wd.get_version().startswith("0.1.dev1+")
 
     # tagging commit is considered the tag
     wd('hg tag v0.1 -u test -d "0 0"')
-    assert wd.version == "0.1"
+    assert wd.get_version() == "0.1"
 
     wd.commit_testfile()
-    assert wd.version.startswith("0.2.dev2")
+    assert wd.get_version().startswith("0.2.dev2")
 
     wd("hg up v0.1")
-    assert wd.version == "0.1"
+    assert wd.get_version() == "0.1"
 
     # commit originating from the tagged revision
-    # that is not a actual tag
+    # that is not an actual tag
     wd.commit_testfile()
-    assert wd.version.startswith("0.2.dev1+")
+    assert wd.get_version().startswith("0.2.dev1+")
 
     # several tags
     wd("hg up")
     wd('hg tag v0.2 -u test -d "0 0"')
     wd('hg tag v0.3 -u test -d "0 0" -r v0.2')
-    assert wd.version == "0.3"
+    assert wd.get_version() == "0.3"
 
 
 def test_version_from_archival(wd: WorkDir) -> None:
@@ -108,14 +108,14 @@ def test_version_from_archival(wd: WorkDir) -> None:
     # cleaning the wd ensure this test won't break randomly
     wd.cwd.joinpath(".hg").rename(wd.cwd / ".nothg")
     wd.write(".hg_archival.txt", "node: 000000000000\n" "tag: 0.1\n")
-    assert wd.version == "0.1"
+    assert wd.get_version() == "0.1"
 
     wd.write(
         ".hg_archival.txt",
         "node: 000000000000\n" "latesttag: 0.1\n" "latesttagdistance: 3\n",
     )
 
-    assert wd.version == "0.2.dev3+h000000000000"
+    assert wd.get_version() == "0.2.dev3+h000000000000"
 
 
 @pytest.mark.issue("#72")
@@ -125,7 +125,7 @@ def test_version_in_merge(wd: WorkDir) -> None:
     wd("hg up 0")
     wd.commit_testfile()
     wd("hg merge --tool :merge")
-    assert wd.version is not None
+    assert wd.get_version() is not None
 
 
 @pytest.mark.issue(128)
@@ -157,14 +157,14 @@ def pre_merge_commit_after_tag(version_1_0: WorkDir) -> WorkDir:
 
 @pytest.mark.usefixtures("pre_merge_commit_after_tag")
 def test_version_bump_before_merge_commit(wd: WorkDir) -> None:
-    assert wd.version.startswith("1.0.1.dev1+")
+    assert wd.get_version().startswith("1.0.1.dev1+")
 
 
 @pytest.mark.issue(219)
 @pytest.mark.usefixtures("pre_merge_commit_after_tag")
 def test_version_bump_from_merge_commit(wd: WorkDir) -> None:
     wd.commit()
-    assert wd.version.startswith("1.0.1.dev3+")  # issue 219
+    assert wd.get_version().startswith("1.0.1.dev3+")  # issue 219
 
 
 @pytest.mark.usefixtures("version_1_0")
@@ -174,9 +174,9 @@ def test_version_bump_from_commit_including_hgtag_mods(wd: WorkDir) -> None:
         tagfile.write(b"0  0\n")
     wd.write("branchfile", "branchtext")
     wd(wd.add_command)
-    assert wd.version.startswith("1.0.1.dev1+")  # bump from dirty version
+    assert wd.get_version().startswith("1.0.1.dev1+")  # bump from dirty version
     wd.commit()  # commits both the testfile _and_ .hgtags
-    assert wd.version.startswith("1.0.1.dev2+")
+    assert wd.get_version().startswith("1.0.1.dev2+")
 
 
 @pytest.mark.issue(229)
@@ -186,7 +186,7 @@ def test_latest_tag_detection(wd: WorkDir) -> None:
     Note that will be superseded by the fix for pypa/setuptools_scm/issues/235
     """
     wd('hg tag some-random-tag -u test -d "0 0"')
-    assert wd.version == "1.0.0"
+    assert wd.get_version() == "1.0.0"
 
 
 @pytest.mark.usefixtures("version_1_0")
diff --git a/testing/test_regressions.py b/testing/test_regressions.py
index f7b4b17..018f37e 100644
--- a/testing/test_regressions.py
+++ b/testing/test_regressions.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import os
 import pprint
 import subprocess
 import sys
@@ -16,7 +15,6 @@ from pathlib import Path
 import pytest
 
 from setuptools_scm import Configuration
-from setuptools_scm import get_version
 from setuptools_scm.git import parse
 from setuptools_scm._run_cmd import run
 
@@ -48,28 +46,7 @@ def test_pkginfo_noscmroot(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> N
         pass
     else:
         res = run([sys.executable, "setup.py", "--version"], p)
-        assert res.stdout == "0.1.dev0"
-
-
-def test_pip_egg_info(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
-    """if we are indeed a sdist, the root does not apply"""
-
-    # we should get the version from pkg-info if git is broken
-    p = tmp_path.joinpath("sub/package")
-    p.mkdir(parents=True)
-    tmp_path.joinpath(".git").mkdir()
-    p.joinpath("setup.py").write_text(
-        "from setuptools import setup;" 'setup(use_scm_version={"root": ".."})'
-    )
-
-    with pytest.raises(LookupError):
-        get_version(root=os.fspath(p), fallback_root=os.fspath(p))
-
-    bad_egg_info = p.joinpath("pip-egg-info/random.egg-info/")
-    bad_egg_info.mkdir(parents=True)
-
-    bad_egg_info.joinpath("PKG-INFO").write_text("Version: 1.0")
-    assert get_version(root=os.fspath(p), fallback_root=os.fspath(p)) == "1.0"
+        assert res.stdout == "0.1.dev0+d20090213"
 
 
 @pytest.mark.issue(164)
diff --git a/testing/wd_wrapper.py b/testing/wd_wrapper.py
index 1587f82..dd504ee 100644
--- a/testing/wd_wrapper.py
+++ b/testing/wd_wrapper.py
@@ -23,15 +23,12 @@ class WorkDir:
         if kw:
             assert isinstance(cmd, str), "formatting the command requires text input"
             cmd = cmd.format(**kw)
-        from setuptools_scm.utils import do
+        from setuptools_scm._run_cmd import run
 
-        return do(cmd, self.cwd)
+        return run(cmd, cwd=self.cwd).stdout
 
-    def write(self, name: str, content: str | bytes, **kw: object) -> Path:
+    def write(self, name: str, content: str | bytes) -> Path:
         path = self.cwd / name
-        if kw:
-            assert isinstance(content, str)
-            content = content.format(**kw)
         if isinstance(content, bytes):
             path.write_bytes(content)
         else:
@@ -59,7 +56,7 @@ class WorkDir:
 
     def commit_testfile(self, reason: str | None = None, signed: bool = False) -> None:
         reason = self._reason(reason)
-        self.write("test.txt", "test {reason}", reason=reason)
+        self.write("test.txt", f"test {reason}")
         self(self.add_command)
         self.commit(reason=reason, signed=signed)
 
@@ -68,10 +65,5 @@ class WorkDir:
         from setuptools_scm import get_version
 
         version = get_version(root=self.cwd, fallback_root=self.cwd, **kw)
-        print(version)
+        print(self.cwd.name, version, sep=": ")
         return version
-
-    @property
-    def version(self) -> str:
-        __tracebackhide__ = True
-        return self.get_version()