Repository: callowayproject/bump-my-version
Pull request #228 (2024-08-18 20:06:51)

Add script hooks
This provides a method for running commands and scripts during the version increment process.


Patch:
diff --git a/bumpversion/bump.py b/bumpversion/bump.py
index 2d135e08..b386aa9a 100644
--- a/bumpversion/bump.py
+++ b/bumpversion/bump.py
@@ -4,6 +4,8 @@ import shlex
 from pathlib import Path
 from typing import TYPE_CHECKING, List, MutableMapping, Optional
 
+from bumpversion.hooks import run_post_commit_hooks, run_pre_commit_hooks, run_setup_hooks
+
 if TYPE_CHECKING:  # pragma: no-coverage
     from bumpversion.files import ConfiguredFile
     from bumpversion.versioning.models import Version
@@ -75,10 +77,14 @@ def do_bump(
     logger.indent()
 
     ctx = get_context(config)
+
     logger.info("Parsing current version '%s'", config.current_version)
     logger.indent()
     version = config.version_config.parse(config.current_version)
     logger.dedent()
+
+    run_setup_hooks(config, version, dry_run)
+
     next_version = get_next_version(version, config, version_part, new_version)
     next_version_str = config.version_config.serialize(next_version, ctx)
     logger.info("New version will be '%s'", next_version_str)
@@ -109,7 +115,13 @@ def do_bump(
 
     ctx = get_context(config, version, next_version)
     ctx["new_version"] = next_version_str
+
+    run_pre_commit_hooks(config, version, next_version, dry_run)
+
     commit_and_tag(config, config_file, configured_files, ctx, dry_run)
+
+    run_post_commit_hooks(config, version, next_version, dry_run)
+
     logger.info("Done.")
 
 
diff --git a/bumpversion/config/__init__.py b/bumpversion/config/__init__.py
index 1195b757..19eec174 100644
--- a/bumpversion/config/__init__.py
+++ b/bumpversion/config/__init__.py
@@ -34,6 +34,9 @@ DEFAULTS = {
     "scm_info": None,
     "parts": {},
     "files": [],
+    "setup_hooks": [],
+    "pre_commit_hooks": [],
+    "post_commit_hooks": [],
 }
 
 
diff --git a/bumpversion/config/models.py b/bumpversion/config/models.py
index 00e630ad..d6ce3ef5 100644
--- a/bumpversion/config/models.py
+++ b/bumpversion/config/models.py
@@ -100,6 +100,9 @@ class Config(BaseSettings):
     scm_info: Optional["SCMInfo"]
     parts: Dict[str, VersionComponentSpec]
     files: List[FileChange] = Field(default_factory=list)
+    setup_hooks: List[str] = Field(default_factory=list)
+    pre_commit_hooks: List[str] = Field(default_factory=list)
+    post_commit_hooks: List[str] = Field(default_factory=list)
     included_paths: List[str] = Field(default_factory=list)
     excluded_paths: List[str] = Field(default_factory=list)
     model_config = SettingsConfigDict(env_prefix="bumpversion_")
diff --git a/bumpversion/hooks.py b/bumpversion/hooks.py
new file mode 100644
index 00000000..3238c019
--- /dev/null
+++ b/bumpversion/hooks.py
@@ -0,0 +1,138 @@
+"""Implementation of the hook interface."""
+
+import datetime
+import os
+import subprocess
+from typing import Dict, List, Optional
+
+from bumpversion.config.models import Config
+from bumpversion.ui import get_indented_logger
+from bumpversion.versioning.models import Version
+
+PREFIX = "BVHOOK_"
+
+logger = get_indented_logger(__name__)
+
+
+def run_command(script: str, environment: Optional[dict] = None) -> subprocess.CompletedProcess:
+    """Runs command-line programs using the shell."""
+    if not isinstance(script, str):
+        raise TypeError(f"`script` must be a string, not {type(script)}")
+    if environment and not isinstance(environment, dict):
+        raise TypeError(f"`environment` must be a dict, not {type(environment)}")
+    return subprocess.run(script, env=environment, encoding="utf-8", shell=True, text=True, capture_output=True)
+
+
+def base_env(config: Config) -> Dict[str, str]:
+    """Provide the base environment variables."""
+    return {
+        f"{PREFIX}NOW": datetime.datetime.now().isoformat(),
+        f"{PREFIX}UTCNOW": datetime.datetime.now(datetime.timezone.utc).isoformat(),
+        **os.environ,
+        **scm_env(config),
+    }
+
+
+def scm_env(config: Config) -> Dict[str, str]:
+    """Provide the scm environment variables."""
+    scm = config.scm_info
+    return {
+        f"{PREFIX}COMMIT_SHA": scm.commit_sha or "",
+        f"{PREFIX}DISTANCE_TO_LATEST_TAG": str(scm.distance_to_latest_tag) or "0",
+        f"{PREFIX}IS_DIRTY": str(scm.dirty),
+        f"{PREFIX}BRANCH_NAME": scm.branch_name or "",
+        f"{PREFIX}SHORT_BRANCH_NAME": scm.short_branch_name or "",
+        f"{PREFIX}CURRENT_VERSION": scm.current_version or "",
+        f"{PREFIX}CURRENT_TAG": scm.current_tag or "",
+    }
+
+
+def version_env(version: Version, version_prefix: str) -> Dict[str, str]:
+    """Provide the environment variables for each version component with a prefix."""
+    return {f"{PREFIX}{version_prefix}{part.upper()}": version[part].value for part in version}
+
+
+def get_setup_hook_env(config: Config, current_version: Version) -> Dict[str, str]:
+    """Provide the environment dictionary for `setup_hook`s."""
+    return {**base_env(config), **scm_env(config), **version_env(current_version, "CURRENT_")}
+
+
+def get_pre_commit_hook_env(config: Config, current_version: Version, new_version: Version) -> Dict[str, str]:
+    """Provide the environment dictionary for `pre_commit_hook`s."""
+    return {
+        **base_env(config),
+        **scm_env(config),
+        **version_env(current_version, "CURRENT_"),
+        **version_env(new_version, "NEW_"),
+    }
+
+
+def get_post_commit_hook_env(config: Config, current_version: Version, new_version: Version) -> Dict[str, str]:
+    """Provide the environment dictionary for `post_commit_hook`s."""
+    return {
+        **base_env(config),
+        **scm_env(config),
+        **version_env(current_version, "CURRENT_"),
+        **version_env(new_version, "NEW_"),
+    }
+
+
+def run_hooks(hooks: List[str], env: Dict[str, str], dry_run: bool = False) -> None:
+    """Run a list of command-line programs using the shell."""
+    logger.indent()
+    for script in hooks:
+        if dry_run:
+            logger.debug(f"Would run {script!r}")
+            continue
+        logger.debug(f"Running {script!r}")
+        logger.indent()
+        result = run_command(script, env)
+        logger.debug(result.stdout)
+        logger.debug(result.stderr)
+        logger.debug(f"Exited with {result.returncode}")
+        logger.indent()
+    logger.dedent()
+
+
+def run_setup_hooks(config: Config, current_version: Version, dry_run: bool = False) -> None:
+    """Run the setup hooks."""
+    env = get_setup_hook_env(config, current_version)
+    if config.setup_hooks:
+        running = "Would run" if dry_run else "Running"
+        logger.info(f"{running} setup hooks:")
+    else:
+        logger.info("No setup hooks defined")
+        return
+
+    run_hooks(config.setup_hooks, env, dry_run)
+
+
+def run_pre_commit_hooks(
+    config: Config, current_version: Version, new_version: Version, dry_run: bool = False
+) -> None:
+    """Run the pre-commit hooks."""
+    env = get_pre_commit_hook_env(config, current_version, new_version)
+
+    if config.pre_commit_hooks:
+        running = "Would run" if dry_run else "Running"
+        logger.info(f"{running} pre-commit hooks:")
+    else:
+        logger.info("No pre-commit hooks defined")
+        return
+
+    run_hooks(config.pre_commit_hooks, env, dry_run)
+
+
+def run_post_commit_hooks(
+    config: Config, current_version: Version, new_version: Version, dry_run: bool = False
+) -> None:
+    """Run the post-commit hooks."""
+    env = get_post_commit_hook_env(config, current_version, new_version)
+    if config.post_commit_hooks:
+        running = "Would run" if dry_run else "Running"
+        logger.info(f"{running} post-commit hooks:")
+    else:
+        logger.info("No post-commit hooks defined")
+        return
+
+    run_hooks(config.post_commit_hooks, env, dry_run)
diff --git a/bumpversion/scm.py b/bumpversion/scm.py
index c8cd6d83..ae540248 100644
--- a/bumpversion/scm.py
+++ b/bumpversion/scm.py
@@ -27,6 +27,7 @@ class SCMInfo:
     commit_sha: Optional[str] = None
     distance_to_latest_tag: int = 0
     current_version: Optional[str] = None
+    current_tag: Optional[str] = None
     branch_name: Optional[str] = None
     short_branch_name: Optional[str] = None
     repository_root: Optional[Path] = None
@@ -42,6 +43,7 @@ class SCMInfo:
             f"commit_sha={self.commit_sha}, "
             f"distance_to_latest_tag={self.distance_to_latest_tag}, "
             f"current_version={self.current_version}, "
+            f"current_tag={self.current_tag}, "
             f"branch_name={self.branch_name}, "
             f"short_branch_name={self.short_branch_name}, "
             f"repository_root={self.repository_root}, "
@@ -286,7 +288,7 @@ class Git(SourceCodeManager):
             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 = dict.fromkeys(["dirty", "commit_sha", "distance_to_latest_tag", "current_version", "current_tag"])
         info["distance_to_latest_tag"] = 0
         try:
             # get info about the latest tag in git
@@ -309,6 +311,7 @@ class Git(SourceCodeManager):
 
             info["commit_sha"] = describe_out.pop().lstrip("g")
             info["distance_to_latest_tag"] = int(describe_out.pop())
+            info["current_tag"] = "-".join(describe_out)
             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:
diff --git a/docs/reference/hooks.md b/docs/reference/hooks.md
new file mode 100644
index 00000000..d18a245c
--- /dev/null
+++ b/docs/reference/hooks.md
@@ -0,0 +1,139 @@
+---
+title: Hooks
+description: Details about writing and setting up hooks
+icon: 
+date: 2024-08-15
+comments: true
+---
+# Hooks
+
+## Hook Suites
+
+A _hook suite_ is a list of _hooks_ to run sequentially. A _hook_ is either an individual shell command or an executable script.
+
+There are three hook suites: _setup, pre-commit,_ and _post-commit._ During the version increment process this is the order of operations:
+
+1. Run _setup_ hooks
+2. Increment version
+3. Change files
+4. Run _pre-commit_ hooks
+5. Commit and tag
+6. Run _post-commit_ hooks
+
+!!! Note
+
+    Don't confuse the _pre-commit_ and _post-commit_ hook suites with Git pre- and post-commit hooks. Those hook suites are named for their adjacency to the commit and tag operation.
+
+
+## Configuration
+
+Configure each hook suite with the `setup_hooks`, `pre_commit_hooks`, or `post_commit_hooks` keys.
+
+Each suite takes a list of strings. The strings may be individual commands:
+
+```toml title="Calling individual commands"
+[tool.bumpversion]
+setup_hooks = [
+    "git config --global user.email \"bump-my-version@github.actions\"",
+    "git config --global user.name \"Testing Git\"",
+    "git --version",
+    "git config --list",
+]
+pre_commit_hooks = ["cat CHANGELOG.md"]
+post_commit_hooks = ["echo Done"]
+```
+
+or the path to an executable script:
+
+```toml title="Calling a shell script"
+[tool.bumpversion]
+setup_hooks = ["path/to/setup.sh"]
+pre_commit_hooks = ["path/to/pre-commit.sh"]
+post_commit_hooks = ["path/to/post-commit.sh"]
+```
+
+!!! Note
+
+    You can make a script executable using the following steps:
+
+    1. Add a [shebang](https://en.wikipedia.org/wiki/Shebang_(Unix)) line to the top like `#!/bin/bash`
+    2. Run `chmod u+x path/to/script.sh` to set the executable bit
+
+## Hook Environments
+
+Each hook has these environment variables set when executed.
+
+### Inherited environment
+
+All environment variables set before bump-my-version was run are available.
+
+### Date and time fields
+
+::: field-list
+
+    `BVHOOK_NOW`
+    : The ISO-8601-formatted current local time without a time zone reference.
+    
+    `BVHOOK_UTCNOW`
+    : The ISO-8601-formatted current local time in the UTC time zone.
+
+### Source code management fields
+
+!!! Note
+
+    These fields will only have values if the code is in a Git or Mercurial repository.
+
+::: field-list
+
+    `BVHOOK_COMMIT_SHA`
+    : The latest commit reference.
+    
+    `BHOOK_DISTANCE_TO_LATEST_TAG`
+    : The number of commits since the latest tag.
+    
+    `BVHOOK_IS_DIRTY`
+    : A boolean indicating if the current repository has pending changes.
+    
+    `BVHOOK_BRANCH_NAME`
+    : The current branch name.
+    
+    `BVHOOK_SHORT_BRANCH_NAME`
+    : The current branch name, converted to lowercase, with non-alphanumeric characters removed and truncated to 20 characters. For example, `feature/MY-long_branch-name` would become `featuremylongbranchn`.
+
+
+### Current version fields
+
+::: field-list
+    `BVHOOK_CURRENT_VERSION`
+    : The current version serialized as a string
+
+    `BVHOOK_CURRENT_TAG`
+    : The current tag
+    
+    `BVHOOK_CURRENT_<version component>`
+    : Each version component defined by the [version configuration parsing regular expression](configuration/global.md#parse). The default configuration would have `BVHOOK_CURRENT_MAJOR`, `BVHOOK_CURRENT_MINOR`, and `BVHOOK_CURRENT_PATCH` available.
+
+
+### New version fields
+
+!!! Note
+
+    These are not available in the _setup_ hook suite.
+
+::: field-list
+    `BVHOOK_NEW_VERSION`
+    : The new version serialized as a string
+
+    `BVHOOK_NEW_TAG`
+    : The new tag
+    
+    `BVHOOK_NEW_<version component>`
+    : Each version component defined by the [version configuration parsing regular expression](configuration/global.md#parse). The default configuration would have `BVHOOK_NEW_MAJOR`, `BVHOOK_NEW_MINOR`, and `BVHOOK_NEW_PATCH` available.
+
+## Outputs
+
+The `stdout` and `stderr` streams are echoed to the console if you pass the `-vv` option.
+
+## Dry-runs
+
+Bump my version does not execute any hooks during a dry run. With the verbose output option it will state which hooks it would have run.
diff --git a/pyproject.toml b/pyproject.toml
index 32aa90cb..f99076e5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -186,7 +186,7 @@ line-length = 119
 select = ["E", "W", "F", "I", "N", "B", "BLE", "C", "D", "E", "F", "I", "N", "S", "T", "W", "RUF", "NPY", "PD", "PGH", "ANN", "C90", "PLC", "PLE", "PLW", "TCH"]
 ignore = [
     "ANN002", "ANN003", "ANN101", "ANN102", "ANN204", "ANN401",
-    "S101", "S104",
+    "S101", "S104", "S602",
     "D105", "D106", "D107", "D200", "D212",
     "PD011",
     "PLW1510",


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index bf0198b2..680b5a55 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -8,6 +8,8 @@ from typing import Generator
 
 import pytest
 
+from bumpversion.versioning.models import Version
+
 
 @pytest.fixture
 def tests_path() -> Path:
@@ -51,6 +53,12 @@ def get_config_data(overrides: dict) -> tuple:
     return conf, version_config, version
 
 
+def get_semver(version: str) -> Version:
+    """Get a semantic version from a string."""
+    _, _, version = get_config_data({"current_version": version})
+    return version
+
+
 @pytest.fixture
 def git_repo(tmp_path: Path) -> Path:
     """Generate a simple temporary git repo and return the path."""
diff --git a/tests/fixtures/basic_cfg_expected.txt b/tests/fixtures/basic_cfg_expected.txt
index 6c738d33..0e0fb5d4 100644
--- a/tests/fixtures/basic_cfg_expected.txt
+++ b/tests/fixtures/basic_cfg_expected.txt
@@ -78,10 +78,13 @@
                        'independent': False,
                        'optional_value': 'gamma',
                        'values': ['dev', 'gamma']}},
+ 'post_commit_hooks': [],
+ 'pre_commit_hooks': [],
  'regex': False,
  'replace': '{new_version}',
  'scm_info': {'branch_name': None,
               'commit_sha': None,
+              'current_tag': None,
               'current_version': None,
               'dirty': None,
               'distance_to_latest_tag': 0,
@@ -90,6 +93,7 @@
               'tool': None},
  'search': '{current_version}',
  'serialize': ('{major}.{minor}.{patch}-{release}', '{major}.{minor}.{patch}'),
+ 'setup_hooks': [],
  'sign_tags': False,
  'tag': True,
  'tag_message': 'Bump version: {current_version} → {new_version}',
diff --git a/tests/fixtures/basic_cfg_expected.yaml b/tests/fixtures/basic_cfg_expected.yaml
index 371e8709..9c35a1d9 100644
--- a/tests/fixtures/basic_cfg_expected.yaml
+++ b/tests/fixtures/basic_cfg_expected.yaml
@@ -106,11 +106,16 @@ parts:
     values:
       - "dev"
       - "gamma"
+post_commit_hooks:
+
+pre_commit_hooks:
+
 regex: false
 replace: "{new_version}"
 scm_info:
   branch_name: null
   commit_sha: null
+  current_tag: null
   current_version: null
   dirty: null
   distance_to_latest_tag: 0
@@ -121,6 +126,8 @@ search: "{current_version}"
 serialize:
   - "{major}.{minor}.{patch}-{release}"
   - "{major}.{minor}.{patch}"
+setup_hooks:
+
 sign_tags: false
 tag: true
 tag_message: "Bump version: {current_version} → {new_version}"
diff --git a/tests/fixtures/basic_cfg_expected_full.json b/tests/fixtures/basic_cfg_expected_full.json
index 3b4d31ba..df4dadeb 100644
--- a/tests/fixtures/basic_cfg_expected_full.json
+++ b/tests/fixtures/basic_cfg_expected_full.json
@@ -121,11 +121,14 @@
       ]
     }
   },
+  "post_commit_hooks": [],
+  "pre_commit_hooks": [],
   "regex": false,
   "replace": "{new_version}",
   "scm_info": {
     "branch_name": null,
     "commit_sha": null,
+    "current_tag": null,
     "current_version": null,
     "dirty": null,
     "distance_to_latest_tag": 0,
@@ -138,6 +141,7 @@
     "{major}.{minor}.{patch}-{release}",
     "{major}.{minor}.{patch}"
   ],
+  "setup_hooks": [],
   "sign_tags": false,
   "tag": true,
   "tag_message": "Bump version: {current_version} \u2192 {new_version}",
diff --git a/tests/test_hooks/__init__.py b/tests/test_hooks/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/test_hooks/test_envs.py b/tests/test_hooks/test_envs.py
new file mode 100644
index 00000000..3570b7f2
--- /dev/null
+++ b/tests/test_hooks/test_envs.py
@@ -0,0 +1,88 @@
+"""Tests for environment generation for hooks."""
+
+import datetime
+import os
+import subprocess
+from pathlib import Path
+
+from bumpversion.hooks import scm_env, PREFIX, base_env, version_env
+from tests.conftest import inside_dir, get_config_data
+
+
+def test_scm_env_returns_correct_info(git_repo: Path):
+    """Should return information about the latest tag."""
+    readme = git_repo.joinpath("readme.md")
+    readme.touch()
+    tag_prefix = "v"
+    overrides = {"current_version": "0.1.0", "commit": True, "tag": True, "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"])
+        conf, _, _ = get_config_data(overrides)
+
+    result = scm_env(conf)
+    assert result[f"{PREFIX}BRANCH_NAME"] == "master"
+    assert len(result[f"{PREFIX}COMMIT_SHA"]) == 40
+    assert result[f"{PREFIX}CURRENT_TAG"] == "v0.1.0"
+    assert result[f"{PREFIX}CURRENT_VERSION"] == "0.1.0"
+    assert result[f"{PREFIX}DISTANCE_TO_LATEST_TAG"] == "0"
+    assert result[f"{PREFIX}IS_DIRTY"] == "False"
+    assert result[f"{PREFIX}SHORT_BRANCH_NAME"] == "master"
+
+
+class MockDatetime(datetime.datetime):
+    @classmethod
+    def now(cls, tz=None):
+        return cls(2022, 2, 1, 17) if tz else cls(2022, 2, 1, 12)
+
+
+class TestBaseEnv:
+    """Tests for base_env function."""
+
+    def test_includes_now_and_utcnow(self, mocker):
+        """The output includes NOW and UTCNOW."""
+        mocker.patch("datetime.datetime", new=MockDatetime)
+        config, _, _ = get_config_data({"current_version": "0.1.0"})
+        result_env = base_env(config)
+
+        assert f"{PREFIX}NOW" in result_env
+        assert f"{PREFIX}UTCNOW" in result_env
+        assert result_env[f"{PREFIX}NOW"] == "2022-02-01T12:00:00"
+        assert result_env[f"{PREFIX}UTCNOW"] == "2022-02-01T17:00:00"
+
+    def test_includes_os_environ(self):
+        """The output includes the current process' environment."""
+        config, _, _ = get_config_data({"current_version": "0.1.0"})
+        result_env = base_env(config)
+
+        for var, value in os.environ.items():
+            assert var in result_env
+            assert result_env[var] == value
+
+    def test_includes_scm_info(self):
+        """The output includes SCM information."""
+        config, _, _ = get_config_data({"current_version": "0.1.0"})
+        result_env = base_env(config)
+
+        assert f"{PREFIX}COMMIT_SHA" in result_env
+        assert f"{PREFIX}DISTANCE_TO_LATEST_TAG" in result_env
+        assert f"{PREFIX}IS_DIRTY" in result_env
+        assert f"{PREFIX}BRANCH_NAME" in result_env
+        assert f"{PREFIX}SHORT_BRANCH_NAME" in result_env
+        assert f"{PREFIX}CURRENT_VERSION" in result_env
+        assert f"{PREFIX}CURRENT_TAG" in result_env
+
+
+def test_current_version_env_includes_correct_info():
+    """pass"""
+    config, _, current_version = get_config_data(
+        {"current_version": "0.1.0", "parse": r"(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"}
+    )
+    result = version_env(current_version, "CURRENT_")
+
+    assert result[f"{PREFIX}CURRENT_MAJOR"] == "0"
+    assert result[f"{PREFIX}CURRENT_MINOR"] == "1"
+    assert result[f"{PREFIX}CURRENT_PATCH"] == "0"
diff --git a/tests/test_hooks/test_run_command.py b/tests/test_hooks/test_run_command.py
new file mode 100644
index 00000000..58f60cd0
--- /dev/null
+++ b/tests/test_hooks/test_run_command.py
@@ -0,0 +1,44 @@
+import subprocess
+import sys
+
+import pytest
+
+from bumpversion.hooks import run_command
+
+
+class TestRunCommand:
+    """Test the run_command function."""
+
+    def test_runs_a_str_command(self):
+        """Runs the command formatted as a string."""
+        result = run_command("echo Hello")
+        assert isinstance(result, subprocess.CompletedProcess)
+        assert result.stdout == "Hello\n"
+
+    def test_can_access_env(self):
+        """The command can access custom environment variables."""
+        cmd = "echo %TEST_ENV%" if sys.platform == "win32" else "echo $TEST_ENV"
+        result = run_command(cmd, environment={"TEST_ENV": "Hello"})
+        assert isinstance(result, subprocess.CompletedProcess)
+        assert result.stdout == "Hello\n"
+
+    def test_non_zero_exit(self):
+        """The result shows a non-zero result code."""
+        result = run_command("exit 1")
+        assert result.returncode == 1
+
+    @pytest.mark.parametrize(
+        "invalid_script",
+        [(123,), (None,), (["exit", "1"])],
+    )
+    def test_an_invalid_script_raises_type_error(self, invalid_script):
+        with pytest.raises(TypeError):
+            run_command(invalid_script)
+
+    @pytest.mark.parametrize(
+        "invalid_env",
+        [("string",), (123,), (None,)],
+    )
+    def test_an_invalid_env_raises_type_error(self, invalid_env):
+        with pytest.raises(TypeError):
+            run_command("echo Hello", environment=invalid_env)
diff --git a/tests/test_hooks/test_run_hook_suites.py b/tests/test_hooks/test_run_hook_suites.py
new file mode 100644
index 00000000..17667edf
--- /dev/null
+++ b/tests/test_hooks/test_run_hook_suites.py
@@ -0,0 +1,118 @@
+import subprocess
+from typing import Callable
+
+import pytest
+from pytest import param
+from bumpversion.hooks import run_setup_hooks, run_pre_commit_hooks, run_post_commit_hooks
+from tests.conftest import get_config_data, get_semver
+
+
+def get_hook_env(*args, **kwargs) -> dict:
+    """Mocked function for the environment setup"""
+    return {}
+
+
+def run_command(script: str, env: dict) -> subprocess.CompletedProcess:
+    """Mocked function for command execution"""
+    return subprocess.CompletedProcess(args=script, returncode=0)
+
+
+CURRENT_VERSION = get_semver("1.0.0")
+NEW_VERSION = get_semver("1.1.0")
+
+
+class TestHookSuites:
+    """Run each hook suite through the same set of tests."""
+
+    suites = (
+        param("setup", run_setup_hooks, (CURRENT_VERSION,), id="setup"),
+        param(
+            "pre_commit",
+            run_pre_commit_hooks,
+            (
+                CURRENT_VERSION,
+                NEW_VERSION,
+            ),
+            id="pre_commit",
+        ),
+        param(
+            "post_commit",
+            run_post_commit_hooks,
+            (
+                CURRENT_VERSION,
+                NEW_VERSION,
+            ),
+            id="post_commit",
+        ),
+    )
+
+    @pytest.mark.parametrize(["suite_name", "suite_func", "suite_args"], suites)
+    def test_calls_each_hook(self, mocker, suite_name: str, suite_func: Callable, suite_args: tuple):
+        """The suite hook runs each hook."""
+        # Assemble
+        env = {"var": "value"}
+        mock_env = mocker.patch(f"bumpversion.hooks.get_{suite_name}_hook_env")
+        mock_env.return_value = env
+        mock_run_command = mocker.patch("bumpversion.hooks.run_command")
+        mock_run_command.return_value = mocker.MagicMock(stdout="output", stderr="error", returncode=0)
+        mock_logger = mocker.patch("bumpversion.hooks.logger")
+
+        config, _, _ = get_config_data({"current_version": "1.0.0", f"{suite_name}_hooks": ["script1", "script2"]})
+
+        # Act
+        suite_func(config, *suite_args)
+
+        # Assert
+        mock_logger.info.assert_called_once_with(f"Running {suite_name} hooks:".replace("_", "-"))
+        mock_env.assert_called_once_with(config, *suite_args)
+        expected_run_command_calls = [
+            mocker.call("script1", env),
+            mocker.call("script2", env),
+        ]
+        mock_run_command.assert_has_calls(expected_run_command_calls)
+
+    @pytest.mark.parametrize(["suite_name", "suite_func", "suite_args"], suites)
+    def test_does_not_run_hooks_if_none_are_specified(
+        self, mocker, suite_name: str, suite_func: Callable, suite_args: tuple
+    ):
+        """If no setup_hooks are defined, nothing is run."""
+        # Assemble
+        env = {"var": "value"}
+        mock_env = mocker.patch(f"bumpversion.hooks.get_{suite_name}_hook_env")
+        mock_env.return_value = env
+        mock_run_command = mocker.patch("bumpversion.hooks.run_command")
+        mock_run_command.return_value = mocker.MagicMock(stdout="output", stderr="error", returncode=0)
+        mock_logger = mocker.patch("bumpversion.hooks.logger")
+
+        config, _, _ = get_config_data({"current_version": "1.0.0", f"{suite_name}_hooks": []})
+
+        # Act
+        suite_func(config, *suite_args)
+
+        # Asserts
+        mock_logger.info.assert_called_once_with(f"No {suite_name} hooks defined".replace("_", "-"))
+        mock_env.assert_called_once_with(config, *suite_args)
+        assert mock_run_command.call_count == 0
+
+    @pytest.mark.parametrize(["suite_name", "suite_func", "suite_args"], suites)
+    def test_does_not_run_hooks_if_dry_run_is_true(
+        self, mocker, suite_name: str, suite_func: Callable, suite_args: tuple
+    ):
+        """If dry_run is True, nothing is run."""
+        # Assemble
+        env = {"var": "value"}
+        mock_env = mocker.patch(f"bumpversion.hooks.get_{suite_name}_hook_env")
+        mock_env.return_value = env
+        mock_run_command = mocker.patch("bumpversion.hooks.run_hooks")
+        mock_logger = mocker.patch("bumpversion.hooks.logger")
+
+        config, _, _ = get_config_data({"current_version": "1.0.0", f"{suite_name}_hooks": ["script1", "script2"]})
+
+        # Act
+        args = [*suite_args, True]
+        suite_func(config, *args)
+
+        # Asserts
+        mock_logger.info.assert_called_once_with(f"Would run {suite_name} hooks:".replace("_", "-"))
+        mock_env.assert_called_once_with(config, *suite_args)
+        assert mock_run_command.call_count == 1
diff --git a/tests/test_hooks/test_run_hooks.py b/tests/test_hooks/test_run_hooks.py
new file mode 100644
index 00000000..f7538924
--- /dev/null
+++ b/tests/test_hooks/test_run_hooks.py
@@ -0,0 +1,48 @@
+"""Tests for the run_hooks function."""
+
+from bumpversion import hooks
+
+
+def test_calls_each_hook(mocker):
+    """It should call each hook passed to it."""
+    # Assemble
+    mock_logger = mocker.patch("bumpversion.hooks.logger")
+    mock_run_command = mocker.patch("bumpversion.hooks.run_command")
+    hooks_list = ["script1", "script2"]
+    env = {"var": "value"}
+    mock_run_command.return_value = mocker.MagicMock(stdout="output", stderr="error", returncode=0)
+
+    # Act
+    hooks.run_hooks(hooks_list, env)
+
+    # Assert
+    expected_calls = [
+        mocker.call("Running 'script1'"),
+        mocker.call("output"),
+        mocker.call("error"),
+        mocker.call("Exited with 0"),
+        mocker.call("Running 'script2'"),
+        mocker.call("output"),
+        mocker.call("error"),
+        mocker.call("Exited with 0"),
+    ]
+    mock_logger.debug.assert_has_calls(expected_calls)
+    mock_run_command.assert_any_call("script1", env)
+    mock_run_command.assert_any_call("script2", env)
+
+
+def test_does_not_call_each_hook_when_dry_run(mocker):
+    """It should not call each hook passed to it when dry_run is True."""
+    # Assemble
+    mock_logger = mocker.patch("bumpversion.hooks.logger")
+    mock_run_command = mocker.patch("bumpversion.hooks.run_command")
+    hooks_list = ["script1", "script2"]
+    env = {"var": "value"}
+
+    # Act
+    hooks.run_hooks(hooks_list, env, dry_run=True)
+
+    # Assert
+    expected_calls = [mocker.call("Would run 'script1'"), mocker.call("Would run 'script2'")]
+    mock_logger.debug.assert_has_calls(expected_calls)
+    mock_run_command.assert_not_called()
diff --git a/tests/test_scm.py b/tests/test_scm.py
index d13c72c7..ed0a0af3 100644
--- a/tests/test_scm.py
+++ b/tests/test_scm.py
@@ -135,37 +135,13 @@ class TestGit:
                 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.current_tag == f"{tag_prefix}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:
     """Attempting to tag when a tag exists will do nothing."""