Repository: callowayproject/bump-my-version
Pull request #219 (2024-08-06 16:02:00)

Fixes issues with external config files in git repos
Introduced the `repository_root` field to store the root path of the repository in the data classes. Refactored subprocess handling to use a new `run_command` utility for improved readability and error handling consistency. Removed unnecessary dependency from `.pre-commit-config.yaml` to streamline dependencies.


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index ad273944..12c51f4a 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -54,7 +54,7 @@ repos:
     hooks:
       - id: mypy
         args: [--no-strict-optional, --ignore-missing-imports]
-        additional_dependencies: ["pydantic>2.0", "toml", "types-all"]
+        additional_dependencies: ["pydantic>2.0", "toml"]
   - repo: https://github.com/jsh9/pydoclint
     rev: 0.5.6
     hooks:
diff --git a/bumpversion/bump.py b/bumpversion/bump.py
index 0e9aee84..935f64de 100644
--- a/bumpversion/bump.py
+++ b/bumpversion/bump.py
@@ -2,7 +2,7 @@
 
 import shlex
 from pathlib import Path
-from typing import TYPE_CHECKING, ChainMap, List, Optional
+from typing import TYPE_CHECKING, List, MutableMapping, Optional
 
 if TYPE_CHECKING:  # pragma: no-coverage
     from bumpversion.files import ConfiguredFile
@@ -117,7 +117,7 @@ def commit_and_tag(
     config: Config,
     config_file: Optional[Path],
     configured_files: List["ConfiguredFile"],
-    ctx: ChainMap,
+    ctx: MutableMapping,
     dry_run: bool = False,
 ) -> None:
     """
diff --git a/bumpversion/config/files.py b/bumpversion/config/files.py
index 54675d3b..134eb8ab 100644
--- a/bumpversion/config/files.py
+++ b/bumpversion/config/files.py
@@ -129,6 +129,11 @@ def update_config_file(
         logger.info("\n%sProcessing config file: %s", logger.indent_str, config_file)
     logger.indent()
     config_path = Path(config_file)
+
+    if config.scm_info.tool and not config.scm_info.path_in_repo(config_path):
+        logger.info("\n%sConfiguration file is outside of the repo. Not going to change.", logger.indent_str)
+        return
+
     if config_path.suffix != ".toml":
         logger.info("You must have a `.toml` suffix to update the config file: %s.", config_path)
         return
diff --git a/bumpversion/scm.py b/bumpversion/scm.py
index 5e4d65e1..f98b8db8 100644
--- a/bumpversion/scm.py
+++ b/bumpversion/scm.py
@@ -6,15 +6,15 @@ import subprocess
 from dataclasses import dataclass
 from pathlib import Path
 from tempfile import NamedTemporaryFile
-from typing import TYPE_CHECKING, ClassVar, List, MutableMapping, Optional, Type, Union
+from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, MutableMapping, Optional, Type, Union
 
 from bumpversion.ui import get_indented_logger
-from bumpversion.utils import extract_regex_flags
+from bumpversion.utils import extract_regex_flags, format_and_raise_error, run_command
 
 if TYPE_CHECKING:  # pragma: no-coverage
     from bumpversion.config import Config
 
-from bumpversion.exceptions import BumpVersionError, DirtyWorkingDirectoryError, SignedTagsError
+from bumpversion.exceptions import DirtyWorkingDirectoryError, SignedTagsError
 
 logger = get_indented_logger(__name__)
 
@@ -29,6 +29,7 @@ class SCMInfo:
     current_version: Optional[str] = None
     branch_name: Optional[str] = None
     short_branch_name: Optional[str] = None
+    repository_root: Optional[Path] = None
     dirty: Optional[bool] = None
 
     def __str__(self):
@@ -37,11 +38,23 @@ class SCMInfo:
     def __repr__(self):
         tool_name = self.tool.__name__ if self.tool else "No SCM tool"
         return (
-            f"SCMInfo(tool={tool_name}, commit_sha={self.commit_sha}, "
-            f"distance_to_latest_tag={self.distance_to_latest_tag}, current_version={self.current_version}, "
+            f"SCMInfo(tool={tool_name}, "
+            f"commit_sha={self.commit_sha}, "
+            f"distance_to_latest_tag={self.distance_to_latest_tag}, "
+            f"current_version={self.current_version}, "
+            f"branch_name={self.branch_name}, "
+            f"short_branch_name={self.short_branch_name}, "
+            f"repository_root={self.repository_root}, "
             f"dirty={self.dirty})"
         )
 
+    def path_in_repo(self, path: Union[Path, str]) -> bool:
+        """Return whether a path is inside this repository."""
+        if self.repository_root is None:
+            return True
+
+        return str(path).startswith(str(self.repository_root))
+
 
 class SourceCodeManager:
     """Base class for version control systems."""
@@ -71,7 +84,7 @@ class SourceCodeManager:
 
         try:
             cmd = [*cls._COMMIT_COMMAND, f.name, *extra_args]
-            subprocess.run(cmd, env=env, capture_output=True, check=True)  # noqa: S603
+            run_command(cmd, env=env)
         except (subprocess.CalledProcessError, TypeError) as exc:  # pragma: no-coverage
             cls.format_and_raise_error(exc)
         finally:
@@ -80,20 +93,13 @@ class SourceCodeManager:
     @classmethod
     def format_and_raise_error(cls, exc: Union[TypeError, subprocess.CalledProcessError]) -> None:
         """Format the error message from an exception and re-raise it as a BumpVersionError."""
-        if isinstance(exc, subprocess.CalledProcessError):
-            output = "\n".join([x for x in [exc.stdout.decode("utf8"), exc.stderr.decode("utf8")] if x])
-            cmd = " ".join(exc.cmd)
-            err_msg = f"Failed to run `{cmd}`: return code {exc.returncode}, output: {output}"
-        else:  # pragma: no-coverage
-            err_msg = f"Failed to run {cls._COMMIT_COMMAND}: {exc}"
-        logger.exception(err_msg)
-        raise BumpVersionError(err_msg) from exc
+        format_and_raise_error(exc)
 
     @classmethod
     def is_usable(cls) -> bool:
         """Is the VCS implementation usable."""
         try:
-            result = subprocess.run(cls._TEST_USABLE_COMMAND, check=True, capture_output=True)  # noqa: S603
+            result = run_command(cls._TEST_USABLE_COMMAND)
             return result.returncode == 0
         except (FileNotFoundError, PermissionError, NotADirectoryError, subprocess.CalledProcessError):
             return False
@@ -122,7 +128,7 @@ class SourceCodeManager:
     def get_all_tags(cls) -> List[str]:
         """Return all tags in VCS."""
         try:
-            result = subprocess.run(cls._ALL_TAGS_COMMAND, text=True, check=True, capture_output=True)  # noqa: S603
+            result = run_command(cls._ALL_TAGS_COMMAND)
             return result.stdout.splitlines()
         except (FileNotFoundError, PermissionError, NotADirectoryError, subprocess.CalledProcessError):
             return []
@@ -238,24 +244,48 @@ class Git(SourceCodeManager):
         """Assert that the working directory is not dirty."""
         lines = [
             line.strip()
-            for line in subprocess.check_output(["git", "status", "--porcelain"]).splitlines()  # noqa: S603, S607
-            if not line.strip().startswith(b"??")
+            for line in run_command(["git", "status", "--porcelain"]).stdout.splitlines()
+            if not line.strip().startswith("??")
         ]
-
-        if lines:
-            joined_lines = b"\n".join(lines).decode()
+        if joined_lines := "\n".join(lines):
             raise DirtyWorkingDirectoryError(f"Git working directory is not clean:\n\n{joined_lines}")
 
     @classmethod
     def latest_tag_info(cls, tag_name: str, parse_pattern: str) -> SCMInfo:
         """Return information about the latest tag."""
+        if not cls.is_usable():
+            return SCMInfo()
+
+        info: Dict[str, Any] = {"tool": cls}
+
         try:
             # git-describe doesn't update the git-index, so we do that
-            subprocess.run(["git", "update-index", "--refresh", "-q"], capture_output=True)  # noqa: S603, S607
+            run_command(["git", "update-index", "--refresh", "-q"])
         except subprocess.CalledProcessError as e:
             logger.debug("Error when running git update-index: %s", e.stderr)
-            return SCMInfo(tool=cls)
+
+        commit_info = cls._commit_info(parse_pattern, tag_name)
+        rev_info = cls._revision_info()
+        info.update(commit_info)
+        info.update(rev_info)
+
+        return SCMInfo(**info)
+
+    @classmethod
+    def _commit_info(cls, parse_pattern: str, tag_name: str) -> dict:
+        """
+        Get the commit info for the repo.
+
+        Args:
+            parse_pattern: The regular expression pattern used to parse the version from the tag.
+            tag_name: The tag name format used to locate the latest tag.
+
+        Returns:
+            A dictionary containing information about the latest commit.
+        """
         tag_pattern = tag_name.replace("{new_version}", "*")
+        info = dict.fromkeys(["dirty", "commit_sha", "distance_to_latest_tag", "current_version"])
+        info["distance_to_latest_tag"] = 0
         try:
             # get info about the latest tag in git
             git_cmd = [
@@ -267,36 +297,65 @@ class Git(SourceCodeManager):
                 "--abbrev=40",
                 f"--match={tag_pattern}",
             ]
-            result = subprocess.run(git_cmd, text=True, check=True, capture_output=True)  # noqa: S603
+            result = run_command(git_cmd)
             describe_out = result.stdout.strip().split("-")
-
-            git_cmd = ["git", "rev-parse", "--abbrev-ref", "HEAD"]
-            result = subprocess.run(git_cmd, text=True, check=True, capture_output=True)  # noqa: S603
-            branch_name = result.stdout.strip()
-            short_branch_name = re.sub(r"([^a-zA-Z0-9]*)", "", branch_name).lower()[:20]
+            if describe_out[-1].strip() == "dirty":
+                info["dirty"] = True
+                describe_out.pop()
+            else:
+                info["dirty"] = False
+
+            info["commit_sha"] = describe_out.pop().lstrip("g")
+            info["distance_to_latest_tag"] = int(describe_out.pop())
+            version = cls.get_version_from_tag("-".join(describe_out), tag_name, parse_pattern)
+            info["current_version"] = version or "-".join(describe_out).lstrip("v")
         except subprocess.CalledProcessError as e:
             logger.debug("Error when running git describe: %s", e.stderr)
-            return SCMInfo(tool=cls)
 
-        info = SCMInfo(tool=cls, branch_name=branch_name, short_branch_name=short_branch_name)
+        return info
 
-        if describe_out[-1].strip() == "dirty":
-            info.dirty = True
-            describe_out.pop()
-        else:
-            info.dirty = False
+    @classmethod
+    def _revision_info(cls) -> dict:
+        """
+        Returns a dictionary containing revision information.
+
+        If an error occurs while running the git command, the dictionary values will be set to None.
+
+        Returns:
+            A dictionary with the following keys:
+                - branch_name: The name of the current branch.
+                - short_branch_name: A 20 lowercase characters of the branch name with special characters removed.
+                - repository_root: The root directory of the Git repository.
+        """
+        info = dict.fromkeys(["branch_name", "short_branch_name", "repository_root"])
 
-        info.commit_sha = describe_out.pop().lstrip("g")
-        info.distance_to_latest_tag = int(describe_out.pop())
-        version = cls.get_version_from_tag("-".join(describe_out), tag_name, parse_pattern)
-        info.current_version = version or "-".join(describe_out).lstrip("v")
+        try:
+            git_cmd = ["git", "rev-parse", "--show-toplevel", "--abbrev-ref", "HEAD"]
+            result = run_command(git_cmd)
+            lines = [line.strip() for line in result.stdout.split("\n")]
+            repository_root = Path(lines[0])
+            branch_name = lines[1]
+            short_branch_name = re.sub(r"([^a-zA-Z0-9]*)", "", branch_name).lower()[:20]
+            info["branch_name"] = branch_name
+            info["short_branch_name"] = short_branch_name
+            info["repository_root"] = repository_root
+        except subprocess.CalledProcessError as e:
+            logger.debug("Error when running git rev-parse: %s", e.stderr)
 
         return info
 
     @classmethod
     def add_path(cls, path: Union[str, Path]) -> None:
         """Add a path to the VCS."""
-        subprocess.check_output(["git", "add", "--update", str(path)])  # noqa: S603, S607
+        info = SCMInfo(**cls._revision_info())
+        if not info.path_in_repo(path):
+            return
+        cwd = Path.cwd()
+        temp_path = os.path.relpath(path, cwd)
+        try:
+            run_command(["git", "add", "--update", str(temp_path)])
+        except subprocess.CalledProcessError as e:
+            format_and_raise_error(e)
 
     @classmethod
     def tag(cls, name: str, sign: bool = False, message: Optional[str] = None) -> None:
@@ -304,7 +363,7 @@ class Git(SourceCodeManager):
         Create a tag of the new_version in VCS.
 
         If only name is given, bumpversion uses a lightweight tag.
-        Otherwise, it utilizes an annotated tag.
+        Otherwise, it uses an annotated tag.
 
         Args:
             name: The name of the tag
@@ -316,7 +375,7 @@ class Git(SourceCodeManager):
             command += ["--sign"]
         if message:
             command += ["--message", message]
-        subprocess.check_output(command)  # noqa: S603
+        run_command(command)
 
 
 class Mercurial(SourceCodeManager):
@@ -331,19 +390,14 @@ class Mercurial(SourceCodeManager):
         """Return information about the latest tag."""
         current_version = None
         re_pattern = tag_name.replace("{new_version}", ".*")
-        result = subprocess.run(
-            ["hg", "log", "-r", f"tag('re:{re_pattern}')", "--template", "{latesttag}\n"],  # noqa: S603, S607
-            text=True,
-            check=True,
-            capture_output=True,
-        )
+        result = run_command(["hg", "log", "-r", f"tag('re:{re_pattern}')", "--template", "{latesttag}\n"])
         result.check_returncode()
         if result.stdout:
             tag_string = result.stdout.splitlines(keepends=False)[-1]
             current_version = cls.get_version_from_tag(tag_string, tag_name, parse_pattern)
         else:
             logger.debug("No tags found")
-        is_dirty = len(subprocess.check_output(["hg", "status", "-mard"])) != 0  # noqa: S603, S607
+        is_dirty = len(run_command(["hg", "status", "-mard"]).stdout) != 0
         return SCMInfo(tool=cls, current_version=current_version, dirty=is_dirty)
 
     @classmethod
@@ -351,12 +405,12 @@ class Mercurial(SourceCodeManager):
         """Assert that the working directory is clean."""
         lines = [
             line.strip()
-            for line in subprocess.check_output(["hg", "status", "-mard"]).splitlines()  # noqa: S603, S607
-            if not line.strip().startswith(b"??")
+            for line in run_command(["hg", "status", "-mard"]).stdout.splitlines()
+            if not line.strip().startswith("??")
         ]
 
         if lines:
-            joined_lines = b"\n".join(lines).decode()
+            joined_lines = "\n".join(lines)
             raise DirtyWorkingDirectoryError(f"Mercurial working directory is not clean:\n{joined_lines}")
 
     @classmethod
@@ -370,7 +424,7 @@ class Mercurial(SourceCodeManager):
         Create a tag of the new_version in VCS.
 
         If only name is given, bumpversion uses a lightweight tag.
-        Otherwise, it utilizes an annotated tag.
+        Otherwise, it uses an annotated tag.
 
         Args:
             name: The name of the tag
@@ -385,7 +439,7 @@ class Mercurial(SourceCodeManager):
             raise SignedTagsError("Mercurial does not support signed tags.")
         if message:
             command += ["--message", message]
-        subprocess.check_output(command)  # noqa: S603
+        run_command(command)
 
 
 def get_scm_info(tag_name: str, parse_pattern: str) -> SCMInfo:
diff --git a/bumpversion/utils.py b/bumpversion/utils.py
index 6a9aa6a2..9e09a42d 100644
--- a/bumpversion/utils.py
+++ b/bumpversion/utils.py
@@ -1,7 +1,14 @@
 """General utilities."""
 
 import string
-from typing import Any, List, Tuple
+import subprocess
+from subprocess import CompletedProcess
+from typing import Any, List, Optional, Tuple, Union
+
+from bumpversion.exceptions import BumpVersionError
+from bumpversion.ui import get_indented_logger
+
+logger = get_indented_logger(__name__)
 
 
 def extract_regex_flags(regex_pattern: str) -> Tuple[str, str]:
@@ -99,3 +106,22 @@ def set_nested_value(d: dict, value: Any, path: str) -> None:
             raise ValueError(f"Path '{'.'.join(keys[:i+1])}' does not lead to a dictionary.")
         else:
             current_element = current_element[key]
+
+
+def format_and_raise_error(exc: Union[TypeError, subprocess.CalledProcessError]) -> None:
+    """Format the error message from an exception and re-raise it as a BumpVersionError."""
+    if isinstance(exc, subprocess.CalledProcessError):
+        output = "\n".join([x for x in [exc.stdout, exc.stderr] if x])
+        cmd = " ".join(exc.cmd)
+        err_msg = f"Failed to run `{cmd}`: return code {exc.returncode}, output: {output}"
+    else:  # pragma: no-coverage
+        err_msg = f"Failed to run a command: {exc}"
+    logger.exception(err_msg)
+    raise BumpVersionError(err_msg) from exc
+
+
+def run_command(command: list, env: Optional[dict] = None) -> CompletedProcess:
+    """Run a shell command and return its output."""
+    result = subprocess.run(command, text=True, check=True, capture_output=True, env=env)  # NOQA: S603
+    result.check_returncode()
+    return result


Test patch:
diff --git a/tests/fixtures/basic_cfg_expected.txt b/tests/fixtures/basic_cfg_expected.txt
index 27a4e6d0..6c738d33 100644
--- a/tests/fixtures/basic_cfg_expected.txt
+++ b/tests/fixtures/basic_cfg_expected.txt
@@ -85,6 +85,7 @@
               'current_version': None,
               'dirty': None,
               'distance_to_latest_tag': 0,
+              'repository_root': None,
               'short_branch_name': None,
               'tool': None},
  'search': '{current_version}',
diff --git a/tests/fixtures/basic_cfg_expected.yaml b/tests/fixtures/basic_cfg_expected.yaml
index 6fa17ac5..371e8709 100644
--- a/tests/fixtures/basic_cfg_expected.yaml
+++ b/tests/fixtures/basic_cfg_expected.yaml
@@ -114,6 +114,7 @@ scm_info:
   current_version: null
   dirty: null
   distance_to_latest_tag: 0
+  repository_root: null
   short_branch_name: null
   tool: null
 search: "{current_version}"
diff --git a/tests/fixtures/basic_cfg_expected_full.json b/tests/fixtures/basic_cfg_expected_full.json
index 243b0216..3b4d31ba 100644
--- a/tests/fixtures/basic_cfg_expected_full.json
+++ b/tests/fixtures/basic_cfg_expected_full.json
@@ -129,6 +129,7 @@
     "current_version": null,
     "dirty": null,
     "distance_to_latest_tag": 0,
+    "repository_root": null,
     "short_branch_name": null,
     "tool": null
   },
diff --git a/tests/test_bump.py b/tests/test_bump.py
index f61f6179..52b97a56 100644
--- a/tests/test_bump.py
+++ b/tests/test_bump.py
@@ -22,16 +22,17 @@ def mock_context():
 class TestGetNextVersion:
     """Tests for the get_next_version function."""
 
-    def test_passing_a_new_version_should_override_a_version_part(self):
+    def test_passing_a_new_version_should_override_a_version_part(self, tmp_path: Path):
         """Passing a new version should return that version."""
         # Arrange
-        config, version_config, current_version = get_config_data({"current_version": "0.1.0"})
-        version_part = "patch"
-        new_version = "1.2.3"
-        expected_next_version = version_config.parse("1.2.3")
+        with inside_dir(tmp_path):
+            config, version_config, current_version = get_config_data({"current_version": "0.1.0"})
+            version_part = "patch"
+            new_version = "1.2.3"
+            expected_next_version = version_config.parse("1.2.3")
 
-        # Act
-        actual_next_version = bump.get_next_version(current_version, config, version_part, new_version)
+            # Act
+            actual_next_version = bump.get_next_version(current_version, config, version_part, new_version)
 
         # Assert
         assert actual_next_version == expected_next_version
diff --git a/tests/test_cli/test_bump.py b/tests/test_cli/test_bump.py
index 9d97fb0b..e52383b9 100644
--- a/tests/test_cli/test_bump.py
+++ b/tests/test_cli/test_bump.py
@@ -346,3 +346,40 @@ def test_current_version_not_required_in_config(tmp_path, fixtures_path, runner)
 
     assert result.exit_code == 0
     assert config_file.read_text() == config_contents
+
+
+def test_remote_config_doesnt_update(tmp_path, git_repo, runner):
+    """The current version is not required in the configuration."""
+
+    config_file = tmp_path / ".bumpversion.toml"
+    config_contents = '[tool.bumpversion]\nallow_dirty = true\n\n[[tool.bumpversion.files]]\nfilename = "readme.md"\n'
+    config_file.write_text(
+        config_contents,
+        encoding="utf-8",
+    )
+    myfile = git_repo / "readme.md"
+    myfile.write_text("version = 1.0.0\n", encoding="utf-8")
+
+    # Act
+    with inside_dir(git_repo):
+        result: Result = runner.invoke(
+            cli.cli,
+            [
+                "bump",
+                "-vv",
+                "--current-version",
+                "1.0.0",
+                f"--config-file={config_file}",
+                "minor",
+            ],
+        )
+
+    # Assert
+    if result.exit_code != 0:
+        print("Here is the output:")
+        print(result.output)
+        print(traceback.print_exception(result.exc_info[1]))
+
+    assert result.exit_code == 0
+    assert config_file.read_text() == config_contents
+    assert myfile.read_text() == "version = 1.1.0\n"
diff --git a/tests/test_scm.py b/tests/test_scm.py
index 2ffdd80a..d13c72c7 100644
--- a/tests/test_scm.py
+++ b/tests/test_scm.py
@@ -1,5 +1,6 @@
 """Tests of the VCS module."""
 
+import logging
 import subprocess
 from pathlib import Path
 
@@ -9,10 +10,11 @@ from pytest import param, LogCaptureFixture
 from bumpversion import scm
 from bumpversion.exceptions import DirtyWorkingDirectoryError, BumpVersionError
 from bumpversion.ui import setup_logging
+from bumpversion.utils import run_command
 from tests.conftest import get_config_data, inside_dir
 
 
-class TestpSourceCodeManager:
+class TestSourceCodeManager:
     """Tests related to SourceCodeManager."""
 
     class TestGetVersionFromTag:
@@ -45,76 +47,124 @@ class TestpSourceCodeManager:
             # Assert
             assert version == expected
 
-
-def test_format_and_raise_error(git_repo: Path) -> None:
-    """The output formatting from called process error string works as expected."""
-    with inside_dir(git_repo):
-        try:
-            subprocess.run(["git", "add", "newfile.txt"], capture_output=True, check=True)  # noqa: S603
-        except subprocess.CalledProcessError as e:
-            with pytest.raises(BumpVersionError) as bump_error:
-                scm.Git.format_and_raise_error(e)
-            assert bump_error.value.message == (
-                "Failed to run `git add newfile.txt`: return code 128, output: "
-                "fatal: pathspec 'newfile.txt' did not match any files\n"
-            )
-
-
-def test_git_is_usable(git_repo: Path) -> None:
-    """Should return true if git is available, and it is a git repo."""
-    with inside_dir(git_repo):
-        assert scm.Git.is_usable()
-
-
-def test_git_is_not_usable(tmp_path: Path) -> None:
-    """Should return false if it is not a git repo."""
-    with inside_dir(tmp_path):
-        assert not scm.Git.is_usable()
-
-
-def test_git_asserts_not_dirty(git_repo: Path) -> None:
-    """If the git repo is clean, assert_notdirty should return true."""
-    with inside_dir(git_repo):
-        scm.Git.assert_nondirty()
-
-
-def test_git_asserts_dirty(git_repo: Path) -> None:
-    """If the git repo has modified files, assert_notdirty should return false."""
-    readme = git_repo.joinpath("readme.md")
-    readme.touch()
-    with pytest.raises(DirtyWorkingDirectoryError):
+    def test_format_and_raise_error_returns_scm_error(self, git_repo: Path) -> None:
+        """The output formatting from called process error string includes the underlying scm error."""
         with inside_dir(git_repo):
-            subprocess.run(["git", "add", "readme.md"])
-            scm.Git.assert_nondirty()
-
-
-def test_git_latest_tag_info(git_repo: Path) -> None:
-    """Should return information about the latest tag."""
-    readme = git_repo.joinpath("readme.md")
-    readme.touch()
-    tag_prefix = "app/"
-    parse_pattern = r"(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"
-    tag_name = f"{tag_prefix}{{new_version}}"
-    with inside_dir(git_repo):
-        assert scm.Git.latest_tag_info(tag_name, parse_pattern=parse_pattern) == scm.SCMInfo(tool=scm.Git)
-
-        # Add a file and tag
-        subprocess.run(["git", "add", "readme.md"])
-        subprocess.run(["git", "commit", "-m", "first"])
-        subprocess.run(["git", "tag", f"{tag_prefix}0.1.0"])
-        tag_info = scm.Git.latest_tag_info(tag_name, parse_pattern=parse_pattern)
-        assert tag_info.commit_sha is not None
-        assert tag_info.current_version == "0.1.0"
-        assert tag_info.distance_to_latest_tag == 0
-
-        # Make it dirty
-        git_repo.joinpath("something.md").touch()
-        subprocess.run(["git", "add", "something.md"])
-        tag_info = scm.Git.latest_tag_info(tag_name, parse_pattern=parse_pattern)
-        assert tag_info.commit_sha is not None
-        assert tag_info.current_version == "0.1.0"
-        assert tag_info.distance_to_latest_tag == 0
-        assert tag_info.dirty is True
+            try:
+                run_command(["git", "add", "newfile.txt"])
+            except subprocess.CalledProcessError as e:
+                with pytest.raises(BumpVersionError) as bump_error:
+                    scm.Git.format_and_raise_error(e)
+                assert bump_error.value.message == (
+                    "Failed to run `git add newfile.txt`: return code 128, output: "
+                    "fatal: pathspec 'newfile.txt' did not match any files\n"
+                )
+
+
+class TestGit:
+    """Tests related to Git."""
+
+    class TestIsUsable:
+        """Tests related to is_usable."""
+
+        def test_recognizes_a_git_repo(self, git_repo: Path) -> None:
+            """Should return true if git is available, and it is a git repo."""
+            with inside_dir(git_repo):
+                assert scm.Git.is_usable()
+
+        def test_recognizes_not_a_git_repo(self, tmp_path: Path) -> None:
+            """Should return false if it is not a git repo."""
+            with inside_dir(tmp_path):
+                assert not scm.Git.is_usable()
+
+    class TestAssertNonDirty:
+        """Tests for the Git.assert_nondirty() function."""
+
+        def test_does_nothing_when_not_dirty(self, git_repo: Path) -> None:
+            """If the git repo is clean, assert_nondirty should do nothing."""
+            with inside_dir(git_repo):
+                scm.Git.assert_nondirty()
+
+        def test_raises_error_when_dirty(self, git_repo: Path) -> None:
+            """If the git repo has modified files, assert_nondirty should return false."""
+            readme = git_repo.joinpath("readme.md")
+            readme.touch()
+            with pytest.raises(DirtyWorkingDirectoryError):
+                with inside_dir(git_repo):
+                    subprocess.run(["git", "add", "readme.md"])
+                    scm.Git.assert_nondirty()
+
+    class TestLatestTagInfo:
+        """Test for the Git.latest_tag_info() function."""
+
+        def test_an_empty_repo_only_fills_tool_info(self, git_repo: Path) -> None:
+            """
+            If the repo is empty, the info only includes the tool type.
+
+            The git commands to get all the other information depend on there being a `HEAD` object.
+            """
+            tag_prefix = "app/"
+            parse_pattern = r"(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"
+            tag_name = f"{tag_prefix}{{new_version}}"
+            expected = scm.SCMInfo(
+                tool=scm.Git,
+                commit_sha=None,
+                distance_to_latest_tag=0,
+                current_version=None,
+                branch_name=None,
+                short_branch_name=None,
+                repository_root=None,
+                dirty=None,
+            )
+            with inside_dir(git_repo):
+                latest_tag_info = scm.Git.latest_tag_info(tag_name, parse_pattern=parse_pattern)
+                assert latest_tag_info == expected
+
+        def test_returns_commit_and_tag_info(self, git_repo: Path) -> None:
+            """Should return information that it is a git repo."""
+            tag_prefix = "app/"
+            parse_pattern = r"(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"
+            tag_name = f"{tag_prefix}{{new_version}}"
+            readme = git_repo.joinpath("readme.md")
+            readme.touch()
+            with inside_dir(git_repo):
+                # Add a file and tag
+                subprocess.run(["git", "add", "readme.md"])
+                subprocess.run(["git", "commit", "-m", "first"])
+                subprocess.run(["git", "tag", f"{tag_prefix}0.1.0"])
+                tag_info = scm.Git.latest_tag_info(tag_name, parse_pattern=parse_pattern)
+                assert tag_info.commit_sha is not None
+                assert tag_info.current_version == "0.1.0"
+                assert tag_info.distance_to_latest_tag == 0
+                assert tag_info.branch_name == "master"
+                assert tag_info.short_branch_name == "master"
+                assert tag_info.repository_root == git_repo
+                assert tag_info.dirty is False
+
+        def test_git_latest_tag_info(self, git_repo: Path) -> None:
+            """Should return information about the latest tag."""
+            readme = git_repo.joinpath("readme.md")
+            readme.touch()
+            tag_prefix = "app/"
+            parse_pattern = r"(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"
+            tag_name = f"{tag_prefix}{{new_version}}"
+            with inside_dir(git_repo):
+                # Add a file and tag
+                subprocess.run(["git", "add", "readme.md"])
+                subprocess.run(["git", "commit", "-m", "first"])
+                subprocess.run(["git", "tag", f"{tag_prefix}0.1.0"])
+
+                # Make it dirty
+                git_repo.joinpath("something.md").touch()
+                subprocess.run(["git", "add", "something.md"])
+                tag_info = scm.Git.latest_tag_info(tag_name, parse_pattern=parse_pattern)
+                assert tag_info.commit_sha is not None
+                assert tag_info.current_version == "0.1.0"
+                assert tag_info.distance_to_latest_tag == 0
+                assert tag_info.branch_name == "master"
+                assert tag_info.short_branch_name == "master"
+                assert tag_info.repository_root == git_repo
+                assert tag_info.dirty is True
 
 
 def test_git_detects_existing_tag(git_repo: Path, caplog: LogCaptureFixture) -> None:
@@ -226,6 +276,7 @@ def test_commit_tag_dry_run_interactions(
 ):
     """Combinations of commit, tag, dry-run and should produce the expected results."""
     # Arrange
+    caplog.set_level(logging.INFO)
     repo_path: Path = request.getfixturevalue(repo)
     version_path = repo_path / "VERSION"
     version_path.write_text("30.0.3", encoding="utf-8")