diff --git a/commitizen/bump.py b/commitizen/bump.py index 030c8f1e5b..2b86f84f8f 100644 --- a/commitizen/bump.py +++ b/commitizen/bump.py @@ -62,6 +62,17 @@ def find_increment( return cast("Increment", increment) +def filter_commits(commits: list[GitCommit], pattern: str) -> list[GitCommit]: + """Filter commits before applying bump-pattern matching. + + This keeps the existing line-by-line ``bump_pattern`` behavior intact while + allowing repository-specific configuration to exclude unrelated commits from + the bump calculation entirely. + """ + select_pattern = re.compile(pattern) + return [commit for commit in commits if select_pattern.match(commit.message)] + + def update_version_in_files( current_version: str, new_version: str, diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py index 0b6e0ffa36..63853695d6 100644 --- a/commitizen/commands/bump.py +++ b/commitizen/commands/bump.py @@ -6,7 +6,7 @@ import questionary -from commitizen import bump, factory, git, hooks, out +from commitizen import bump, defaults, factory, git, hooks, out from commitizen.changelog_formats import get_changelog_format from commitizen.commands.changelog import Changelog from commitizen.defaults import Settings @@ -158,7 +158,18 @@ 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) + default_filter_pattern = defaults.DEFAULT_SETTINGS["bump_commit_filter_pattern"] + bump_commit_filter_pattern = self.cz.bump_commit_filter_pattern + if bump_commit_filter_pattern in (None, default_filter_pattern): + bump_commit_filter_pattern = self.config.settings.get( + "bump_commit_filter_pattern" + ) + if bump_commit_filter_pattern is None: + bump_commit_filter_pattern = default_filter_pattern + filtered_commits = bump.filter_commits(commits, bump_commit_filter_pattern) + return bump.find_increment( + filtered_commits, regex=bump_pattern, increments_map=bump_map + ) def _validate_arguments(self, current_version: VersionProtocol) -> None: errors: list[str] = [] diff --git a/commitizen/commands/version.py b/commitizen/commands/version.py index 976b9c04a9..f3e8bd0343 100644 --- a/commitizen/commands/version.py +++ b/commitizen/commands/version.py @@ -4,7 +4,7 @@ from packaging.version import InvalidVersion -from commitizen import bump, factory, git, out +from commitizen import bump, defaults, factory, git, out from commitizen.__version__ import __version__ from commitizen.config import BaseConfig from commitizen.exceptions import ( @@ -180,8 +180,17 @@ def _get_next_git_version( raise NoPatternMapError( f"'{self.config.settings['name']}' rule does not support bump" ) + default_filter_pattern = defaults.DEFAULT_SETTINGS["bump_commit_filter_pattern"] + bump_commit_filter_pattern = self.cz.bump_commit_filter_pattern + if bump_commit_filter_pattern in (None, default_filter_pattern): + bump_commit_filter_pattern = self.config.settings.get( + "bump_commit_filter_pattern" + ) + if bump_commit_filter_pattern is None: + bump_commit_filter_pattern = default_filter_pattern + filtered_commits = bump.filter_commits(commits, bump_commit_filter_pattern) increment = bump.find_increment( - commits, regex=bump_pattern, increments_map=bump_map + filtered_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..34f4ddaadb 100644 --- a/commitizen/cz/base.py +++ b/commitizen/cz/base.py @@ -36,6 +36,7 @@ class ValidationResult(NamedTuple): class BaseCommitizen(metaclass=ABCMeta): + bump_commit_filter_pattern: str | None = None bump_pattern: str | None = None bump_map: dict[str, str] | None = None bump_map_major_version_zero: dict[str, str] | None = None diff --git a/commitizen/cz/conventional_commits/conventional_commits.py b/commitizen/cz/conventional_commits/conventional_commits.py index 31c329595a..ef7c394bf2 100644 --- a/commitizen/cz/conventional_commits/conventional_commits.py +++ b/commitizen/cz/conventional_commits/conventional_commits.py @@ -31,6 +31,7 @@ class ConventionalCommitsAnswers(TypedDict): class ConventionalCommitsCz(BaseCommitizen): + bump_commit_filter_pattern = defaults.DEFAULT_SETTINGS["bump_commit_filter_pattern"] bump_pattern = defaults.BUMP_PATTERN bump_map = defaults.BUMP_MAP bump_map_major_version_zero = defaults.BUMP_MAP_MAJOR_VERSION_ZERO diff --git a/commitizen/cz/customize/customize.py b/commitizen/cz/customize/customize.py index 8f8857d210..adad2c462b 100644 --- a/commitizen/cz/customize/customize.py +++ b/commitizen/cz/customize/customize.py @@ -44,6 +44,7 @@ def _derive_major_version_zero( class CustomizeCommitsCz(BaseCommitizen): + bump_commit_filter_pattern = defaults.DEFAULT_SETTINGS["bump_commit_filter_pattern"] bump_pattern = defaults.BUMP_PATTERN bump_map = defaults.BUMP_MAP bump_map_major_version_zero = defaults.BUMP_MAP_MAJOR_VERSION_ZERO @@ -57,6 +58,7 @@ def __init__(self, config: BaseConfig) -> None: self.custom_settings = self.config.settings["customize"] for attr_name in [ + "bump_commit_filter_pattern", "bump_pattern", "bump_map", "bump_map_major_version_zero", diff --git a/commitizen/defaults.py b/commitizen/defaults.py index 93bb835a38..15c6742a01 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -12,6 +12,7 @@ class CzSettings(TypedDict, total=False): + bump_commit_filter_pattern: str bump_pattern: str bump_map: OrderedDict[str, str] bump_map_major_version_zero: OrderedDict[str, str] @@ -34,6 +35,7 @@ class Settings(TypedDict, total=False): allowed_prefixes: list[str] always_signoff: bool annotated_tag: bool + bump_commit_filter_pattern: str bump_message: str | None change_type_map: dict[str, str] changelog_file: str @@ -88,6 +90,7 @@ class Settings(TypedDict, total=False): "tag_format": "$version", # example v$version "legacy_tag_formats": [], "ignored_tag_formats": [], + "bump_commit_filter_pattern": r".*", "bump_message": None, # bumped v$current_version to $new_version "retry_after_failure": False, "allow_abort": False, diff --git a/docs/config/bump.md b/docs/config/bump.md index 7263f8066e..26cf045314 100644 --- a/docs/config/bump.md +++ b/docs/config/bump.md @@ -11,6 +11,22 @@ When set to `true`, `cz bump` is equivalent to `cz bump --annotated-tag`. annotated_tag = true ``` +## `bump_commit_filter_pattern` + +- Type: `str` +- Default: `".*"` + +Regular expression used to decide which commits `cz bump` should consider +before applying the commit rule's `bump_pattern`. + +This is useful in monorepos where a component should ignore commits that belong +to other applications or packages. + +```toml title="pyproject.toml" +[tool.commitizen] +bump_commit_filter_pattern = "^(feat|fix)\\(library-b\\)(!)?:" +``` + ## `bump_message` Template used to specify the commit message generated when bumping. diff --git a/docs/config/configuration_file.md b/docs/config/configuration_file.md index 172cbce1a3..5d8c87bd18 100644 --- a/docs/config/configuration_file.md +++ b/docs/config/configuration_file.md @@ -68,6 +68,7 @@ All formats support the same configuration options. Choose the format that best update_changelog_on_bump = true changelog_file = "CHANGELOG.md" changelog_incremental = false + bump_commit_filter_pattern = ".*" bump_message = "bump: version $current_version → $new_version" gpg_sign = false annotated_tag = false @@ -126,6 +127,7 @@ All formats support the same configuration options. Choose the format that best "update_changelog_on_bump": true, "changelog_file": "CHANGELOG.md", "changelog_incremental": false, + "bump_commit_filter_pattern": ".*", "bump_message": "bump: version $current_version → $new_version", "gpg_sign": false, "annotated_tag": false, @@ -182,6 +184,7 @@ All formats support the same configuration options. Choose the format that best update_changelog_on_bump: true changelog_file: CHANGELOG.md changelog_incremental: false + bump_commit_filter_pattern: ".*" bump_message: "bump: version $current_version → $new_version" gpg_sign: false annotated_tag: false @@ -235,7 +238,7 @@ Key configuration categories include: - **Version Management**: `version`, `version_provider`, `version_scheme`, `version_files` - **Tagging**: `tag_format`, `legacy_tag_formats`, `ignored_tag_formats`, `gpg_sign`, `annotated_tag` - **Changelog**: `changelog_file`, `changelog_format`, `changelog_incremental`, `update_changelog_on_bump` -- **Bumping**: `bump_message`, `major_version_zero`, `prerelease_offset`, `pre_bump_hooks`, `post_bump_hooks` +- **Bumping**: `bump_commit_filter_pattern`, `bump_message`, `major_version_zero`, `prerelease_offset`, `pre_bump_hooks`, `post_bump_hooks` - **Commit Validation**: `allowed_prefixes`, `message_length_limit`, `allow_abort`, `retry_after_failure` - **Customization**: `customize`, `style`, `use_shortcuts`, `template`, `extras` diff --git a/docs/tutorials/monorepo_guidance.md b/docs/tutorials/monorepo_guidance.md index f863a9e531..33e5d249cb 100644 --- a/docs/tutorials/monorepo_guidance.md +++ b/docs/tutorials/monorepo_guidance.md @@ -34,6 +34,7 @@ Here is a step-by-step example using two libraries, `library-b` and `library-z`: version = "0.0.0" tag_format = "${version}-library-b" # the component name can be a prefix or suffix with or without a separator ignored_tag_formats = ["${version}-library-*"] # Avoid noise from other tags + bump_commit_filter_pattern = "^(feat|fix)\\(library-b\\)(!)?:" update_changelog_on_bump = true ``` @@ -44,6 +45,7 @@ Here is a step-by-step example using two libraries, `library-b` and `library-z`: version = "0.0.0" tag_format = "${version}-library-z" ignored_tag_formats = ["${version}-library-*"] # Avoid noise from other tags + bump_commit_filter_pattern = "^(feat|fix)\\(library-z\\)(!)?:" update_changelog_on_bump = true ``` @@ -55,7 +57,7 @@ Here is a step-by-step example using two libraries, `library-b` and `library-z`: ``` -## Changelog per component +## Bump and changelog per component To filter the correct commits for each component, you'll need to define a strategy. @@ -70,8 +72,9 @@ For example: ### Example with scope in conventional commits -In this example, we want `library-b`'s changelog to only include commits that use the `library-b` scope. -To achieve this, we configure Commitizen to match only commit messages with that scope. +In this example, we want `library-b`'s bump calculation and changelog to only +include commits that use the `library-b` scope. To achieve this, we configure +Commitizen to match only commit messages with that scope. Here is an example configuration for `library-b`: diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index b26f095c9f..eb78f83a59 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -4,7 +4,7 @@ import re from pathlib import Path from textwrap import dedent -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from unittest.mock import call import pytest @@ -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,88 @@ def test_bump_minor_increment(commit_msg: str, util: UtilFixture): ) +def test_bump_commit_filter_pattern_ignores_unrelated_commits( + tmp_commitizen_project_initial, util: UtilFixture +): + config_extra = ( + 'bump_commit_filter_pattern = "^(feat|fix)\\\\(library-b\\\\)(!)?:"\n' + ) + tmp_commitizen_project_initial( + config_extra=config_extra, initial_commit="feat(library-a): add initial feature" + ) + util.create_file_and_commit("fix(library-b): patch release issue") + + util.run_cli("bump", "--yes") + + assert git.tag_exist("0.1.1") is True + assert git.tag_exist("0.2.0") is False + + +def test_bump_commit_filter_pattern_excludes_all_commits( + tmp_commitizen_project_initial, util: UtilFixture +): + config_extra = ( + 'bump_commit_filter_pattern = "^(feat|fix)\\\\(library-b\\\\)(!)?:"\n' + ) + tmp_commitizen_project_initial( + config_extra=config_extra, initial_commit="feat(library-a): add initial feature" + ) + + with pytest.raises( + NoneIncrementExit, + match=r"\[NO_COMMITS_TO_BUMP\]\nThe commits found are not eligible to be bumped", + ): + util.run_cli("bump", "--yes") + + +def test_bump_commit_filter_pattern_defaults_when_not_configured( + tmp_commitizen_project_initial, + util: UtilFixture, + config: BaseConfig, + mocker: MockFixture, +): + tmp_commitizen_project_initial() + config.settings["version"] = "0.1.0" + config.settings["bump_commit_filter_pattern"] = None # type: ignore[typeddict-item] + conventional_commits_cz = ConventionalCommitsCz(config) + conventional_commits_cz.bump_commit_filter_pattern = None # type: ignore[assignment] + mocker.patch( + "commitizen.commands.bump.factory.committer_factory", + return_value=conventional_commits_cz, + ) + util.create_file_and_commit("feat: initial commit") + util.create_tag("0.1.0") + util.create_file_and_commit("fix: patch release issue") + + arguments = cast( + "bump.BumpArgs", + { + "changelog": False, + "changelog_to_stdout": False, + "check_consistency": False, + "dry_run": False, + "extras": None, + "file_name": None, + "files_only": False, + "get_next": False, + "git_output_to_stderr": False, + "local_version": False, + "no_verify": False, + "retry": False, + "template": None, + "version_scheme": None, + "version_files_only": False, + "yes": True, + }, + ) + bump_command = bump.Bump(config, arguments) + + commits = git.get_commits("0.1.0") + increment = bump_command._find_increment(commits) + + assert increment == "PATCH" + + @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): @@ -1214,6 +1297,23 @@ def test_bump_get_next(util: UtilFixture, capsys: pytest.CaptureFixture): assert git.tag_exist("0.2.0") is False +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_get_next_warns_when_changelog_flag_is_set( + util: UtilFixture, capsys: pytest.CaptureFixture +): + util.create_file_and_commit("feat: new file") + + with pytest.warns( + UserWarning, match="--changelog has no effect when used with --get-next" + ): + with pytest.raises(DryRunExit): + util.run_cli("bump", "--yes", "--get-next", "--changelog") + + out, _ = capsys.readouterr() + assert "0.2.0" in out + assert git.tag_exist("0.2.0") is False + + @pytest.mark.usefixtures("tmp_commitizen_project") def test_bump_get_next_update_changelog_on_bump( util: UtilFixture, capsys: pytest.CaptureFixture, config_path: Path diff --git a/tests/commands/test_version_command.py b/tests/commands/test_version_command.py index b86df045c1..57d9ab5f1a 100644 --- a/tests/commands/test_version_command.py +++ b/tests/commands/test_version_command.py @@ -8,6 +8,7 @@ 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, @@ -377,6 +378,26 @@ def test_version_next_use_git_commits_major_version_zero( assert captured.out == "0.2.0\n" +@pytest.mark.usefixtures("tmp_git_project") +def test_version_next_use_git_commits_respects_bump_commit_filter_pattern( + config: BaseConfig, capsys: pytest.CaptureFixture, util: UtilFixture +): + config.settings["version"] = "1.0.0" + config.settings["bump_commit_filter_pattern"] = r"^fix\(library-b\):" + util.create_file_and_commit("feat: initial commit") + util.create_tag("1.0.0") + util.create_file_and_commit("feat(library-a): new feature") + util.create_file_and_commit("fix(library-b): patch release issue") + + 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_prerelease_without_commits( config: BaseConfig, capsys: pytest.CaptureFixture, util: UtilFixture @@ -395,6 +416,34 @@ def test_version_next_use_git_commits_prerelease_without_commits( assert captured.out == "1.0.0\n" +@pytest.mark.usefixtures("tmp_git_project") +def test_version_next_use_git_commits_defaults_filter_when_not_configured( + config: BaseConfig, + capsys: pytest.CaptureFixture, + util: UtilFixture, + mocker: MockerFixture, +): + config.settings["version"] = "1.0.0" + config.settings["bump_commit_filter_pattern"] = None # type: ignore[typeddict-item] + conventional_commits_cz = ConventionalCommitsCz(config) + conventional_commits_cz.bump_commit_filter_pattern = None # type: ignore[assignment] + mocker.patch( + "commitizen.factory.committer_factory", + return_value=conventional_commits_cz, + ) + util.create_file_and_commit("feat: initial commit") + util.create_tag("1.0.0") + util.create_file_and_commit("fix: a bug") + + 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_no_commits_raises( config: BaseConfig, util: UtilFixture diff --git a/tests/test_bump_find_increment.py b/tests/test_bump_find_increment.py index 8209278ed5..8104d672ac 100644 --- a/tests/test_bump_find_increment.py +++ b/tests/test_bump_find_increment.py @@ -122,3 +122,15 @@ def test_find_increment_sve(messages, expected_type): commits, regex=semantic_version_pattern, increments_map=semantic_version_map ) assert increment_type == expected_type + + +def test_filter_commits(): + commits = [ + GitCommit(rev="1", title="feat(library-a): add command"), + GitCommit(rev="2", title="fix(library-b): patch regression"), + GitCommit(rev="3", title="docs: update README"), + ] + + filtered_commits = bump.filter_commits(commits, r"^(feat|fix)\(library-b\)(!)?:") + + assert filtered_commits == [commits[1]] diff --git a/tests/test_conf.py b/tests/test_conf.py index 15be0630aa..2582f9e88f 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -83,6 +83,7 @@ "tag_format": "$version", "legacy_tag_formats": [], "ignored_tag_formats": [], + "bump_commit_filter_pattern": ".*", "bump_message": None, "retry_after_failure": False, "allow_abort": False, @@ -124,6 +125,7 @@ "tag_format": "$version", "legacy_tag_formats": [], "ignored_tag_formats": [], + "bump_commit_filter_pattern": ".*", "bump_message": None, "retry_after_failure": False, "allow_abort": False, diff --git a/tests/test_cz_customize.py b/tests/test_cz_customize.py index 726177247b..f9faa0a14d 100644 --- a/tests/test_cz_customize.py +++ b/tests/test_cz_customize.py @@ -562,6 +562,14 @@ def test_questions_unicode(config_with_unicode): assert list(questions) == expected_questions +def test_bump_commit_filter_pattern_sets_customize_attribute(config): + config.settings["customize"]["bump_commit_filter_pattern"] = r"^fix\(foo\):" + + cz = CustomizeCommitsCz(config) + + assert cz.bump_commit_filter_pattern == r"^fix\(foo\):" + + def test_answer(config): cz = CustomizeCommitsCz(config) answers = {