diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py index 0b6e0ffa36..a44ba9eddb 100644 --- a/commitizen/commands/bump.py +++ b/commitizen/commands/bump.py @@ -158,7 +158,11 @@ def _find_increment(self, commits: list[git.GitCommit]) -> Increment | None: raise NoPatternMapError( f"'{self.config.settings['name']}' rule does not support bump" ) - return bump.find_increment(commits, regex=bump_pattern, increments_map=bump_map) + return bump.find_increment( + self.cz.filter_commits_before_bump(commits), + regex=bump_pattern, + increments_map=bump_map, + ) def _validate_arguments(self, current_version: VersionProtocol) -> None: errors: list[str] = [] diff --git a/commitizen/commands/changelog.py b/commitizen/commands/changelog.py index 7dbc28ac54..abe03a9241 100644 --- a/commitizen/commands/changelog.py +++ b/commitizen/commands/changelog.py @@ -265,6 +265,7 @@ def __call__(self) -> None: ): raise NoCommitsFoundError("No commits found") + commits = self.cz.filter_commits_before_changelog(commits) tree = changelog.generate_tree_from_commits( commits, tags, diff --git a/commitizen/commands/version.py b/commitizen/commands/version.py index 976b9c04a9..e8b8ea8e34 100644 --- a/commitizen/commands/version.py +++ b/commitizen/commands/version.py @@ -181,7 +181,9 @@ def _get_next_git_version( f"'{self.config.settings['name']}' rule does not support bump" ) increment = bump.find_increment( - commits, regex=bump_pattern, increments_map=bump_map + self.cz.filter_commits_before_bump(commits), + regex=bump_pattern, + increments_map=bump_map, ) # TODO: Consider adding all the parameters `.bump` supports: diff --git a/commitizen/cz/base.py b/commitizen/cz/base.py index 5e7f2663ca..ee51b35edd 100644 --- a/commitizen/cz/base.py +++ b/commitizen/cz/base.py @@ -78,6 +78,52 @@ def __init__(self, config: BaseConfig) -> None: if not self.config.settings.get("style"): self.config.settings.update({"style": BaseCommitizen.default_style_config}) + def filter_commits(self, commits: list[git.GitCommit]) -> list[git.GitCommit]: + """Select commits shared by bump and changelog operations. + + Custom rules can override this method when both operations should use the + same subset of commits. + + Args: + commits: The commits available to the current operation. + + Returns: + The commits that the rule considers relevant. + """ + return commits + + def filter_commits_before_bump( + self, commits: list[git.GitCommit] + ) -> list[git.GitCommit]: + """Select commits before calculating a version increment. + + This operation-specific hook delegates to `filter_commits` so custom rules + can share filtering by default or override only bump behavior. + + Args: + commits: The commits available for bump calculation. + + Returns: + The commits that should contribute to the version increment. + """ + return self.filter_commits(commits) + + def filter_commits_before_changelog( + self, commits: list[git.GitCommit] + ) -> list[git.GitCommit]: + """Select commits before generating a changelog. + + This operation-specific hook delegates to `filter_commits` so custom rules + can share filtering by default or override only changelog behavior. + + Args: + commits: The commits available for changelog generation. + + Returns: + The commits that should contribute to the changelog. + """ + return self.filter_commits(commits) + @abstractmethod def questions(self) -> list[CzQuestion]: """Questions regarding the commit message.""" diff --git a/docs/customization/python_class.md b/docs/customization/python_class.md index c698b03dee..84e448119d 100644 --- a/docs/customization/python_class.md +++ b/docs/customization/python_class.md @@ -118,6 +118,100 @@ That's it, your Commitizen now supports custom rules, and you can run. cz -n cz_strange bump ``` +### Filter commits before bump and changelog generation + +Custom rules can select which commits are relevant before Commitizen calculates a +version increment or generates a changelog. + +| Method | Used by | Default behavior | +| ----------------------------------- | ----------------------------------------------------- | ---------------------------- | +| `filter_commits` | Shared by the operation-specific methods | Return all commits unchanged | +| `filter_commits_before_bump` | `cz bump` and `cz version --next USE_GIT_COMMITS` | Call `filter_commits` | +| `filter_commits_before_changelog` | `cz changelog`, including changelogs created by bump | Call `filter_commits` | + +Override `filter_commits` when bump and changelog generation should use the same +selection. Override an operation-specific method when their selections should +differ. These methods do not affect `cz check`. + +For example, a monorepo can use a full commit-message metadata line to associate +commits with applications: + +```text +feat: add shared library + +Applications: ['AppA', 'AppB'] +``` + +A plugin can retain the built-in Conventional Commits behavior while filtering +on that metadata: + +```python title="cz_applications.py" +import re + +from commitizen import git +from commitizen.cz.conventional_commits import ConventionalCommitsCz +from commitizen.exceptions import InvalidConfigurationError + + +class ApplicationsCommitizen(ConventionalCommitsCz): + """Apply Conventional Commits rules to one configured application. + + Example: + Configure `app = "AppA"` to keep commits whose `Applications:` metadata + includes `AppA`. + """ + + def filter_commits(self, commits: list[git.GitCommit]) -> list[git.GitCommit]: + """Keep commits associated with the configured application. + + Args: + commits: The commits available to the current operation. + + Returns: + Commits whose full message lists the configured application. + + Raises: + InvalidConfigurationError: If the plugin's `app` setting is missing. + """ + application = dict(self.config.settings).get("app") + if not isinstance(application, str) or not application: + raise InvalidConfigurationError( + "cz_applications requires a non-empty 'app' setting" + ) + + application_line = re.compile( + rf"""^Applications:\s*\[[^\]]*['"]{re.escape(application)}['"][^\]]*\]\s*$""", + re.MULTILINE, + ) + return [commit for commit in commits if application_line.search(commit.message)] +``` + +Expose the class through the plugin package: + +```toml title="pyproject.toml" +[project.entry-points."commitizen.plugin"] +cz_applications = "cz_applications:ApplicationsCommitizen" +``` + +Then select the plugin and application in each component's configuration: + +```toml title="app-a/.cz.toml" +[tool.commitizen] +name = "cz_applications" +app = "AppA" +version = "1.0.0" +tag_format = "$version-app-a" +``` + +`app` is owned and interpreted by this plugin; it is not a built-in Commitizen +setting. TOML keys under `[tool.commitizen]` are available through +`self.config.settings`. The declarative `[tool.commitizen.customize]` section +cannot override Python methods, so this use case requires a Python plugin. + +Filtering happens before the existing rule processing. The retained commits are +still interpreted by `bump_pattern` and `bump_map` for version increments and by +`changelog_pattern` and `commit_parser` for changelog entries. + [convcomms]: https://github.com/commitizen-tools/commitizen/blob/master/commitizen/cz/conventional_commits/conventional_commits.py ### Custom commit validation and error message diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index b26f095c9f..55e055813f 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -12,6 +12,7 @@ import commitizen.commands.bump as bump from commitizen import cmd, defaults, git, hooks from commitizen.config.base_config import BaseConfig +from commitizen.cz.conventional_commits import ConventionalCommitsCz from commitizen.exceptions import ( BumpTagFailedError, CommitizenException, @@ -69,6 +70,56 @@ def test_bump_minor_increment(commit_msg: str, util: UtilFixture): ) +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_filters_commits_before_finding_increment( + util: UtilFixture, mocker: MockFixture +): + """Bump calculation considers only commits retained by the rule hook.""" + + def filter_app_a_commits( + self: ConventionalCommitsCz, commits: list[git.GitCommit] + ) -> list[git.GitCommit]: + """Keep commits whose full message declares AppA.""" + return [ + commit + for commit in commits + if "'AppA'" in commit.message.partition("Applications:")[2] + ] + + mocker.patch.object( + ConventionalCommitsCz, + "filter_commits_before_bump", + filter_app_a_commits, + ) + util.create_file_and_commit( + "feat: add AppB feature\n\nApplications: ['AppB']", + filename="app-b", + ) + util.create_file_and_commit( + "fix: correct shared behavior\n\nApplications: ['AppA', 'AppB']", + filename="app-a", + ) + + util.run_cli("bump", "--yes") + + assert git.tag_exist("0.1.1") is True + assert git.tag_exist("0.2.0") is False + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_handles_all_commits_filtered_out(util: UtilFixture, mocker: MockFixture): + """An empty filtered selection follows existing no-increment handling.""" + mocker.patch.object( + ConventionalCommitsCz, + "filter_commits_before_bump", + return_value=[], + ) + util.create_file_and_commit("feat: add an unrelated feature") + + with pytest.raises(NoneIncrementExit): + util.run_cli("bump", "--yes") + + @pytest.mark.parametrize("commit_msg", ["feat: new file", "feat(user): new file"]) @pytest.mark.usefixtures("tmp_commitizen_project") def test_bump_minor_increment_annotated(commit_msg: str, util: UtilFixture): diff --git a/tests/commands/test_changelog_command.py b/tests/commands/test_changelog_command.py index 469f9e88e1..8922b256f9 100644 --- a/tests/commands/test_changelog_command.py +++ b/tests/commands/test_changelog_command.py @@ -10,6 +10,7 @@ from commitizen import git from commitizen.commands.changelog import Changelog +from commitizen.cz.conventional_commits import ConventionalCommitsCz from commitizen.exceptions import ( DryRunExit, InvalidCommandArgumentError, @@ -87,6 +88,46 @@ def test_changelog_with_different_cz( file_regression.check(out, extension=".md") +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_filters_commits_before_generating_tree( + capsys: pytest.CaptureFixture, + util: UtilFixture, + mocker: MockFixture, +): + """Changelog generation excludes commits rejected by the rule hook.""" + + def filter_app_a_commits( + self: ConventionalCommitsCz, commits: list[git.GitCommit] + ) -> list[git.GitCommit]: + """Keep commits whose full message declares AppA.""" + return [ + commit + for commit in commits + if "'AppA'" in commit.message.partition("Applications:")[2] + ] + + mocker.patch.object( + ConventionalCommitsCz, + "filter_commits_before_changelog", + filter_app_a_commits, + ) + util.create_file_and_commit( + "feat: add AppB feature\n\nApplications: ['AppB']", + filename="app-b", + ) + util.create_file_and_commit( + "fix: correct shared behavior\n\nApplications: ['AppA', 'AppB']", + filename="app-a", + ) + + with pytest.raises(DryRunExit): + util.run_cli("changelog", "--dry-run") + + out, _ = capsys.readouterr() + assert "correct shared behavior" in out + assert "add AppB feature" not in out + + @pytest.mark.usefixtures("tmp_commitizen_project") def test_changelog_from_start( changelog_format: ChangelogFormat, diff --git a/tests/commands/test_version_command.py b/tests/commands/test_version_command.py index b86df045c1..005b570d20 100644 --- a/tests/commands/test_version_command.py +++ b/tests/commands/test_version_command.py @@ -4,10 +4,11 @@ import pytest from pytest_mock import MockerFixture -from commitizen import commands +from commitizen import commands, git from commitizen.__version__ import __version__ from commitizen.config.base_config import BaseConfig from commitizen.cz.base import BaseCommitizen +from commitizen.cz.conventional_commits import ConventionalCommitsCz from commitizen.exceptions import ( NoCommitsFoundError, NoPatternMapError, @@ -321,6 +322,51 @@ def test_version_next_use_git_commits( assert captured.out == f"{expected_version}\n" +@pytest.mark.usefixtures("tmp_git_project") +def test_version_next_use_git_commits_filters_before_finding_increment( + config: BaseConfig, + capsys: pytest.CaptureFixture, + util: UtilFixture, + mocker: MockerFixture, +): + """Commit-derived versions use the same filtering hook as bump.""" + + def filter_app_a_commits( + self: ConventionalCommitsCz, commits: list[git.GitCommit] + ) -> list[git.GitCommit]: + """Keep commits whose full message declares AppA.""" + return [ + commit + for commit in commits + if "'AppA'" in commit.message.partition("Applications:")[2] + ] + + mocker.patch.object( + ConventionalCommitsCz, + "filter_commits_before_bump", + filter_app_a_commits, + ) + config.settings["version"] = "1.0.0" + util.create_file_and_commit("feat: initial commit") + util.create_tag("1.0.0") + util.create_file_and_commit( + "feat: add AppB feature\n\nApplications: ['AppB']", + filename="app-b", + ) + util.create_file_and_commit( + "fix: correct shared behavior\n\nApplications: ['AppA', 'AppB']", + filename="app-a", + ) + + commands.Version( + config, + {"project": True, "next": "USE_GIT_COMMITS"}, + )() + + captured = capsys.readouterr() + assert captured.out == "1.0.1\n" + + @pytest.mark.usefixtures("tmp_git_project") def test_version_next_use_git_commits_manual_version( config: BaseConfig, capsys: pytest.CaptureFixture, util: UtilFixture diff --git a/tests/test_factory.py b/tests/test_factory.py index ea58680180..b5abf3cce5 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -5,7 +5,7 @@ import pytest -from commitizen import BaseCommitizen, defaults, factory +from commitizen import BaseCommitizen, defaults, factory, git from commitizen.config import BaseConfig from commitizen.cz import discover_plugins from commitizen.cz.conventional_commits import ConventionalCommitsCz @@ -29,6 +29,34 @@ def test_factory(): assert isinstance(r, BaseCommitizen) +def test_default_commit_filters_keep_all_commits(config: BaseConfig): + """Default filtering hooks preserve the original commit list.""" + cz = ConventionalCommitsCz(config) + commits = [git.GitCommit(rev="1", title="feat: add filtering")] + + assert cz.filter_commits(commits) is commits + assert cz.filter_commits_before_bump(commits) is commits + assert cz.filter_commits_before_changelog(commits) is commits + + +def test_operation_commit_filters_delegate_to_shared_filter(config: BaseConfig, mocker): + """Operation-specific hooks delegate to a shared custom filter.""" + cz = ConventionalCommitsCz(config) + commits = [ + git.GitCommit(rev="1", title="feat: app a"), + git.GitCommit(rev="2", title="feat: app b"), + ] + filtered_commits = commits[:1] + filter_commits = mocker.patch.object( + cz, "filter_commits", return_value=filtered_commits + ) + + assert cz.filter_commits_before_bump(commits) is filtered_commits + assert cz.filter_commits_before_changelog(commits) is filtered_commits + assert filter_commits.call_count == 2 + filter_commits.assert_any_call(commits) + + def test_factory_fails(): config = BaseConfig() config.settings.update({"name": "Nothing"})