Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion commitizen/commands/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down
1 change: 1 addition & 0 deletions commitizen/commands/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion commitizen/commands/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
46 changes: 46 additions & 0 deletions commitizen/cz/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
94 changes: 94 additions & 0 deletions docs/customization/python_class.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions tests/commands/test_bump_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
41 changes: 41 additions & 0 deletions tests/commands/test_changelog_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
48 changes: 47 additions & 1 deletion tests/commands/test_version_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading