Repository: pantsbuild/pants

Issue:
Pants swallows git stderr on git failures.
In a repo with only a remote of 'origin' and no 'upstream' remote:
```console
$ ./pants --changed-since=upstream/main fmt lint check test
...
  File "/home/jsirois/dev/pantsbuild/pants/src/python/pants/vcs/git.py", line 82, in _check_result
    raise GitException(failure_msg or f"{cmd_str} failed with exit code {result}")
pants.vcs.git.GitException: git --git-dir=/home/jsirois/dev/pantsbuild/pants/.git --work-tree=/home/jsirois/dev/pantsbuild/pants diff --name-only upstream/main...HEAD -- /home/jsirois/dev/pantsbuild/pants failed with exit code 128

Use -ldebug for more logs. 
See https://www.pantsbuild.org/v2.8/docs/troubleshooting for common issues.
Consider reaching out for help: https://www.pantsbuild.org/v2.8/docs/getting-help
```

Running that command line bare:
```console
$ git --git-dir=/home/jsirois/dev/pantsbuild/pants/.git --work-tree=/home/jsirois/dev/pantsbuild/pants diff --name-only upstream/main...HEAD -- /home/jsirois/dev/pantsbuild/pants
fatal: bad revision 'upstream/main...HEAD'
```

This code is swallowing stderr for at least some code paths where it shouldn't be:
https://github.com/pantsbuild/pants/blob/6b1d21590f09544245cd03949ba3281cfd4bf99e/src/python/pants/vcs/git.py#L51-L72



Discussion:

Resolving patch:
diff --git a/src/python/pants/base/build_environment.py b/src/python/pants/base/build_environment.py
--- a/src/python/pants/base/build_environment.py
+++ b/src/python/pants/base/build_environment.py
@@ -1,10 +1,12 @@
 # Copyright 2014 Pants project contributors (see CONTRIBUTORS.md).
 # Licensed under the Apache License, Version 2.0 (see LICENSE).
 
+from __future__ import annotations
+
 import logging
 import os
+from enum import Enum
 from pathlib import Path
-from typing import Optional
 
 from pants.base.build_root import BuildRoot
 from pants.engine.internals import native_engine
@@ -48,22 +50,23 @@ def is_in_container() -> bool:
     )
 
 
-_Git: Optional[Git] = None
+class _GitIinitialized(Enum):
+    NO = 0
+
 
+_Git: _GitIinitialized | Git | None = _GitIinitialized.NO
 
-def get_git() -> Optional[Git]:
+
+def get_git() -> Git | None:
     """Returns Git, if available."""
     global _Git
-    if _Git:
-        return _Git
-
-    # We know about Git, so attempt an auto-configure
-    worktree = Git.detect_worktree()
-    if worktree and os.path.isdir(worktree):
-        git = Git(worktree=worktree)
+    if _Git is _GitIinitialized.NO:
+        # We know about Git, so attempt an auto-configure
         try:
-            logger.debug(f"Detected git repository at {worktree} on branch {git.branch_name}")
+            git = Git.mount()
+            logger.debug(f"Detected git repository at {git.worktree} on branch {git.branch_name}")
             _Git = git
         except GitException as e:
-            logger.info(f"Failed to load git repository at {worktree}: {e!r}")
+            logger.info(f"No git repository at {os.getcwd()}: {e!r}")
+            _Git = None
     return _Git
diff --git a/src/python/pants/vcs/git.py b/src/python/pants/vcs/git.py
--- a/src/python/pants/vcs/git.py
+++ b/src/python/pants/vcs/git.py
@@ -1,21 +1,18 @@
 # Copyright 2014 Pants project contributors (see CONTRIBUTORS.md).
 # Licensed under the Apache License, Version 2.0 (see LICENSE).
 
+from __future__ import annotations
+
 import logging
 import os
 import subprocess
+from dataclasses import dataclass
+from os import PathLike
+from pathlib import Path, PurePath
+from typing import Iterable
 
 from pants.util.contextutil import pushd
-
-# 40 is Linux's hard-coded limit for total symlinks followed when resolving a path.
-MAX_SYMLINKS_IN_REALPATH = 40
-GIT_HASH_LENGTH = 20
-
-NUL = b"\0"
-SPACE = b" "
-NEWLINE = b"\n"
-EMPTY_STRING = b""
-
+from pants.util.meta import frozen_after_init
 
 logger = logging.getLogger(__name__)
 
@@ -24,102 +21,93 @@ class GitException(Exception):
     pass
 
 
+@frozen_after_init
+@dataclass(unsafe_hash=True)
 class Git:
-    @classmethod
-    def detect_worktree(cls, binary="git", subdir=None):
-        """Detect the git working tree above cwd and return it; else, return None.
+    worktree: PurePath
+    _gitdir: PurePath
+    _gitcmd: str
+
+    def __init__(
+        self,
+        worktree: PathLike[str] | None = None,
+        *,
+        gitdir: PathLike[str] | None = None,
+        binary: str = "git",
+    ) -> None:
+        """Creates a git object that assumes the git repository is in the cwd by default.
 
-        :param string binary: The path to the git binary to use, 'git' by default.
-        :param string subdir: The path to start searching for a git repo.
-        :returns: path to the directory where the git working tree is rooted.
-        :rtype: string
+        worktree:  The path to the git repository working tree directory (typically '.').
+        gitdir:    The path to the repository's git metadata directory (typically '.git').
+        binary:    The path to the git binary to use, 'git' by default.
         """
-        # TODO(John Sirois): This is only used as a factory for a Git instance in
-        # pants.base.build_environment.get_scm, encapsulate in a true factory method.
-        cmd = [binary, "rev-parse", "--show-toplevel"]
-        try:
-            if subdir:
-                with pushd(subdir):
-                    process, out = cls._invoke(cmd, stderr=subprocess.DEVNULL)
-            else:
-                process, out = cls._invoke(cmd, stderr=subprocess.DEVNULL)
-            cls._check_result(cmd, process.returncode)
-        except GitException:
-            return None
-        return cls._cleanse(out)
+        self.worktree = Path(worktree or os.getcwd()).resolve()
+        self._gitdir = Path(gitdir).resolve() if gitdir else (self.worktree / ".git")
+        self._gitcmd = binary
 
     @classmethod
-    def _invoke(cls, cmd, stderr=None):
-        """Invoke the given command, and return a tuple of process and raw binary output.
-
-        If stderr is defined as None, it will flow to wherever it is currently mapped
-        for the parent process, generally to the terminal where the user can see the error
-        (cf. https://docs.python.org/3.7/library/subprocess.html#subprocess.Popen ). In
-        some cases we want to treat it specially, which is why it is exposed
-        in the signature of _invoke.
-
-        :param list cmd: The command in the form of a list of strings
-        :returns: The completed process object and its standard output.
-        :raises: Scm.LocalException if there was a problem exec'ing the command at all.
+    def mount(cls, subdir: str | PurePath | None = None, *, binary: str | PurePath = "git") -> Git:
+        """Detect the git working tree above cwd and return it.
+
+        :param string subdir: The path to start searching for a git repo.
+        :param string binary: The path to the git binary to use, 'git' by default.
+        :returns: a Git object that is configured to operate on the found git repo.
+        :raises: :class:`GitException` if no git repo could be found.
         """
+        cmd = [str(binary), "rev-parse", "--show-toplevel"]
+        if subdir:
+            with pushd(str(subdir)):
+                process, out, err = cls._invoke(cmd)
+        else:
+            process, out, err = cls._invoke(cmd)
+        cls._check_result(cmd, process.returncode, err.decode())
+        return cls(worktree=PurePath(cls._cleanse(out)))
+
+    @staticmethod
+    def _invoke(cmd: list[str]) -> tuple[subprocess.Popen, bytes, bytes]:
         try:
-            process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=stderr)
+            process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
         except OSError as e:
             # Binary DNE or is not executable
             cmd_str = " ".join(cmd)
             raise GitException(f"Failed to execute command {cmd_str}: {e!r}")
-        out, _ = process.communicate()
-        return process, out
+        out, err = process.communicate()
+        return process, out, err
 
     @classmethod
-    def _cleanse(cls, output, errors="strict"):
-        return output.strip().decode("utf-8", errors=errors)
+    def _cleanse(cls, output: bytes) -> str:
+        return output.decode().strip()
 
     @classmethod
-    def _check_result(cls, cmd, result, failure_msg=None):
+    def _check_result(cls, cmd: Iterable[str], result: int, failure_msg: str | None = None) -> None:
         if result != 0:
             cmd_str = " ".join(cmd)
             raise GitException(failure_msg or f"{cmd_str} failed with exit code {result}")
 
-    def __init__(self, binary="git", gitdir=None, worktree=None, remote=None, branch=None):
-        """Creates a git scm proxy that assumes the git repository is in the cwd by default.
-
-        binary:    The path to the git binary to use, 'git' by default.
-        gitdir:    The path to the repository's git metadata directory (typically '.git').
-        worktree:  The path to the git repository working tree directory (typically '.').
-        remote:    The default remote to use.
-        branch:    The default remote branch to use.
-        """
-        super().__init__()
-        self._gitcmd = binary
-        self._worktree = os.path.realpath(worktree or os.getcwd())
-        self._gitdir = os.path.realpath(gitdir) if gitdir else os.path.join(self._worktree, ".git")
-        self._remote = remote
-        self._branch = branch
-
     @property
     def current_rev_identifier(self):
         return "HEAD"
 
-    @property
-    def worktree(self):
-        return self._worktree
-
     @property
     def commit_id(self):
         return self._check_output(["rev-parse", "HEAD"])
 
     @property
-    def branch_name(self):
+    def branch_name(self) -> str | None:
         branch = self._check_output(["rev-parse", "--abbrev-ref", "HEAD"])
         return None if branch == "HEAD" else branch
 
-    def fix_git_relative_path(self, worktree_path, relative_to):
-        return os.path.relpath(os.path.join(self._worktree, worktree_path), relative_to)
-
-    def changed_files(self, from_commit=None, include_untracked=False, relative_to=None):
-        relative_to = relative_to or self._worktree
-        rel_suffix = ["--", relative_to]
+    def _fix_git_relative_path(self, worktree_path: str, relative_to: PurePath | str) -> str:
+        return str((self.worktree / worktree_path).relative_to(relative_to))
+
+    def changed_files(
+        self,
+        from_commit: str | None = None,
+        include_untracked: bool = False,
+        relative_to: PurePath | str | None = None,
+    ) -> set[str]:
+        relative_to = PurePath(relative_to) if relative_to is not None else self.worktree
+        rel_suffix = ["--", str(relative_to)]
         uncommitted_changes = self._check_output(["diff", "--name-only", "HEAD"] + rel_suffix)
 
         files = set(uncommitted_changes.splitlines())
@@ -139,40 +127,39 @@ def changed_files(self, from_commit=None, include_untracked=False, relative_to=N
             untracked = self._check_output(untracked_cmd)
             files.update(untracked.split())
         # git will report changed files relative to the worktree: re-relativize to relative_to
-        return {self.fix_git_relative_path(f, relative_to) for f in files}
+        return {self._fix_git_relative_path(f, relative_to) for f in files}
 
-    def changes_in(self, diffspec, relative_to=None):
-        relative_to = relative_to or self._worktree
+    def changes_in(self, diffspec: str, relative_to: PurePath | str | None = None) -> set[str]:
+        relative_to = PurePath(relative_to) if relative_to is not None else self.worktree
         cmd = ["diff-tree", "--no-commit-id", "--name-only", "-r", diffspec]
         files = self._check_output(cmd).split()
-        return {self.fix_git_relative_path(f.strip(), relative_to) for f in files}
+        return {self._fix_git_relative_path(f.strip(), relative_to) for f in files}
 
-    def commit(self, message, verify=True):
-        cmd = ["commit", "--all", "--message=" + message]
-        if not verify:
-            cmd.append("--no-verify")
-        self._check_call(cmd)
+    # N.B.: Only used by tests.
+    def commit(self, message: str) -> None:
+        self._check_call(["commit", "--all", "--message", message])
 
-    def add(self, *paths):
-        self._check_call(["add"] + list(paths))
+    # N.B.: Only used by tests.
+    def add(self, *paths: PurePath) -> None:
+        self._check_call(["add", *(str(path) for path in paths)])
 
-    def _check_call(self, args, failure_msg=None):
+    def _check_call(self, args: Iterable[str]) -> None:
         cmd = self._create_git_cmdline(args)
         self._log_call(cmd)
         result = subprocess.call(cmd)
-        self._check_result(cmd, result, failure_msg)
+        self._check_result(cmd, result)
 
-    def _check_output(self, args, failure_msg=None, errors="strict"):
+    def _check_output(self, args: Iterable[str]) -> str:
         cmd = self._create_git_cmdline(args)
         self._log_call(cmd)
 
-        process, out = self._invoke(cmd)
+        process, out, err = self._invoke(cmd)
 
-        self._check_result(cmd, process.returncode, failure_msg)
-        return self._cleanse(out, errors=errors)
+        self._check_result(cmd, process.returncode, err.decode())
+        return self._cleanse(out)
 
-    def _create_git_cmdline(self, args):
-        return [self._gitcmd, "--git-dir=" + self._gitdir, "--work-tree=" + self._worktree] + args
+    def _create_git_cmdline(self, args: Iterable[str]) -> list[str]:
+        return [self._gitcmd, f"--git-dir={self._gitdir}", f"--work-tree={self.worktree}", *args]
 
-    def _log_call(self, cmd):
+    def _log_call(self, cmd: Iterable[str]) -> None:
         logger.debug("Executing: " + " ".join(cmd))


Test patch:
diff --git a/src/python/pants/vcs/changed_integration_test.py b/src/python/pants/vcs/changed_integration_test.py
--- a/src/python/pants/vcs/changed_integration_test.py
+++ b/src/python/pants/vcs/changed_integration_test.py
@@ -49,7 +49,7 @@ def use_gitdir() -> Iterator[str]:
         subprocess.run(["git", "config", "user.name", "Your Name"], check=True)
         subprocess.run(["git", "add", "."], check=True)
         subprocess.run(["git", "commit", "-am", "Add project files."], check=True)
-        yield Git(gitdir=git_dir, worktree=worktree)
+        yield Git.mount(subdir=worktree)
 
 
 def lines_to_set(str_or_list):
diff --git a/src/python/pants/vcs/git_test.py b/src/python/pants/vcs/git_test.py
--- a/src/python/pants/vcs/git_test.py
+++ b/src/python/pants/vcs/git_test.py
@@ -1,276 +1,301 @@
 # Copyright 2014 Pants project contributors (see CONTRIBUTORS.md).
 # Licensed under the Apache License, Version 2.0 (see LICENSE).
 
+from __future__ import annotations
+
 import os
+import re
 import subprocess
-import unittest
-from contextlib import contextmanager
-
-from pants.util.contextutil import environment_as, pushd, temporary_dir
-from pants.util.dirutil import chmod_plus_x, safe_mkdir, safe_mkdtemp, safe_open, safe_rmtree, touch
-from pants.vcs.git import Git
-
-
-class GitTest(unittest.TestCase):
-    @staticmethod
-    def init_repo(remote_name, remote):
-        subprocess.check_call(["git", "init", "--initial-branch=main"])
-        subprocess.check_call(["git", "config", "user.email", "you@example.com"])
-        subprocess.check_call(["git", "config", "user.name", "Your Name"])
-        subprocess.check_call(["git", "config", "commit.gpgSign", "false"])
-        subprocess.check_call(["git", "remote", "add", remote_name, remote])
-
-    def setUp(self):
-        self.origin = safe_mkdtemp()
-        with pushd(self.origin):
-            subprocess.check_call(["git", "init", "--bare", "--initial-branch=main"])
-
-        self.gitdir = safe_mkdtemp()
-        self.worktree = safe_mkdtemp()
-
-        self.readme_file = os.path.join(self.worktree, "README")
-
-        with environment_as(GIT_DIR=self.gitdir, GIT_WORK_TREE=self.worktree):
-            self.init_repo("depot", self.origin)
-
-            touch(self.readme_file)
-            subprocess.check_call(["git", "add", "README"])
-            safe_mkdir(os.path.join(self.worktree, "dir"))
-            with open(os.path.join(self.worktree, "dir", "f"), "w") as f:
-                f.write("file in subdir")
-
-            # Make some symlinks
-            os.symlink("f", os.path.join(self.worktree, "dir", "relative-symlink"))
-            os.symlink("no-such-file", os.path.join(self.worktree, "dir", "relative-nonexistent"))
-            os.symlink("dir/f", os.path.join(self.worktree, "dir", "not-absolute\u2764"))
-            os.symlink("../README", os.path.join(self.worktree, "dir", "relative-dotdot"))
-            os.symlink("dir", os.path.join(self.worktree, "link-to-dir"))
-            os.symlink("README/f", os.path.join(self.worktree, "not-a-dir"))
-            os.symlink("loop1", os.path.join(self.worktree, "loop2"))
-            os.symlink("loop2", os.path.join(self.worktree, "loop1"))
-
-            subprocess.check_call(
-                ["git", "add", "README", "dir", "loop1", "loop2", "link-to-dir", "not-a-dir"]
-            )
-            subprocess.check_call(["git", "commit", "-am", "initial commit with decode -> \x81b"])
-            self.initial_rev = subprocess.check_output(["git", "rev-parse", "HEAD"]).strip()
-            subprocess.check_call(["git", "tag", "first"])
-            subprocess.check_call(["git", "push", "--tags", "depot", "main"])
-            subprocess.check_call(["git", "branch", "--set-upstream-to", "depot/main"])
-
-            with safe_open(self.readme_file, "wb") as readme:
-                readme.write("Hello World.\u2764".encode())
-            subprocess.check_call(["git", "commit", "-am", "Update README."])
-
-            self.current_rev = subprocess.check_output(["git", "rev-parse", "HEAD"]).strip()
-
-        self.clone2 = safe_mkdtemp()
-        with pushd(self.clone2):
-            self.init_repo("origin", self.origin)
-            subprocess.check_call(["git", "pull", "--tags", "origin", "main:main"])
-
-            with safe_open(os.path.realpath("README"), "a") as readme:
-                readme.write("--")
-            subprocess.check_call(["git", "commit", "-am", "Update README 2."])
-            subprocess.check_call(["git", "push", "--tags", "origin", "main"])
-
-        self.git = Git(gitdir=self.gitdir, worktree=self.worktree)
-
-    def tearDown(self):
-        safe_rmtree(self.origin)
-        safe_rmtree(self.gitdir)
-        safe_rmtree(self.worktree)
-        safe_rmtree(self.clone2)
-
-    def test_integration(self):
-        self.assertEqual(set(), self.git.changed_files())
-        self.assertEqual({"README"}, self.git.changed_files(from_commit="HEAD^"))
-
-        tip_sha = self.git.commit_id
-        self.assertTrue(tip_sha)
-        self.assertEqual("main", self.git.branch_name)
-
-        def edit_readme():
-            with open(self.readme_file, "a") as fp:
-                fp.write("More data.")
-
-        edit_readme()
-        with open(os.path.join(self.worktree, "INSTALL"), "w") as untracked:
-            untracked.write("make install")
-        self.assertEqual({"README"}, self.git.changed_files())
-        self.assertEqual({"README", "INSTALL"}, self.git.changed_files(include_untracked=True))
-
-        # Confirm that files outside of a given relative_to path are ignored
-        self.assertEqual(set(), self.git.changed_files(relative_to="non-existent"))
-
-    def test_detect_worktree(self):
-        with temporary_dir() as _clone:
-            with pushd(_clone):
-                clone = os.path.realpath(_clone)
-
-                self.init_repo("origin", self.origin)
-                subprocess.check_call(["git", "pull", "--tags", "origin", "main:main"])
-
-                def worktree_relative_to(cwd, expected):
-                    # Given a cwd relative to the worktree, tests that the worktree is detected as 'expected'.
-                    orig_cwd = os.getcwd()
-                    try:
-                        abs_cwd = os.path.join(clone, cwd)
-                        if not os.path.isdir(abs_cwd):
-                            os.mkdir(abs_cwd)
-                        os.chdir(abs_cwd)
-                        actual = Git.detect_worktree()
-                        self.assertEqual(expected, actual)
-                    finally:
-                        os.chdir(orig_cwd)
-
-                worktree_relative_to("..", None)
-                worktree_relative_to(".", clone)
-                worktree_relative_to("is", clone)
-                worktree_relative_to("is/a", clone)
-                worktree_relative_to("is/a/dir", clone)
-
-    def test_detect_worktree_no_cwd(self):
-        with temporary_dir() as _clone:
-            with pushd(_clone):
-                clone = os.path.realpath(_clone)
-
-                self.init_repo("origin", self.origin)
-                subprocess.check_call(["git", "pull", "--tags", "origin", "main:main"])
-
-                def worktree_relative_to(some_dir, expected):
-                    # Given a directory relative to the worktree, tests that the worktree is detected as 'expected'.
-                    subdir = os.path.join(clone, some_dir)
-                    if not os.path.isdir(subdir):
-                        os.mkdir(subdir)
-                    actual = Git.detect_worktree(subdir=subdir)
-                    self.assertEqual(expected, actual)
-
-                worktree_relative_to("..", None)
-                worktree_relative_to(".", clone)
-                worktree_relative_to("is", clone)
-                worktree_relative_to("is/a", clone)
-                worktree_relative_to("is/a/dir", clone)
-
-    @property
-    def test_changes_in(self):
-        """Test finding changes in a diffspecs.
-
-        To some extent this is just testing functionality of git not pants, since all pants says is
-        that it will pass the diffspec to git diff-tree, but this should serve to at least document
-        the functionality we believe works.
-        """
-        with environment_as(GIT_DIR=self.gitdir, GIT_WORK_TREE=self.worktree):
-
-            def commit_contents_to_files(content, *files):
-                for path in files:
-                    with safe_open(os.path.join(self.worktree, path), "w") as fp:
-                        fp.write(content)
-                subprocess.check_call(["git", "add", "."])
-                subprocess.check_call(["git", "commit", "-m", f"change {files}"])
-                return subprocess.check_output(["git", "rev-parse", "HEAD"]).strip()
-
-            # We can get changes in HEAD or by SHA
-            c1 = commit_contents_to_files("1", "foo")
-            self.assertEqual({"foo"}, self.git.changes_in("HEAD"))
-            self.assertEqual({"foo"}, self.git.changes_in(c1))
-
-            # Changes in new HEAD, from old-to-new HEAD, in old HEAD, or from old-old-head to new.
-            commit_contents_to_files("2", "bar")
-            self.assertEqual({"bar"}, self.git.changes_in("HEAD"))
-            self.assertEqual({"bar"}, self.git.changes_in("HEAD^..HEAD"))
-            self.assertEqual({"foo"}, self.git.changes_in("HEAD^"))
-            self.assertEqual({"foo"}, self.git.changes_in("HEAD~1"))
-            self.assertEqual({"foo", "bar"}, self.git.changes_in("HEAD^^..HEAD"))
-
-            # New commit doesn't change results-by-sha
-            self.assertEqual({"foo"}, self.git.changes_in(c1))
-
-            # Files changed in multiple diffs within a range
-            c3 = commit_contents_to_files("3", "foo")
-            self.assertEqual({"foo", "bar"}, self.git.changes_in(f"{c1}..{c3}"))
-
-            # Changes in a tag
-            subprocess.check_call(["git", "tag", "v1"])
-            self.assertEqual({"foo"}, self.git.changes_in("v1"))
-
-            # Introduce a new filename
-            c4 = commit_contents_to_files("4", "baz")
-            self.assertEqual({"baz"}, self.git.changes_in("HEAD"))
-
-            # Tag-to-sha
-            self.assertEqual({"baz"}, self.git.changes_in(f"v1..{c4}"))
-
-            # We can get multiple changes from one ref
-            commit_contents_to_files("5", "foo", "bar")
-            self.assertEqual({"foo", "bar"}, self.git.changes_in("HEAD"))
-            self.assertEqual({"foo", "bar", "baz"}, self.git.changes_in("HEAD~4..HEAD"))
-            self.assertEqual({"foo", "bar", "baz"}, self.git.changes_in(f"{c1}..HEAD"))
-            self.assertEqual({"foo", "bar", "baz"}, self.git.changes_in(f"{c1}..{c4}"))
-
-    def test_commit_with_new_untracked_file_adds_file(self):
-        new_file = os.path.join(self.worktree, "untracked_file")
-
-        touch(new_file)
-
-        self.assertEqual({"untracked_file"}, self.git.changed_files(include_untracked=True))
-
-        self.git.add(new_file)
-
-        self.assertEqual({"untracked_file"}, self.git.changed_files())
-
-        self.git.commit("API Changes.")
-
-        self.assertEqual(set(), self.git.changed_files(include_untracked=True))
-
-
-class DetectWorktreeFakeGitTest(unittest.TestCase):
-    @contextmanager
-    def empty_path(self):
-        with temporary_dir() as path:
-            with environment_as(PATH=path):
-                yield path
-
-    @contextmanager
-    def unexecutable_git(self):
-        with self.empty_path() as path:
-            git = os.path.join(path, "git")
-            touch(git)
-            yield git
-
-    @contextmanager
-    def executable_git(self):
-        with self.unexecutable_git() as git:
-            chmod_plus_x(git)
-            yield git
-
-    def test_detect_worktree_no_git(self):
-        with self.empty_path():
-            self.assertIsNone(Git.detect_worktree())
-
-    def test_detect_worktree_unexectuable_git(self):
-        with self.unexecutable_git() as git:
-            self.assertIsNone(Git.detect_worktree())
-            self.assertIsNone(Git.detect_worktree(binary=git))
-
-    def test_detect_worktree_invalid_executable_git(self):
-        with self.executable_git() as git:
-            self.assertIsNone(Git.detect_worktree())
-            self.assertIsNone(Git.detect_worktree(binary=git))
-
-    def test_detect_worktree_failing_git(self):
-        with self.executable_git() as git:
-            with open(git, "w") as fp:
-                fp.write("#!/bin/sh\n")
-                fp.write("exit 1")
-            self.assertIsNone(Git.detect_worktree())
-            self.assertIsNone(Git.detect_worktree(git))
-
-    def test_detect_worktree_working_git(self):
-        expected_worktree_dir = "/a/fake/worktree/dir"
-        with self.executable_git() as git:
-            with open(git, "w") as fp:
-                fp.write("#!/bin/sh\n")
-                fp.write("echo " + expected_worktree_dir)
-            self.assertEqual(expected_worktree_dir, Git.detect_worktree())
-            self.assertEqual(expected_worktree_dir, Git.detect_worktree(binary=git))
+from pathlib import Path, PurePath
+from textwrap import dedent
+from typing import Iterator
+
+import pytest
+from _pytest.tmpdir import TempPathFactory
+
+from pants.util.contextutil import environment_as, pushd
+from pants.vcs.git import Git, GitException
+
+
+def init_repo(remote_name: str, remote: PurePath) -> None:
+    subprocess.check_call(["git", "init", "--initial-branch=main"])
+    subprocess.check_call(["git", "config", "user.email", "you@example.com"])
+    subprocess.check_call(["git", "config", "user.name", "Your Name"])
+    subprocess.check_call(["git", "config", "commit.gpgSign", "false"])
+    subprocess.check_call(["git", "remote", "add", remote_name, str(remote)])
+
+
+@pytest.fixture
+def origin(tmp_path_factory: TempPathFactory) -> PurePath:
+    origin = tmp_path_factory.mktemp("origin")
+    with pushd(str(origin)):
+        subprocess.check_call(["git", "init", "--bare", "--initial-branch=main"])
+    return origin
+
+
+@pytest.fixture
+def gitdir(tmp_path_factory: TempPathFactory) -> PurePath:
+    return tmp_path_factory.mktemp("gitdir")
+
+
+@pytest.fixture
+def worktree(tmp_path_factory: TempPathFactory) -> Path:
+    return tmp_path_factory.mktemp("worktree")
+
+
+@pytest.fixture
+def readme_file(worktree: Path) -> Path:
+    return worktree / "README"
+
+
+@pytest.fixture
+def git(origin: Path, gitdir: Path, worktree: Path, readme_file: Path) -> Git:
+    with environment_as(GIT_DIR=str(gitdir), GIT_WORK_TREE=str(worktree)):
+        init_repo("depot", origin)
+
+        readme_file.touch()
+        subprocess.check_call(["git", "add", "README"])
+        subdir = worktree / "dir"
+        subdir.mkdir()
+        (subdir / "f").write_text("file in subdir")
+
+        # Make some symlinks
+        os.symlink("f", os.path.join(worktree, "dir", "relative-symlink"))
+        os.symlink("no-such-file", os.path.join(worktree, "dir", "relative-nonexistent"))
+        os.symlink("dir/f", os.path.join(worktree, "dir", "not-absolute\u2764"))
+        os.symlink("../README", os.path.join(worktree, "dir", "relative-dotdot"))
+        os.symlink("dir", os.path.join(worktree, "link-to-dir"))
+        os.symlink("README/f", os.path.join(worktree, "not-a-dir"))
+        os.symlink("loop1", os.path.join(worktree, "loop2"))
+        os.symlink("loop2", os.path.join(worktree, "loop1"))
+
+        subprocess.check_call(
+            ["git", "add", "README", "dir", "loop1", "loop2", "link-to-dir", "not-a-dir"]
+        )
+        subprocess.check_call(["git", "commit", "-am", "initial commit with decode -> \x81b"])
+
+        subprocess.check_call(["git", "tag", "first"])
+        subprocess.check_call(["git", "push", "--tags", "depot", "main"])
+        subprocess.check_call(["git", "branch", "--set-upstream-to", "depot/main"])
+
+        readme_file.write_bytes("Hello World.\u2764".encode())
+        subprocess.check_call(["git", "commit", "-am", "Update README."])
+
+    return Git(gitdir=gitdir, worktree=worktree)
+
+
+def test_integration(worktree: Path, readme_file: Path, git: Git) -> None:
+    assert set() == git.changed_files()
+    assert {"README"} == git.changed_files(from_commit="HEAD^")
+
+    assert "main" == git.branch_name
+
+    with readme_file.open(mode="a") as fp:
+        fp.write("More data.")
+
+    (worktree / "INSTALL").write_text("make install")
+
+    assert {"README"} == git.changed_files()
+    assert {"README", "INSTALL"} == git.changed_files(include_untracked=True)
+
+    # Confirm that files outside of a given relative_to path are ignored
+    assert set() == git.changed_files(relative_to="non-existent")
+
+
+def test_detect_worktree(tmp_path_factory: TempPathFactory, origin: PurePath, git: Git) -> None:
+    clone = tmp_path_factory.mktemp("clone").resolve()
+    with pushd(str(clone)):
+        init_repo("origin", origin)
+        subprocess.check_call(["git", "pull", "--tags", "origin", "main:main"])
+
+        def worktree_relative_to(cwd: str, expected: PurePath | None):
+            # Given a cwd relative to the worktree, tests that the worktree is detected as
+            # 'expected'.
+            abs_cwd = clone / cwd
+            abs_cwd.mkdir(parents=True, exist_ok=True)
+            with pushd(str(abs_cwd)):
+                actual = Git.mount().worktree
+                assert expected == actual
+
+        with pytest.raises(GitException):
+            worktree_relative_to("..", None)
+        worktree_relative_to(".", clone)
+        worktree_relative_to("is", clone)
+        worktree_relative_to("is/a", clone)
+        worktree_relative_to("is/a/dir", clone)
+
+
+def test_detect_worktree_no_cwd(
+    tmp_path_factory: TempPathFactory, origin: PurePath, git: Git
+) -> None:
+    clone = tmp_path_factory.mktemp("clone").resolve()
+    with pushd(str(clone)):
+        init_repo("origin", origin)
+        subprocess.check_call(["git", "pull", "--tags", "origin", "main:main"])
+
+        def worktree_relative_to(some_dir: str, expected: PurePath | None):
+            # Given a directory relative to the worktree, tests that the worktree is detected as
+            # 'expected'.
+            subdir = clone / some_dir
+            subdir.mkdir(parents=True, exist_ok=True)
+            actual = Git.mount(subdir=subdir).worktree
+            assert expected == actual
+
+        with pytest.raises(GitException):
+            worktree_relative_to("..", None)
+        worktree_relative_to(".", clone)
+        worktree_relative_to("is", clone)
+        worktree_relative_to("is/a", clone)
+        worktree_relative_to("is/a/dir", clone)
+
+
+def test_changes_in(gitdir: PurePath, worktree: Path, git: Git) -> None:
+    """Test finding changes in a diffspecs.
+
+    To some extent this is just testing functionality of git not pants, since all pants says is that
+    it will pass the diffspec to git diff-tree, but this should serve to at least document the
+    functionality we believe works.
+    """
+    with environment_as(GIT_DIR=str(gitdir), GIT_WORK_TREE=str(worktree)):
+
+        def commit_contents_to_files(content: str, *files: str) -> str:
+            for path in files:
+                (worktree / path).write_text(content)
+            subprocess.check_call(["git", "add", "."])
+            subprocess.check_call(["git", "commit", "-m", f"change {files}"])
+            return subprocess.check_output(["git", "rev-parse", "HEAD"]).decode().strip()
+
+        # We can get changes in HEAD or by SHA
+        c1 = commit_contents_to_files("1", "foo")
+        assert {"foo"} == git.changes_in("HEAD")
+        assert {"foo"} == git.changes_in(c1)
+
+        # Changes in new HEAD, from old-to-new HEAD, in old HEAD, or from old-old-head to new.
+        commit_contents_to_files("2", "bar")
+        assert {"bar"} == git.changes_in("HEAD")
+        assert {"bar"} == git.changes_in("HEAD^..HEAD")
+        assert {"foo"} == git.changes_in("HEAD^")
+        assert {"foo"} == git.changes_in("HEAD~1")
+        assert {"foo", "bar"} == git.changes_in("HEAD^^..HEAD")
+
+        # New commit doesn't change results-by-sha
+        assert {"foo"} == git.changes_in(c1)
+
+        # Files changed in multiple diffs within a range
+        c3 = commit_contents_to_files("3", "foo")
+        assert {"foo", "bar"} == git.changes_in(f"{c1}..{c3}")
+
+        # Changes in a tag
+        subprocess.check_call(["git", "tag", "v1"])
+        assert {"foo"} == git.changes_in("v1")
+
+        # Introduce a new filename
+        c4 = commit_contents_to_files("4", "baz")
+        assert {"baz"} == git.changes_in("HEAD")
+
+        # Tag-to-sha
+        assert {"baz"} == git.changes_in(f"v1..{c4}")
+
+        # We can get multiple changes from one ref
+        commit_contents_to_files("5", "foo", "bar")
+        assert {"foo", "bar"} == git.changes_in("HEAD")
+        assert {"foo", "bar", "baz"} == git.changes_in("HEAD~4..HEAD")
+        assert {"foo", "bar", "baz"} == git.changes_in(f"{c1}..HEAD")
+        assert {"foo", "bar", "baz"} == git.changes_in(f"{c1}..{c4}")
+
+
+def test_commit_with_new_untracked_file_adds_file(worktree: Path, git: Git) -> None:
+    new_file = worktree / "untracked_file"
+    new_file.touch()
+
+    assert {"untracked_file"} == git.changed_files(include_untracked=True)
+
+    git.add(new_file)
+
+    assert {"untracked_file"} == git.changed_files()
+
+    git.commit("API Changes.")
+
+    assert set() == git.changed_files(include_untracked=True)
+
+
+def test_bad_ref_stderr_issues_13396(git: Git) -> None:
+    with pytest.raises(GitException, match=re.escape("fatal: bad revision 'remote/dne...HEAD'\n")):
+        git.changed_files(from_commit="remote/dne")
+
+    with pytest.raises(
+        GitException,
+        match=re.escape(
+            "fatal: ambiguous argument 'HEAD~1000': unknown revision or path not in the working "
+            "tree.\n"
+        ),
+    ):
+        git.changes_in(diffspec="HEAD~1000")
+
+
+@pytest.fixture
+def empty_path(tmp_path_factory: TempPathFactory) -> Iterator[Path]:
+    path = tmp_path_factory.mktemp(basename="bin")
+    with environment_as(PATH=str(path)):
+        yield path
+
+
+@pytest.fixture
+def unexecutable_git(empty_path: Path) -> Path:
+    git = empty_path / "git"
+    git.touch()
+    return git
+
+
+@pytest.fixture
+def executable_git(unexecutable_git: Path) -> Path:
+    unexecutable_git.chmod(0o755)
+    return unexecutable_git
+
+
+def test_detect_worktree_no_git(empty_path: PurePath) -> None:
+    with pytest.raises(GitException):
+        Git.mount()
+
+
+def test_detect_worktree_unexectuable_git(unexecutable_git: PurePath) -> None:
+    with pytest.raises(GitException):
+        Git.mount()
+    with pytest.raises(GitException):
+        Git.mount(binary=unexecutable_git)
+
+
+def test_detect_worktree_invalid_executable_git(executable_git: PurePath) -> None:
+    with pytest.raises(GitException):
+        assert Git.mount() is None
+    with pytest.raises(GitException):
+        Git.mount(binary=executable_git)
+
+
+def test_detect_worktree_failing_git(executable_git: Path) -> None:
+    executable_git.write_text(
+        dedent(
+            """\
+            #!/bin/sh
+            exit 1
+            """
+        )
+    )
+    with pytest.raises(GitException):
+        Git.mount()
+    with pytest.raises(GitException):
+        Git.mount(binary=executable_git)
+
+
+def test_detect_worktree_working_git(executable_git: Path) -> None:
+    expected_worktree_dir = PurePath("/a/fake/worktree/dir")
+    executable_git.write_text(
+        dedent(
+            f"""\
+            #!/bin/sh
+            echo {expected_worktree_dir}
+            """
+        )
+    )
+    assert expected_worktree_dir == Git.mount().worktree
+    assert expected_worktree_dir == Git.mount(binary=executable_git).worktree