Skip to content

Commit 0a593cd

Browse files
schlotterCopilot
andcommitted
feat: add extensible commit filters
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 100bfe3 commit 0a593cd

9 files changed

Lines changed: 317 additions & 4 deletions

File tree

commitizen/commands/bump.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,11 @@ def _find_increment(self, commits: list[git.GitCommit]) -> Increment | None:
158158
raise NoPatternMapError(
159159
f"'{self.config.settings['name']}' rule does not support bump"
160160
)
161-
return bump.find_increment(commits, regex=bump_pattern, increments_map=bump_map)
161+
return bump.find_increment(
162+
self.cz.filter_commits_before_bump(commits),
163+
regex=bump_pattern,
164+
increments_map=bump_map,
165+
)
162166

163167
def _validate_arguments(self, current_version: VersionProtocol) -> None:
164168
errors: list[str] = []

commitizen/commands/changelog.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ def __call__(self) -> None:
265265
):
266266
raise NoCommitsFoundError("No commits found")
267267

268+
commits = self.cz.filter_commits_before_changelog(commits)
268269
tree = changelog.generate_tree_from_commits(
269270
commits,
270271
tags,

commitizen/commands/version.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,9 @@ def _get_next_git_version(
181181
f"'{self.config.settings['name']}' rule does not support bump"
182182
)
183183
increment = bump.find_increment(
184-
commits, regex=bump_pattern, increments_map=bump_map
184+
self.cz.filter_commits_before_bump(commits),
185+
regex=bump_pattern,
186+
increments_map=bump_map,
185187
)
186188

187189
# TODO: Consider adding all the parameters `.bump` supports:

commitizen/cz/base.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,52 @@ def __init__(self, config: BaseConfig) -> None:
7878
if not self.config.settings.get("style"):
7979
self.config.settings.update({"style": BaseCommitizen.default_style_config})
8080

81+
def filter_commits(self, commits: list[git.GitCommit]) -> list[git.GitCommit]:
82+
"""Select commits shared by bump and changelog operations.
83+
84+
Custom rules can override this method when both operations should use the
85+
same subset of commits.
86+
87+
Args:
88+
commits: The commits available to the current operation.
89+
90+
Returns:
91+
The commits that the rule considers relevant.
92+
"""
93+
return commits
94+
95+
def filter_commits_before_bump(
96+
self, commits: list[git.GitCommit]
97+
) -> list[git.GitCommit]:
98+
"""Select commits before calculating a version increment.
99+
100+
This operation-specific hook delegates to `filter_commits` so custom rules
101+
can share filtering by default or override only bump behavior.
102+
103+
Args:
104+
commits: The commits available for bump calculation.
105+
106+
Returns:
107+
The commits that should contribute to the version increment.
108+
"""
109+
return self.filter_commits(commits)
110+
111+
def filter_commits_before_changelog(
112+
self, commits: list[git.GitCommit]
113+
) -> list[git.GitCommit]:
114+
"""Select commits before generating a changelog.
115+
116+
This operation-specific hook delegates to `filter_commits` so custom rules
117+
can share filtering by default or override only changelog behavior.
118+
119+
Args:
120+
commits: The commits available for changelog generation.
121+
122+
Returns:
123+
The commits that should contribute to the changelog.
124+
"""
125+
return self.filter_commits(commits)
126+
81127
@abstractmethod
82128
def questions(self) -> list[CzQuestion]:
83129
"""Questions regarding the commit message."""

docs/customization/python_class.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,100 @@ That's it, your Commitizen now supports custom rules, and you can run.
118118
cz -n cz_strange bump
119119
```
120120

121+
### Filter commits before bump and changelog generation
122+
123+
Custom rules can select which commits are relevant before Commitizen calculates a
124+
version increment or generates a changelog.
125+
126+
| Method | Used by | Default behavior |
127+
| ----------------------------------- | ----------------------------------------------------- | ---------------------------- |
128+
| `filter_commits` | Shared by the operation-specific methods | Return all commits unchanged |
129+
| `filter_commits_before_bump` | `cz bump` and `cz version --next USE_GIT_COMMITS` | Call `filter_commits` |
130+
| `filter_commits_before_changelog` | `cz changelog`, including changelogs created by bump | Call `filter_commits` |
131+
132+
Override `filter_commits` when bump and changelog generation should use the same
133+
selection. Override an operation-specific method when their selections should
134+
differ. These methods do not affect `cz check`.
135+
136+
For example, a monorepo can use a full commit-message metadata line to associate
137+
commits with applications:
138+
139+
```text
140+
feat: add shared library
141+
142+
Applications: ['AppA', 'AppB']
143+
```
144+
145+
A plugin can retain the built-in Conventional Commits behavior while filtering
146+
on that metadata:
147+
148+
```python title="cz_applications.py"
149+
import re
150+
151+
from commitizen import git
152+
from commitizen.cz.conventional_commits import ConventionalCommitsCz
153+
from commitizen.exceptions import InvalidConfigurationError
154+
155+
156+
class ApplicationsCommitizen(ConventionalCommitsCz):
157+
"""Apply Conventional Commits rules to one configured application.
158+
159+
Example:
160+
Configure `app = "AppA"` to keep commits whose `Applications:` metadata
161+
includes `AppA`.
162+
"""
163+
164+
def filter_commits(self, commits: list[git.GitCommit]) -> list[git.GitCommit]:
165+
"""Keep commits associated with the configured application.
166+
167+
Args:
168+
commits: The commits available to the current operation.
169+
170+
Returns:
171+
Commits whose full message lists the configured application.
172+
173+
Raises:
174+
InvalidConfigurationError: If the plugin's `app` setting is missing.
175+
"""
176+
application = dict(self.config.settings).get("app")
177+
if not isinstance(application, str) or not application:
178+
raise InvalidConfigurationError(
179+
"cz_applications requires a non-empty 'app' setting"
180+
)
181+
182+
application_line = re.compile(
183+
rf"""^Applications:\s*\[[^\]]*['"]{re.escape(application)}['"][^\]]*\]\s*$""",
184+
re.MULTILINE,
185+
)
186+
return [commit for commit in commits if application_line.search(commit.message)]
187+
```
188+
189+
Expose the class through the plugin package:
190+
191+
```toml title="pyproject.toml"
192+
[project.entry-points."commitizen.plugin"]
193+
cz_applications = "cz_applications:ApplicationsCommitizen"
194+
```
195+
196+
Then select the plugin and application in each component's configuration:
197+
198+
```toml title="app-a/.cz.toml"
199+
[tool.commitizen]
200+
name = "cz_applications"
201+
app = "AppA"
202+
version = "1.0.0"
203+
tag_format = "$version-app-a"
204+
```
205+
206+
`app` is owned and interpreted by this plugin; it is not a built-in Commitizen
207+
setting. TOML keys under `[tool.commitizen]` are available through
208+
`self.config.settings`. The declarative `[tool.commitizen.customize]` section
209+
cannot override Python methods, so this use case requires a Python plugin.
210+
211+
Filtering happens before the existing rule processing. The retained commits are
212+
still interpreted by `bump_pattern` and `bump_map` for version increments and by
213+
`changelog_pattern` and `commit_parser` for changelog entries.
214+
121215
[convcomms]: https://github.com/commitizen-tools/commitizen/blob/master/commitizen/cz/conventional_commits/conventional_commits.py
122216

123217
### Custom commit validation and error message

tests/commands/test_bump_command.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import commitizen.commands.bump as bump
1313
from commitizen import cmd, defaults, git, hooks
1414
from commitizen.config.base_config import BaseConfig
15+
from commitizen.cz.conventional_commits import ConventionalCommitsCz
1516
from commitizen.exceptions import (
1617
BumpTagFailedError,
1718
CommitizenException,
@@ -69,6 +70,56 @@ def test_bump_minor_increment(commit_msg: str, util: UtilFixture):
6970
)
7071

7172

73+
@pytest.mark.usefixtures("tmp_commitizen_project")
74+
def test_bump_filters_commits_before_finding_increment(
75+
util: UtilFixture, mocker: MockFixture
76+
):
77+
"""Bump calculation considers only commits retained by the rule hook."""
78+
79+
def filter_app_a_commits(
80+
self: ConventionalCommitsCz, commits: list[git.GitCommit]
81+
) -> list[git.GitCommit]:
82+
"""Keep commits whose full message declares AppA."""
83+
return [
84+
commit
85+
for commit in commits
86+
if "'AppA'" in commit.message.partition("Applications:")[2]
87+
]
88+
89+
mocker.patch.object(
90+
ConventionalCommitsCz,
91+
"filter_commits_before_bump",
92+
filter_app_a_commits,
93+
)
94+
util.create_file_and_commit(
95+
"feat: add AppB feature\n\nApplications: ['AppB']",
96+
filename="app-b",
97+
)
98+
util.create_file_and_commit(
99+
"fix: correct shared behavior\n\nApplications: ['AppA', 'AppB']",
100+
filename="app-a",
101+
)
102+
103+
util.run_cli("bump", "--yes")
104+
105+
assert git.tag_exist("0.1.1") is True
106+
assert git.tag_exist("0.2.0") is False
107+
108+
109+
@pytest.mark.usefixtures("tmp_commitizen_project")
110+
def test_bump_handles_all_commits_filtered_out(util: UtilFixture, mocker: MockFixture):
111+
"""An empty filtered selection follows existing no-increment handling."""
112+
mocker.patch.object(
113+
ConventionalCommitsCz,
114+
"filter_commits_before_bump",
115+
return_value=[],
116+
)
117+
util.create_file_and_commit("feat: add an unrelated feature")
118+
119+
with pytest.raises(NoneIncrementExit):
120+
util.run_cli("bump", "--yes")
121+
122+
72123
@pytest.mark.parametrize("commit_msg", ["feat: new file", "feat(user): new file"])
73124
@pytest.mark.usefixtures("tmp_commitizen_project")
74125
def test_bump_minor_increment_annotated(commit_msg: str, util: UtilFixture):

tests/commands/test_changelog_command.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from commitizen import git
1212
from commitizen.commands.changelog import Changelog
13+
from commitizen.cz.conventional_commits import ConventionalCommitsCz
1314
from commitizen.exceptions import (
1415
DryRunExit,
1516
InvalidCommandArgumentError,
@@ -87,6 +88,46 @@ def test_changelog_with_different_cz(
8788
file_regression.check(out, extension=".md")
8889

8990

91+
@pytest.mark.usefixtures("tmp_commitizen_project")
92+
def test_changelog_filters_commits_before_generating_tree(
93+
capsys: pytest.CaptureFixture,
94+
util: UtilFixture,
95+
mocker: MockFixture,
96+
):
97+
"""Changelog generation excludes commits rejected by the rule hook."""
98+
99+
def filter_app_a_commits(
100+
self: ConventionalCommitsCz, commits: list[git.GitCommit]
101+
) -> list[git.GitCommit]:
102+
"""Keep commits whose full message declares AppA."""
103+
return [
104+
commit
105+
for commit in commits
106+
if "'AppA'" in commit.message.partition("Applications:")[2]
107+
]
108+
109+
mocker.patch.object(
110+
ConventionalCommitsCz,
111+
"filter_commits_before_changelog",
112+
filter_app_a_commits,
113+
)
114+
util.create_file_and_commit(
115+
"feat: add AppB feature\n\nApplications: ['AppB']",
116+
filename="app-b",
117+
)
118+
util.create_file_and_commit(
119+
"fix: correct shared behavior\n\nApplications: ['AppA', 'AppB']",
120+
filename="app-a",
121+
)
122+
123+
with pytest.raises(DryRunExit):
124+
util.run_cli("changelog", "--dry-run")
125+
126+
out, _ = capsys.readouterr()
127+
assert "correct shared behavior" in out
128+
assert "add AppB feature" not in out
129+
130+
90131
@pytest.mark.usefixtures("tmp_commitizen_project")
91132
def test_changelog_from_start(
92133
changelog_format: ChangelogFormat,

tests/commands/test_version_command.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
import pytest
55
from pytest_mock import MockerFixture
66

7-
from commitizen import commands
7+
from commitizen import commands, git
88
from commitizen.__version__ import __version__
99
from commitizen.config.base_config import BaseConfig
1010
from commitizen.cz.base import BaseCommitizen
11+
from commitizen.cz.conventional_commits import ConventionalCommitsCz
1112
from commitizen.exceptions import (
1213
NoCommitsFoundError,
1314
NoPatternMapError,
@@ -321,6 +322,51 @@ def test_version_next_use_git_commits(
321322
assert captured.out == f"{expected_version}\n"
322323

323324

325+
@pytest.mark.usefixtures("tmp_git_project")
326+
def test_version_next_use_git_commits_filters_before_finding_increment(
327+
config: BaseConfig,
328+
capsys: pytest.CaptureFixture,
329+
util: UtilFixture,
330+
mocker: MockerFixture,
331+
):
332+
"""Commit-derived versions use the same filtering hook as bump."""
333+
334+
def filter_app_a_commits(
335+
self: ConventionalCommitsCz, commits: list[git.GitCommit]
336+
) -> list[git.GitCommit]:
337+
"""Keep commits whose full message declares AppA."""
338+
return [
339+
commit
340+
for commit in commits
341+
if "'AppA'" in commit.message.partition("Applications:")[2]
342+
]
343+
344+
mocker.patch.object(
345+
ConventionalCommitsCz,
346+
"filter_commits_before_bump",
347+
filter_app_a_commits,
348+
)
349+
config.settings["version"] = "1.0.0"
350+
util.create_file_and_commit("feat: initial commit")
351+
util.create_tag("1.0.0")
352+
util.create_file_and_commit(
353+
"feat: add AppB feature\n\nApplications: ['AppB']",
354+
filename="app-b",
355+
)
356+
util.create_file_and_commit(
357+
"fix: correct shared behavior\n\nApplications: ['AppA', 'AppB']",
358+
filename="app-a",
359+
)
360+
361+
commands.Version(
362+
config,
363+
{"project": True, "next": "USE_GIT_COMMITS"},
364+
)()
365+
366+
captured = capsys.readouterr()
367+
assert captured.out == "1.0.1\n"
368+
369+
324370
@pytest.mark.usefixtures("tmp_git_project")
325371
def test_version_next_use_git_commits_manual_version(
326372
config: BaseConfig, capsys: pytest.CaptureFixture, util: UtilFixture

0 commit comments

Comments
 (0)