Repository: relekang/python-semantic-release
Pull request #408 (2022-01-21 15:32:08)

feat: include additional file changes in release commits
It may be useful to include additional file changes within the automated release commits (e.g. generated docs, diagrams, ...)

I suggest that we allow the user to specify a command to be run pre-commit (`pre_commit_command`) and a list of files they would like to include in that commit (`include_additional_files`).

Example usage:
```
[semantic_release]
...
pre_commit_command=make generate-diagrams
include_additional_files=img/d1.png,img/d2.png
```

Feedback welcome! I'll cleanup/document/test more if @relekang agrees with the principle

relekang: Nice, please add docs for the options and then this will be good 😄 
MattPColeman: > Nice, please add docs for the options and then this will be good 😄

👍  Added

Patch:
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 3cd1ad4..9b09134 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -121,6 +121,19 @@ set this option to `false`.
 
 Default: `true`.
 
+.. _config-pre_commit_command:
+
+``pre_commit_command``
+----------------------
+If this command is provided, it will be run prior to the creation of the release commit.
+
+.. _config-include_additional_files:
+
+``include_additional_files``
+----------------------------
+A comma-separated list of files to be included within the release commit. This can include
+any files created/modified by the ``pre_commit_command``.
+
 Commit Parsing
 ==============
 
diff --git a/semantic_release/cli.py b/semantic_release/cli.py
index 3c4a3df..0950e9d 100644
--- a/semantic_release/cli.py
+++ b/semantic_release/cli.py
@@ -29,6 +29,7 @@ from .hvcs import (
     post_changelog,
     upload_to_release,
 )
+from .pre_commit import run_pre_commit, should_run_pre_commit
 from .repository import ArtifactRepo
 from .settings import config, overload_configuration
 from .vcs_helpers import (
@@ -38,6 +39,7 @@ from .vcs_helpers import (
     get_repository_owner_and_name,
     push_new_version,
     tag_new_version,
+    update_additional_files,
     update_changelog_file,
 )
 
@@ -272,8 +274,13 @@ def publish(retry: bool = False, noop: bool = False, **kwargs):
             previous_version=current_version,
         )
 
+        if should_run_pre_commit():
+            logger.info("Running pre-commit command")
+            run_pre_commit()
+
         if not retry:
             update_changelog_file(new_version, changelog_md)
+            update_additional_files()
             bump_version(new_version, level_bump)
         # A new version was released
         logger.info("Pushing new version")
diff --git a/semantic_release/defaults.cfg b/semantic_release/defaults.cfg
index 566cfa7..22c2713 100644
--- a/semantic_release/defaults.cfg
+++ b/semantic_release/defaults.cfg
@@ -12,6 +12,7 @@ changelog_placeholder=<!--next-version-placeholder-->
 changelog_scope=true
 changelog_sections=feature,fix,breaking,documentation,performance,:boom:,:sparkles:,:children_crossing:,:lipstick:,:iphone:,:egg:,:chart_with_upwards_trend:,:ambulance:,:lock:,:bug:,:zap:,:goal_net:,:alien:,:wheelchair:,:speech_balloon:,:mag:,:apple:,:penguin:,:checkered_flag:,:robot:,:green_apple:,Other
 check_build_status=false
+include_additional_files=
 commit_message=Automatically generated by python-semantic-release
 commit_parser=semantic_release.history.angular_parser
 commit_subject={version}
@@ -26,6 +27,7 @@ minor_emoji=:sparkles:,:children_crossing:,:lipstick:,:iphone:,:egg:,:chart_with
 minor_tag=:sparkles:
 patch_emoji=:ambulance:,:lock:,:bug:,:zap:,:goal_net:,:alien:,:wheelchair:,:speech_balloon:,:mag:,:apple:,:penguin:,:checkered_flag:,:robot:,:green_apple:
 patch_without_tag=false
+pre_commit_command=
 pypi_pass_var=PYPI_PASSWORD
 pypi_token_var=PYPI_TOKEN
 pypi_user_var=PYPI_USERNAME
diff --git a/semantic_release/pre_commit.py b/semantic_release/pre_commit.py
new file mode 100644
index 0000000..b00a539
--- /dev/null
+++ b/semantic_release/pre_commit.py
@@ -0,0 +1,20 @@
+"""Run commands prior to the release commit
+"""
+import logging
+
+from invoke import run
+
+from .settings import config
+
+logger = logging.getLogger(__name__)
+
+
+def should_run_pre_commit():
+    command = config.get("pre_commit_command")
+    return bool(command)
+
+
+def run_pre_commit():
+    command = config.get("pre_commit_command")
+    logger.debug(f"Running {command}")
+    run(command)
diff --git a/semantic_release/vcs_helpers.py b/semantic_release/vcs_helpers.py
index 0e26349..78c6e5a 100644
--- a/semantic_release/vcs_helpers.py
+++ b/semantic_release/vcs_helpers.py
@@ -6,7 +6,7 @@ import re
 from datetime import date
 from functools import wraps
 from pathlib import Path, PurePath
-from typing import Optional, Tuple
+from typing import List, Optional, Tuple
 from urllib.parse import urlsplit
 
 from git import GitCommandError, InvalidGitRepositoryError, Repo
@@ -210,6 +210,36 @@ def update_changelog_file(version: str, content_to_add: str):
     repo.git.add(str(git_path.relative_to(str(repo.working_dir))))
 
 
+def get_changed_files(repo: Repo) -> List[str]:
+    """
+    Get untracked / dirty files in the given git repo.
+
+    :param repo: Git repo to check.
+    :return: A list of filenames.
+    """
+    untracked_files = repo.untracked_files
+    dirty_files = [item.a_path for item in repo.index.diff(None)]
+    return [*untracked_files, *dirty_files]
+
+
+@check_repo
+@LoggedFunction(logger)
+def update_additional_files():
+    """
+    Add specified files to VCS, if they've changed.
+    """
+    changed_files = get_changed_files(repo)
+
+    include_additional_files = config.get("include_additional_files")
+    if include_additional_files:
+        for filename in include_additional_files.split(","):
+            if filename in changed_files:
+                logger.debug(f"Updated file: {filename}")
+                repo.git.add(filename)
+            else:
+                logger.warning(f"File {filename} shows no changes, cannot update it.")
+
+
 @check_repo
 @LoggedFunction(logger)
 def tag_new_version(version: str):


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 6d936a2..328b59f 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -452,6 +452,95 @@ def test_version_retry(mocker):
     mock_get_new.assert_called_once_with("current", "patch")
 
 
+def test_publish_should_not_run_pre_commit_by_default(mocker):
+    mocker.patch("semantic_release.cli.checkout")
+    mocker.patch("semantic_release.cli.ci_checks.check")
+    mocker.patch.object(ArtifactRepo, "upload")
+    mocker.patch("semantic_release.cli.upload_to_release")
+    mocker.patch("semantic_release.cli.post_changelog", lambda *x: True)
+    mocker.patch("semantic_release.cli.push_new_version", return_value=True)
+    mocker.patch("semantic_release.cli.should_bump_version", return_value=True)
+    mocker.patch("semantic_release.cli.markdown_changelog", lambda *x, **y: "CHANGES")
+    mocker.patch("semantic_release.cli.update_changelog_file")
+    mocker.patch("semantic_release.cli.bump_version")
+    mocker.patch("semantic_release.cli.get_new_version", lambda *x: "2.0.0")
+    mocker.patch("semantic_release.cli.check_token", lambda: True)
+    run_pre_commit = mocker.patch("semantic_release.cli.run_pre_commit")
+    mocker.patch(
+        "semantic_release.cli.config.get",
+        wrapped_config_get(
+            remove_dist=False,
+            upload_to_pypi=False,
+            upload_to_release=False,
+        ),
+    )
+    mocker.patch("semantic_release.cli.update_changelog_file", lambda *x, **y: None)
+
+    publish()
+
+    assert not run_pre_commit.called
+
+
+def test_publish_should_not_run_pre_commit_with_empty_command(mocker):
+    mocker.patch("semantic_release.cli.checkout")
+    mocker.patch("semantic_release.cli.ci_checks.check")
+    mocker.patch.object(ArtifactRepo, "upload")
+    mocker.patch("semantic_release.cli.upload_to_release")
+    mocker.patch("semantic_release.cli.post_changelog", lambda *x: True)
+    mocker.patch("semantic_release.cli.push_new_version", return_value=True)
+    mocker.patch("semantic_release.cli.should_bump_version", return_value=True)
+    mocker.patch("semantic_release.cli.markdown_changelog", lambda *x, **y: "CHANGES")
+    mocker.patch("semantic_release.cli.update_changelog_file")
+    mocker.patch("semantic_release.cli.bump_version")
+    mocker.patch("semantic_release.cli.get_new_version", lambda *x: "2.0.0")
+    mocker.patch("semantic_release.cli.check_token", lambda: True)
+    run_pre_commit = mocker.patch("semantic_release.cli.run_pre_commit")
+    mocker.patch(
+        "semantic_release.cli.config.get",
+        wrapped_config_get(
+            remove_dist=False,
+            upload_to_pypi=False,
+            upload_to_release=False,
+            pre_commit_command="",
+        ),
+    )
+    mocker.patch("semantic_release.cli.update_changelog_file", lambda *x, **y: None)
+
+    publish()
+
+    assert not run_pre_commit.called
+
+
+def test_publish_should_run_pre_commit_if_provided(mocker):
+    mocker.patch("semantic_release.cli.checkout")
+    mocker.patch("semantic_release.cli.ci_checks.check")
+    mocker.patch.object(ArtifactRepo, "upload")
+    mocker.patch("semantic_release.cli.upload_to_release")
+    mocker.patch("semantic_release.cli.post_changelog", lambda *x: True)
+    mocker.patch("semantic_release.cli.push_new_version", return_value=True)
+    mocker.patch("semantic_release.cli.should_bump_version", return_value=True)
+    mocker.patch("semantic_release.cli.markdown_changelog", lambda *x, **y: "CHANGES")
+    mocker.patch("semantic_release.cli.update_changelog_file")
+    mocker.patch("semantic_release.cli.bump_version")
+    mocker.patch("semantic_release.cli.get_new_version", lambda *x: "2.0.0")
+    mocker.patch("semantic_release.cli.check_token", lambda: True)
+    run_pre_commit = mocker.patch("semantic_release.cli.run_pre_commit")
+    mocker.patch(
+        "semantic_release.cli.config.get",
+        wrapped_config_get(
+            remove_dist=False,
+            upload_to_pypi=False,
+            upload_to_release=False,
+            pre_commit_command="echo \"Hello, world.\"",
+        ),
+    )
+    mocker.patch("semantic_release.cli.update_changelog_file", lambda *x, **y: None)
+
+    publish()
+
+    assert run_pre_commit.called
+
+
 def test_publish_should_not_upload_to_pypi_if_option_is_false(mocker):
     mocker.patch("semantic_release.cli.checkout")
     mocker.patch("semantic_release.cli.ci_checks.check")
diff --git a/tests/test_pre_commit.py b/tests/test_pre_commit.py
new file mode 100644
index 0000000..496cbed
--- /dev/null
+++ b/tests/test_pre_commit.py
@@ -0,0 +1,46 @@
+from semantic_release.pre_commit import run_pre_commit, should_run_pre_commit
+
+import pytest
+
+
+@pytest.mark.parametrize(
+    "commands",
+    ["make do-stuff", "echo hello > somefile.txt"],
+)
+def test_pre_commit_command(mocker, commands):
+    mocker.patch("semantic_release.pre_commit.config.get", lambda *a: commands)
+    mock_run = mocker.patch("semantic_release.pre_commit.run")
+    run_pre_commit()
+    mock_run.assert_called_once_with(commands)
+
+
+@pytest.mark.parametrize(
+    "config,expected",
+    [
+        (
+            {
+                "pre_commit_command": "make generate_some_file",
+            },
+            True,
+        ),
+        (
+            {
+                "pre_commit_command": "cmd",
+            },
+            True,
+        ),
+        (
+            {
+                "pre_commit_command": "",
+            },
+            False,
+        ),
+        (
+            {},
+            False,
+        ),
+    ],
+)
+def test_should_run_pre_commit_command(config, expected, mocker):
+    mocker.patch("semantic_release.cli.config.get", lambda key: config.get(key))
+    assert should_run_pre_commit() == expected
diff --git a/tests/test_vcs_helpers.py b/tests/test_vcs_helpers.py
index 81fca59..0168ba6 100644
--- a/tests/test_vcs_helpers.py
+++ b/tests/test_vcs_helpers.py
@@ -18,6 +18,7 @@ from semantic_release.vcs_helpers import (
     push_new_version,
     tag_new_version,
     update_changelog_file,
+    update_additional_files,
 )
 
 from . import mock, wrapped_config_get
@@ -446,3 +447,83 @@ def test_update_changelog_file_missing_placeholder(mock_git, mocker):
     mock_git.add.assert_not_called()
     mocked_read_text.assert_called_once()
     mocked_write_text.assert_not_called()
+
+
+@pytest.mark.parametrize(
+    "include_additional_files",
+    [
+        "",
+        ",",
+        "somefile.txt",
+        "somefile.txt,anotherfile.rst",
+        "somefile.txt,anotherfile.rst,finalfile.md",
+    ],
+)
+def test_update_additional_files_with_no_changes(
+    mock_git,
+    mocker,
+    include_additional_files,
+):
+    """
+    Since we have no file changes, we expect `add` to never be called,
+    regardless of the config.
+    """
+    mocker.patch(
+        "semantic_release.vcs_helpers.config.get",
+        wrapped_config_get(**{"include_additional_files": include_additional_files}),
+    )
+    mocker.patch("semantic_release.vcs_helpers.get_changed_files", return_value=[])
+    update_additional_files()
+    mock_git.add.assert_not_called()
+
+
+def test_update_additional_files_single_changed_file(mock_git, mocker):
+    """
+    We expect to add the single file corresponding to config & changes.
+    """
+    mocker.patch(
+        "semantic_release.vcs_helpers.config.get",
+        wrapped_config_get(**{"include_additional_files": "somefile.txt"}),
+    )
+    mocker.patch(
+        "semantic_release.vcs_helpers.get_changed_files",
+        return_value=["somefile.txt"],
+    )
+    update_additional_files()
+    mock_git.add.assert_called_once_with("somefile.txt")
+
+
+def test_update_additional_files_one_in_config_two_changes(mock_git, mocker):
+    """
+    Given two file changes, but only one referenced in the config, we
+    expect that single file to be added.
+    """
+    mocker.patch(
+        "semantic_release.vcs_helpers.config.get",
+        wrapped_config_get(**{"include_additional_files": "anotherfile.txt"}),
+    )
+    mocker.patch(
+        "semantic_release.vcs_helpers.get_changed_files",
+        return_value=["somefile.txt", "anotherfile.txt"],
+    )
+    update_additional_files()
+    mock_git.add.assert_called_once_with("anotherfile.txt")
+
+
+def test_update_additional_files_two_in_config_one_change(mock_git, mocker):
+    """
+    Given two file changes, but only one referenced in the config, we
+    expect that single file to be added.
+    """
+    mocker.patch(
+        "semantic_release.vcs_helpers.config.get",
+        wrapped_config_get(
+            **{"include_additional_files": "somefile.txt,anotherfile.txt"}
+        ),
+    )
+    mocker.patch(
+        "semantic_release.vcs_helpers.get_changed_files",
+        return_value=["anotherfile.txt"],
+    )
+    update_additional_files()
+    mock_git.add.assert_called_once_with("anotherfile.txt")