From 67dc6d59db4d132c1fd65526246b9d473631cc50 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Thu, 1 Oct 2026 00:27:16 +0800 Subject: [PATCH 1/5] refactor(bump): replace find_increment with VersionIncrement max --- commitizen/bump.py | 54 +-------- commitizen/commands/bump.py | 15 ++- commitizen/commands/version.py | 19 +++- commitizen/version_increment.py | 56 +++++++++ tests/test_bump_find_increment.py | 124 -------------------- tests/test_version_increment.py | 182 ++++++++++++++++++++++++++++++ 6 files changed, 266 insertions(+), 184 deletions(-) delete mode 100644 tests/test_bump_find_increment.py diff --git a/commitizen/bump.py b/commitizen/bump.py index 030c8f1e5b..a858535826 100644 --- a/commitizen/bump.py +++ b/commitizen/bump.py @@ -2,64 +2,18 @@ import os import re -from collections import OrderedDict from glob import iglob -from logging import getLogger from string import Template -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING -from commitizen.defaults import BUMP_MESSAGE, MAJOR, MINOR, PATCH +from commitizen.defaults import BUMP_MESSAGE from commitizen.exceptions import CurrentVersionNotFoundError -from commitizen.git import GitCommit, smart_open +from commitizen.git import smart_open if TYPE_CHECKING: from collections.abc import Generator, Iterable - from commitizen.version_schemes import Increment, VersionProtocol - -VERSION_TYPES = [None, PATCH, MINOR, MAJOR] - -logger = getLogger("commitizen") - - -def find_increment( - commits: list[GitCommit], regex: str, increments_map: dict | OrderedDict -) -> Increment | None: - if isinstance(increments_map, dict): - increments_map = OrderedDict(increments_map) - - # Most important cases are major and minor. - # Everything else will be considered patch. - select_pattern = re.compile(regex) - increment: str | None = None - - for commit in commits: - for message in commit.message.split("\n"): - result = select_pattern.search(message) - - if result: - found_keyword = result.group(1) - new_increment = None - for match_pattern in increments_map.keys(): - if re.match(match_pattern, found_keyword): - new_increment = increments_map[match_pattern] - break - - if new_increment is None: - logger.debug( - f"no increment needed for '{found_keyword}' in '{message}'" - ) - - if VERSION_TYPES.index(increment) < VERSION_TYPES.index(new_increment): - logger.debug( - f"increment detected is '{new_increment}' due to '{found_keyword}' in '{message}'" - ) - increment = new_increment - - if increment == MAJOR: - break - - return cast("Increment", increment) + from commitizen.version_schemes import VersionProtocol def update_version_in_files( diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py index a44ba9eddb..0122b9f1cd 100644 --- a/commitizen/commands/bump.py +++ b/commitizen/commands/bump.py @@ -24,6 +24,7 @@ ) from commitizen.providers import get_provider from commitizen.tags import TagRules +from commitizen.version_increment import VersionIncrement from commitizen.version_schemes import ( Increment, InvalidVersion, @@ -145,8 +146,8 @@ def _is_initial_tag( return bool(questionary.confirm("Is this the first tag created?").ask()) def _find_increment(self, commits: list[git.GitCommit]) -> Increment | None: + """Determine the highest configured increment across candidate commits.""" # Update the bump map to ensure major version doesn't increment. - # self.cz.bump_map = defaults.bump_map_major_version_zero bump_map = ( self.cz.bump_map_major_version_zero if self.bump_settings["major_version_zero"] @@ -158,11 +159,15 @@ 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( - self.cz.filter_commits_before_bump(commits), - regex=bump_pattern, - increments_map=bump_map, + + increment = VersionIncrement.get_highest_by_messages( + (commit.message for commit in self.cz.filter_commits_before_bump(commits)), + bump_pattern, + bump_map, ) + if increment == VersionIncrement.NONE: + return None + return cast("Increment", str(increment)) def _validate_arguments(self, current_version: VersionProtocol) -> None: errors: list[str] = [] diff --git a/commitizen/commands/version.py b/commitizen/commands/version.py index e8b8ea8e34..b1c338319a 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 factory, git, out from commitizen.__version__ import __version__ from commitizen.config import BaseConfig from commitizen.exceptions import ( @@ -180,11 +180,20 @@ def _get_next_git_version( raise NoPatternMapError( f"'{self.config.settings['name']}' rule does not support bump" ) - increment = bump.find_increment( - self.cz.filter_commits_before_bump(commits), - regex=bump_pattern, - increments_map=bump_map, + next_increment = VersionIncrement.get_highest_by_messages( + (commit.message for commit in self.cz.filter_commits_before_bump(commits)), + bump_pattern, + bump_map, ) + increment: Increment | None + if next_increment == VersionIncrement.NONE: + increment = None + elif next_increment == VersionIncrement.PATCH: + increment = "PATCH" + elif next_increment == VersionIncrement.MINOR: + increment = "MINOR" + else: + increment = "MAJOR" # TODO: Consider adding all the parameters `.bump` supports: # prerelease, prerelease_offset,exact_increment, etc.. diff --git a/commitizen/version_increment.py b/commitizen/version_increment.py index 9320fe8f90..c90670f0ab 100644 --- a/commitizen/version_increment.py +++ b/commitizen/version_increment.py @@ -1,6 +1,11 @@ from __future__ import annotations +import re from enum import IntEnum +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping class VersionIncrement(IntEnum): @@ -21,13 +26,64 @@ class VersionIncrement(IntEnum): MAJOR = 3 def __str__(self) -> str: + """Return the legacy string representation used by existing callers.""" return self.name @classmethod def from_value(cls, value: object) -> VersionIncrement: + """Convert a possibly-invalid external value into a known increment.""" if not isinstance(value, str): return VersionIncrement.NONE try: return cls[value] except KeyError: return VersionIncrement.NONE + + @classmethod + def from_match( + cls, matched_text: str, increments_map: Mapping[str, object] + ) -> VersionIncrement: + """Resolve one extracted bump token against a configured bump map. + + The first regular-expression key that matches wins, preserving the + current user-visible behavior of ``bump_map`` ordering. + """ + for match_pattern, increment in increments_map.items(): + if re.match(match_pattern, matched_text): + return cls.from_value(increment) + return cls.NONE + + @classmethod + def from_message( + cls, message: str, regex: str, increments_map: Mapping[str, object] + ) -> VersionIncrement: + """Compute the highest increment contributed by one commit message. + + Commitizen historically evaluates each line of the full commit message + against ``bump_pattern`` and then keeps the highest resulting bump. + This method preserves that behavior while returning a comparable enum. + """ + select_pattern = re.compile(regex) + increments = [] + + for line in message.split("\n"): + result = select_pattern.search(line) + if result: + increments.append(cls.from_match(result.group(1), increments_map)) + + return max(increments, default=cls.NONE) + + @classmethod + def get_highest_by_messages( + cls, messages: Iterable[str], regex: str, increments_map: Mapping[str, object] + ) -> VersionIncrement: + """Compute the highest increment across many commit messages. + + An empty iterable yields ``NONE`` so callers can preserve the existing + "no increment" behavior explicitly instead of relying on exceptions from + ``max()``. + """ + return max( + (cls.from_message(message, regex, increments_map) for message in messages), + default=cls.NONE, + ) diff --git a/tests/test_bump_find_increment.py b/tests/test_bump_find_increment.py deleted file mode 100644 index 8209278ed5..0000000000 --- a/tests/test_bump_find_increment.py +++ /dev/null @@ -1,124 +0,0 @@ -""" -CC: Conventional commits -SVE: Semantic version at the end -""" - -import pytest - -from commitizen import bump -from commitizen.cz.conventional_commits import ConventionalCommitsCz -from commitizen.git import GitCommit - -NONE_INCREMENT_CC = [ - "docs(README): motivation", - "ci: added travis", - "performance. Remove or disable the reimplemented linters", - "refactor that how this line starts", -] - -PATCH_INCREMENTS_CC = [ - "fix(setup.py): future is now required for every python version", - "docs(README): motivation", -] - -MINOR_INCREMENTS_CC = [ - "feat(cli): added version", - "docs(README): motivation", - "fix(setup.py): future is now required for every python version", - "perf: app is much faster", - "refactor: app is much faster", -] - -MAJOR_INCREMENTS_BREAKING_CHANGE_CC = [ - "feat(cli): added version", - "docs(README): motivation", - "BREAKING CHANGE: `extends` key in config file is now used for extending other config files", - "fix(setup.py): future is now required for every python version", -] - -MAJOR_INCREMENTS_BREAKING_CHANGE_ALT_CC = [ - "feat(cli): added version", - "docs(README): motivation", - "BREAKING-CHANGE: `extends` key in config file is now used for extending other config files", - "fix(setup.py): future is now required for every python version", -] - -MAJOR_INCREMENTS_EXCLAMATION_CC = [ - "feat(cli)!: added version", - "docs(README): motivation", - "fix(setup.py): future is now required for every python version", -] - -MAJOR_INCREMENTS_EXCLAMATION_CC_SAMPLE_2 = [ - "feat(pipeline)!: some text with breaking change" -] - -MAJOR_INCREMENTS_EXCLAMATION_OTHER_TYPE_CC = [ - "chore!: drop support for Python 3.9", - "docs(README): motivation", - "fix(setup.py): future is now required for every python version", -] - -MAJOR_INCREMENTS_EXCLAMATION_OTHER_TYPE_WITH_SCOPE_CC = [ - "chore(deps)!: drop support for Python 3.9", - "docs(README): motivation", - "fix(setup.py): future is now required for every python version", -] - -PATCH_INCREMENTS_SVE = ["readme motivation PATCH", "fix setup.py PATCH"] - -MINOR_INCREMENTS_SVE = [ - "readme motivation PATCH", - "fix setup.py PATCH", - "added version to cli MINOR", -] - -MAJOR_INCREMENTS_SVE = [ - "readme motivation PATCH", - "fix setup.py PATCH", - "added version to cli MINOR", - "extends key is used for other config files MAJOR", -] - -semantic_version_pattern = r"(MAJOR|MINOR|PATCH)" -semantic_version_map = {"MAJOR": "MAJOR", "MINOR": "MINOR", "PATCH": "PATCH"} - - -@pytest.mark.parametrize( - ("messages", "expected_type"), - [ - (PATCH_INCREMENTS_CC, "PATCH"), - (MINOR_INCREMENTS_CC, "MINOR"), - (MAJOR_INCREMENTS_BREAKING_CHANGE_CC, "MAJOR"), - (MAJOR_INCREMENTS_BREAKING_CHANGE_ALT_CC, "MAJOR"), - (MAJOR_INCREMENTS_EXCLAMATION_OTHER_TYPE_CC, "MAJOR"), - (MAJOR_INCREMENTS_EXCLAMATION_OTHER_TYPE_WITH_SCOPE_CC, "MAJOR"), - (MAJOR_INCREMENTS_EXCLAMATION_CC, "MAJOR"), - (MAJOR_INCREMENTS_EXCLAMATION_CC_SAMPLE_2, "MAJOR"), - (NONE_INCREMENT_CC, None), - ], -) -def test_find_increment(messages, expected_type): - commits = [GitCommit(rev="test", title=message) for message in messages] - increment_type = bump.find_increment( - commits, - regex=ConventionalCommitsCz.bump_pattern, - increments_map=ConventionalCommitsCz.bump_map, - ) - assert increment_type == expected_type - - -@pytest.mark.parametrize( - ("messages", "expected_type"), - [ - (PATCH_INCREMENTS_SVE, "PATCH"), - (MINOR_INCREMENTS_SVE, "MINOR"), - (MAJOR_INCREMENTS_SVE, "MAJOR"), - ], -) -def test_find_increment_sve(messages, expected_type): - commits = [GitCommit(rev="test", title=message) for message in messages] - increment_type = bump.find_increment( - commits, regex=semantic_version_pattern, increments_map=semantic_version_map - ) - assert increment_type == expected_type diff --git a/tests/test_version_increment.py b/tests/test_version_increment.py index 5832140a81..c9e9b4255b 100644 --- a/tests/test_version_increment.py +++ b/tests/test_version_increment.py @@ -1,7 +1,84 @@ +"""Tests for semantic-version increment ordering and extraction helpers.""" + import pytest +from commitizen.cz.conventional_commits import ConventionalCommitsCz from commitizen.version_increment import VersionIncrement +NONE_INCREMENT_CC = [ + "docs(README): motivation", + "ci: added travis", + "performance. Remove or disable the reimplemented linters", + "refactor that how this line starts", +] + +PATCH_INCREMENTS_CC = [ + "fix(setup.py): future is now required for every python version", + "docs(README): motivation", +] + +MINOR_INCREMENTS_CC = [ + "feat(cli): added version", + "docs(README): motivation", + "fix(setup.py): future is now required for every python version", + "perf: app is much faster", + "refactor: app is much faster", +] + +MAJOR_INCREMENTS_BREAKING_CHANGE_CC = [ + "feat(cli): added version", + "docs(README): motivation", + "BREAKING CHANGE: `extends` key in config file is now used for extending other config files", + "fix(setup.py): future is now required for every python version", +] + +MAJOR_INCREMENTS_BREAKING_CHANGE_ALT_CC = [ + "feat(cli): added version", + "docs(README): motivation", + "BREAKING-CHANGE: `extends` key in config file is now used for extending other config files", + "fix(setup.py): future is now required for every python version", +] + +MAJOR_INCREMENTS_EXCLAMATION_CC = [ + "feat(cli)!: added version", + "docs(README): motivation", + "fix(setup.py): future is now required for every python version", +] + +MAJOR_INCREMENTS_EXCLAMATION_CC_SAMPLE_2 = [ + "feat(pipeline)!: some text with breaking change" +] + +MAJOR_INCREMENTS_EXCLAMATION_OTHER_TYPE_CC = [ + "chore!: drop support for Python 3.9", + "docs(README): motivation", + "fix(setup.py): future is now required for every python version", +] + +MAJOR_INCREMENTS_EXCLAMATION_OTHER_TYPE_WITH_SCOPE_CC = [ + "chore(deps)!: drop support for Python 3.9", + "docs(README): motivation", + "fix(setup.py): future is now required for every python version", +] + +PATCH_INCREMENTS_SVE = ["readme motivation PATCH", "fix setup.py PATCH"] + +MINOR_INCREMENTS_SVE = [ + "readme motivation PATCH", + "fix setup.py PATCH", + "added version to cli MINOR", +] + +MAJOR_INCREMENTS_SVE = [ + "readme motivation PATCH", + "fix setup.py PATCH", + "added version to cli MINOR", + "extends key is used for other config files MAJOR", +] + +SEMANTIC_VERSION_PATTERN = r"(MAJOR|MINOR|PATCH)" +SEMANTIC_VERSION_MAP = {"MAJOR": "MAJOR", "MINOR": "MINOR", "PATCH": "PATCH"} + @pytest.mark.parametrize( ("value", "expected"), @@ -18,8 +95,113 @@ def test_version_increment_from_value( value: object, expected: VersionIncrement ) -> None: + """Unknown values fall back to NONE instead of raising.""" assert VersionIncrement.from_value(value) == expected def test_version_increment_str() -> None: + """String conversion preserves the legacy enum names.""" assert str(VersionIncrement.PATCH) == "PATCH" + + +def test_version_increment_comparison_order() -> None: + """The enum provides semantic ordering instead of string ordering.""" + assert VersionIncrement.NONE < VersionIncrement.PATCH + assert VersionIncrement.PATCH < VersionIncrement.MINOR + assert VersionIncrement.MINOR < VersionIncrement.MAJOR + + +def test_version_increment_max_uses_semantic_order() -> None: + """max() chooses the highest semantic increment across mixed values.""" + assert ( + max( + [ + VersionIncrement.PATCH, + VersionIncrement.MAJOR, + VersionIncrement.MINOR, + VersionIncrement.NONE, + ] + ) + == VersionIncrement.MAJOR + ) + + +@pytest.mark.parametrize( + ("messages", "expected"), + [ + (PATCH_INCREMENTS_CC, VersionIncrement.PATCH), + (MINOR_INCREMENTS_CC, VersionIncrement.MINOR), + (MAJOR_INCREMENTS_BREAKING_CHANGE_CC, VersionIncrement.MAJOR), + (MAJOR_INCREMENTS_BREAKING_CHANGE_ALT_CC, VersionIncrement.MAJOR), + (MAJOR_INCREMENTS_EXCLAMATION_OTHER_TYPE_CC, VersionIncrement.MAJOR), + ( + MAJOR_INCREMENTS_EXCLAMATION_OTHER_TYPE_WITH_SCOPE_CC, + VersionIncrement.MAJOR, + ), + (MAJOR_INCREMENTS_EXCLAMATION_CC, VersionIncrement.MAJOR), + (MAJOR_INCREMENTS_EXCLAMATION_CC_SAMPLE_2, VersionIncrement.MAJOR), + (NONE_INCREMENT_CC, VersionIncrement.NONE), + ], +) +def test_get_highest_by_messages_for_conventional_commits( + messages: list[str], expected: VersionIncrement +) -> None: + """Conventional-commit bump detection matches the existing behavior.""" + assert ( + VersionIncrement.get_highest_by_messages( + messages, + ConventionalCommitsCz.bump_pattern, + ConventionalCommitsCz.bump_map, + ) + == expected + ) + + +@pytest.mark.parametrize( + ("messages", "expected"), + [ + (PATCH_INCREMENTS_SVE, VersionIncrement.PATCH), + (MINOR_INCREMENTS_SVE, VersionIncrement.MINOR), + (MAJOR_INCREMENTS_SVE, VersionIncrement.MAJOR), + ], +) +def test_get_highest_by_messages_for_semantic_tokens( + messages: list[str], expected: VersionIncrement +) -> None: + """Custom bump maps continue to work with the shared extraction helpers.""" + assert ( + VersionIncrement.get_highest_by_messages( + messages, + SEMANTIC_VERSION_PATTERN, + SEMANTIC_VERSION_MAP, + ) + == expected + ) + + +def test_get_highest_by_messages_returns_none_for_empty_input() -> None: + """Empty inputs preserve the existing no-increment behavior explicitly.""" + assert ( + VersionIncrement.get_highest_by_messages( + [], + ConventionalCommitsCz.bump_pattern, + ConventionalCommitsCz.bump_map, + ) + == VersionIncrement.NONE + ) + + +def test_from_message_uses_highest_matching_line() -> None: + """A multiline commit message keeps the highest line-level increment.""" + message = ( + "feat(api): add endpoint\n\nBREAKING CHANGE: remove the legacy response body" + ) + + assert ( + VersionIncrement.from_message( + message, + ConventionalCommitsCz.bump_pattern, + ConventionalCommitsCz.bump_map, + ) + == VersionIncrement.MAJOR + ) From c130f3e4c3dcda2576a7b3d9c7e5ddb9ba289845 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Thu, 1 Oct 2026 01:17:56 +0800 Subject: [PATCH 2/5] no-mistakes(review): Fix invalid custom bump-map increment handling --- commitizen/version_increment.py | 43 ++++++++++++++++++++--- tests/commands/test_bump_command.py | 39 +++++++++++++++++++++ tests/test_version_increment.py | 54 +++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 5 deletions(-) diff --git a/commitizen/version_increment.py b/commitizen/version_increment.py index c90670f0ab..7e3a050610 100644 --- a/commitizen/version_increment.py +++ b/commitizen/version_increment.py @@ -7,6 +7,8 @@ if TYPE_CHECKING: from collections.abc import Iterable, Mapping +RegexPattern = str | re.Pattern[str] + class VersionIncrement(IntEnum): """Semantic versioning bump increments. @@ -39,6 +41,30 @@ def from_value(cls, value: object) -> VersionIncrement: except KeyError: return VersionIncrement.NONE + @classmethod + def _compile_pattern(cls, regex: RegexPattern) -> re.Pattern[str]: + """Normalize a bump-pattern input into a compiled regular expression.""" + if isinstance(regex, re.Pattern): + return regex + return re.compile(regex) + + @classmethod + def _from_bump_map_value(cls, value: object) -> VersionIncrement: + """Resolve a configured bump-map value or reject invalid rule output. + + This preserves the historical behavior where a matched custom bump-map + entry with an unsupported increment fails immediately instead of being + treated as a no-op. + """ + if value is None: + return cls.NONE + if not isinstance(value, str): + raise ValueError(f"Invalid bump increment: {value!r}") + try: + return cls[value] + except KeyError as exc: + raise ValueError(f"Invalid bump increment: {value!r}") from exc + @classmethod def from_match( cls, matched_text: str, increments_map: Mapping[str, object] @@ -50,12 +76,12 @@ def from_match( """ for match_pattern, increment in increments_map.items(): if re.match(match_pattern, matched_text): - return cls.from_value(increment) + return cls._from_bump_map_value(increment) return cls.NONE @classmethod def from_message( - cls, message: str, regex: str, increments_map: Mapping[str, object] + cls, message: str, regex: RegexPattern, increments_map: Mapping[str, object] ) -> VersionIncrement: """Compute the highest increment contributed by one commit message. @@ -63,7 +89,7 @@ def from_message( against ``bump_pattern`` and then keeps the highest resulting bump. This method preserves that behavior while returning a comparable enum. """ - select_pattern = re.compile(regex) + select_pattern = cls._compile_pattern(regex) increments = [] for line in message.split("\n"): @@ -75,7 +101,10 @@ def from_message( @classmethod def get_highest_by_messages( - cls, messages: Iterable[str], regex: str, increments_map: Mapping[str, object] + cls, + messages: Iterable[str], + regex: RegexPattern, + increments_map: Mapping[str, object], ) -> VersionIncrement: """Compute the highest increment across many commit messages. @@ -83,7 +112,11 @@ def get_highest_by_messages( "no increment" behavior explicitly instead of relying on exceptions from ``max()``. """ + select_pattern = cls._compile_pattern(regex) return max( - (cls.from_message(message, regex, increments_map) for message in messages), + ( + cls.from_message(message, select_pattern, increments_map) + for message in messages + ), default=cls.NONE, ) diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index 55e055813f..c442b25382 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -1330,6 +1330,45 @@ def test_bump_allow_no_commit_with_no_eligible_commit( assert "bump: version 1.0.0 → 1.0.1" in out + +def test_bump_allow_no_commit_with_invalid_custom_bump_map_raises( + tmp_commitizen_project: Path, + monkeypatch: pytest.MonkeyPatch, + util: UtilFixture, +) -> None: + """An invalid matched custom bump-map value must fail before PATCH fallback.""" + monkeypatch.chdir(tmp_commitizen_project) + (tmp_commitizen_project / "pyproject.toml").write_text( + dedent( + """ + [tool.commitizen] + name = "cz_customize" + version = "0.1.0" + + [tool.commitizen.customize] + bump_pattern = "^(new|fix|docs)" + bump_map = { new = "MINORR", fix = "PATCH", docs = "NONE" } + change_type_map = { new = "Features", fix = "Bugs", docs = "Docs" } + changelog_pattern = "^(new|fix|docs)" + commit_parser = '^(?Pnew|fix|docs):\\s(?P.+)$' + schema_pattern = ".*" + questions = [] + """ + ).strip() + + "\n", + encoding="utf-8", + ) + + util.create_file_and_commit("fix: seed release") + util.run_cli("bump", "--yes") + util.create_file_and_commit("new: add endpoint") + + with pytest.raises(ValueError, match="MINORR"): + util.run_cli("bump", "--yes", "--allow-no-commit") + + assert git.tag_exist("0.1.2") is False + + def test_bump_allow_no_commit_with_increment( tmp_commitizen_project, monkeypatch, util: UtilFixture, capsys ): diff --git a/tests/test_version_increment.py b/tests/test_version_increment.py index c9e9b4255b..fe73f3fca7 100644 --- a/tests/test_version_increment.py +++ b/tests/test_version_increment.py @@ -1,5 +1,7 @@ """Tests for semantic-version increment ordering and extraction helpers.""" +import re + import pytest from commitizen.cz.conventional_commits import ConventionalCommitsCz @@ -205,3 +207,55 @@ def test_from_message_uses_highest_matching_line() -> None: ) == VersionIncrement.MAJOR ) + + +def test_get_highest_by_messages_supports_custom_none_mapping() -> None: + """A matched custom rule can still explicitly opt out of bumping.""" + assert ( + VersionIncrement.get_highest_by_messages( + ["docs: update guide"], + r"^(docs)", + {"docs": None}, + ) + == VersionIncrement.NONE + ) + + + +def test_get_highest_by_messages_raises_for_invalid_bump_map_value() -> None: + """A matched custom bump-map value must fail instead of becoming NONE.""" + with pytest.raises(ValueError, match="MINORR"): + VersionIncrement.get_highest_by_messages( + ["new: add endpoint"], + r"^(new)", + {"new": "MINORR"}, + ) + + + +def test_get_highest_by_messages_compiles_pattern_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Scanning many messages reuses one compiled bump pattern.""" + compile_calls = 0 + original_compile = re.compile + + def counted_compile(pattern: str, flags: int = 0) -> re.Pattern[str]: + """Record each pattern compilation while preserving regex behavior.""" + nonlocal compile_calls + compile_calls += 1 + return original_compile(pattern, flags) + + monkeypatch.setattr("commitizen.version_increment.re.compile", counted_compile) + + increment = VersionIncrement.get_highest_by_messages( + [ + "fix: correct typo", + "feat(api): add endpoint\n\nBREAKING CHANGE: remove fallback", + ], + ConventionalCommitsCz.bump_pattern, + ConventionalCommitsCz.bump_map, + ) + + assert increment == VersionIncrement.MAJOR + assert compile_calls == 1 From 07386e7c7d36e0f398948a13121c5f4810bd29ed Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Thu, 1 Oct 2026 01:21:16 +0800 Subject: [PATCH 3/5] no-mistakes(review): Preserve major short-circuit in multiline bump detection --- commitizen/version_increment.py | 15 +++++++++++---- tests/test_version_increment.py | 13 +++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/commitizen/version_increment.py b/commitizen/version_increment.py index 7e3a050610..ebac622da0 100644 --- a/commitizen/version_increment.py +++ b/commitizen/version_increment.py @@ -90,14 +90,21 @@ def from_message( This method preserves that behavior while returning a comparable enum. """ select_pattern = cls._compile_pattern(regex) - increments = [] + increment = cls.NONE for line in message.split("\n"): result = select_pattern.search(line) - if result: - increments.append(cls.from_match(result.group(1), increments_map)) + if not result: + continue - return max(increments, default=cls.NONE) + increment = max( + increment, + cls.from_match(result.group(1), increments_map), + ) + if increment == cls.MAJOR: + return increment + + return increment @classmethod def get_highest_by_messages( diff --git a/tests/test_version_increment.py b/tests/test_version_increment.py index fe73f3fca7..f275b65fff 100644 --- a/tests/test_version_increment.py +++ b/tests/test_version_increment.py @@ -222,6 +222,19 @@ def test_get_highest_by_messages_supports_custom_none_mapping() -> None: +def test_from_message_stops_after_major_match() -> None: + """A major match on one line shields later invalid lines in that message.""" + assert ( + VersionIncrement.from_message( + "break: api\n\noops: typo", + r"^(break|oops)", + {"break": "MAJOR", "oops": "MINORR"}, + ) + == VersionIncrement.MAJOR + ) + + + def test_get_highest_by_messages_raises_for_invalid_bump_map_value() -> None: """A matched custom bump-map value must fail instead of becoming NONE.""" with pytest.raises(ValueError, match="MINORR"): From fa667dc51e682e8fba718bb10372475f62a4d663 Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Thu, 1 Oct 2026 02:19:09 +0800 Subject: [PATCH 4/5] no-mistakes(review): Reject string NONE in bump maps --- commitizen/version_increment.py | 13 ++++---- tests/commands/test_bump_command.py | 49 ++++++++++++++++++++++++++--- tests/test_version_increment.py | 11 +++++++ 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/commitizen/version_increment.py b/commitizen/version_increment.py index ebac622da0..813522e852 100644 --- a/commitizen/version_increment.py +++ b/commitizen/version_increment.py @@ -58,12 +58,13 @@ def _from_bump_map_value(cls, value: object) -> VersionIncrement: """ if value is None: return cls.NONE - if not isinstance(value, str): - raise ValueError(f"Invalid bump increment: {value!r}") - try: - return cls[value] - except KeyError as exc: - raise ValueError(f"Invalid bump increment: {value!r}") from exc + if value == "PATCH": + return cls.PATCH + if value == "MINOR": + return cls.MINOR + if value == "MAJOR": + return cls.MAJOR + raise ValueError(f"Invalid bump increment: {value!r}") @classmethod def from_match( diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index c442b25382..971d547784 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -1346,11 +1346,11 @@ def test_bump_allow_no_commit_with_invalid_custom_bump_map_raises( version = "0.1.0" [tool.commitizen.customize] - bump_pattern = "^(new|fix|docs)" - bump_map = { new = "MINORR", fix = "PATCH", docs = "NONE" } - change_type_map = { new = "Features", fix = "Bugs", docs = "Docs" } - changelog_pattern = "^(new|fix|docs)" - commit_parser = '^(?Pnew|fix|docs):\\s(?P.+)$' + bump_pattern = "^(new|fix)" + bump_map = { new = "MINORR", fix = "PATCH" } + change_type_map = { new = "Features", fix = "Bugs" } + changelog_pattern = "^(new|fix)" + commit_parser = '^(?Pnew|fix):\\s(?P.+)$' schema_pattern = ".*" questions = [] """ @@ -1369,6 +1369,45 @@ def test_bump_allow_no_commit_with_invalid_custom_bump_map_raises( assert git.tag_exist("0.1.2") is False + +def test_bump_allow_no_commit_with_string_none_bump_map_raises( + tmp_commitizen_project: Path, + monkeypatch: pytest.MonkeyPatch, + util: UtilFixture, +) -> None: + """A matched bump-map string NONE must fail instead of falling back to PATCH.""" + monkeypatch.chdir(tmp_commitizen_project) + (tmp_commitizen_project / "pyproject.toml").write_text( + dedent( + """ + [tool.commitizen] + name = "cz_customize" + version = "0.1.0" + + [tool.commitizen.customize] + bump_pattern = "^(docs|fix)" + bump_map = { docs = "NONE", fix = "PATCH" } + change_type_map = { docs = "Docs", fix = "Bugs" } + changelog_pattern = "^(docs|fix)" + commit_parser = '^(?Pdocs|fix):\\s(?P.+)$' + schema_pattern = ".*" + questions = [] + """ + ).strip() + + "\n", + encoding="utf-8", + ) + + util.create_file_and_commit("fix: seed release") + util.run_cli("bump", "--yes") + util.create_file_and_commit("docs: update guide") + + with pytest.raises(ValueError, match="NONE"): + util.run_cli("bump", "--yes", "--allow-no-commit") + + assert git.tag_exist("0.1.2") is False + + def test_bump_allow_no_commit_with_increment( tmp_commitizen_project, monkeypatch, util: UtilFixture, capsys ): diff --git a/tests/test_version_increment.py b/tests/test_version_increment.py index f275b65fff..18eb1b4f3e 100644 --- a/tests/test_version_increment.py +++ b/tests/test_version_increment.py @@ -222,6 +222,17 @@ def test_get_highest_by_messages_supports_custom_none_mapping() -> None: +def test_get_highest_by_messages_rejects_string_none_mapping() -> None: + """The string NONE remains invalid at the bump-map computation boundary.""" + with pytest.raises(ValueError, match="NONE"): + VersionIncrement.get_highest_by_messages( + ["docs: update guide"], + r"^(docs)", + {"docs": "NONE"}, + ) + + + def test_from_message_stops_after_major_match() -> None: """A major match on one line shields later invalid lines in that message.""" assert ( From 3a7ff2c031da27e6e661d142e841233573fd0b0b Mon Sep 17 00:00:00 2001 From: Yu-Ting Hsiung Date: Thu, 1 Oct 2026 02:57:29 +0800 Subject: [PATCH 5/5] no-mistakes(document): Document bump_map no-bump rules --- commitizen/version_increment.py | 23 ++++++++++++++++++++++- docs/customization/config_file.md | 2 +- docs/customization/python_class.md | 2 +- tests/commands/test_bump_command.py | 2 -- tests/test_version_increment.py | 4 ---- 5 files changed, 24 insertions(+), 9 deletions(-) diff --git a/commitizen/version_increment.py b/commitizen/version_increment.py index 813522e852..60d6d7952d 100644 --- a/commitizen/version_increment.py +++ b/commitizen/version_increment.py @@ -1,3 +1,12 @@ +"""Helpers for comparing and deriving semantic-version increments. + +This module centralizes the conversion between external increment labels and the +ordered enum used internally by bump-related commands. The distinction matters +because some callers intentionally coerce unknown values to ``NONE`` while +matched ``bump_map`` outputs must preserve the historical fail-fast behavior for +invalid custom rule values. +""" + from __future__ import annotations import re @@ -20,6 +29,12 @@ class VersionIncrement(IntEnum): - PATCH: backwards-compatible bug fixes - MINOR: backwards-compatible features - MAJOR: incompatible API changes + + Examples: + ```python + max([VersionIncrement.PATCH, VersionIncrement.MINOR]) + # VersionIncrement.MINOR + ``` """ NONE = 0 @@ -33,7 +48,13 @@ def __str__(self) -> str: @classmethod def from_value(cls, value: object) -> VersionIncrement: - """Convert a possibly-invalid external value into a known increment.""" + """Convert external increment input into a known increment. + + This lenient path is used for inputs such as CLI arguments where an + unknown string should behave like "no increment" instead of raising. + Matched ``bump_map`` values are validated separately so custom rules keep + their historical fail-fast behavior. + """ if not isinstance(value, str): return VersionIncrement.NONE try: diff --git a/docs/customization/config_file.md b/docs/customization/config_file.md index 50185a7583..97750e75a7 100644 --- a/docs/customization/config_file.md +++ b/docs/customization/config_file.md @@ -160,7 +160,7 @@ Example: | `schema_pattern` | `str` | `""` | (OPTIONAL) The regular expression used to do commit message validation. Used by `cz check`. | | `info_path` | `str` | `""` | (OPTIONAL) The path to the file that contains explanation of the commit rules. Used by `cz info`. If not provided `cz info`, will load `info` instead. | | `info` | `str` | `""` | (OPTIONAL) Explanation of the commit rules. Used by `cz info`. | -| `bump_map` | `dict` | `None` | (OPTIONAL) Dictionary mapping the extracted information to a `SemVer` increment type (`MAJOR`, `MINOR`, `PATCH`) | +| `bump_map` | `dict` | `None` | (OPTIONAL) Dictionary mapping the extracted information to a `SemVer` increment type (`MAJOR`, `MINOR`, `PATCH`). In JSON and YAML configs, `null` keeps a matched rule from bumping the version. | | `bump_pattern` | `str` | `None` | (OPTIONAL) Regex to extract information from commit (subject and body) | | `change_type_order` | `str` | `None` | (OPTIONAL) List of strings used to order the Changelog. All other types will be sorted alphabetically. Default is `["BREAKING CHANGE", "Feat", "Fix", "Refactor", "Perf"]` | | `commit_parser` | `str` | `None` | (OPTIONAL) Regex to extract information used in creating changelog. [See more][changelog-spec] | diff --git a/docs/customization/python_class.md b/docs/customization/python_class.md index 84e448119d..9d54d80720 100644 --- a/docs/customization/python_class.md +++ b/docs/customization/python_class.md @@ -99,7 +99,7 @@ You need to define 2 parameters inside your custom `BaseCommitizen`. | Parameter | Type | Default | Description | | -------------- | ------ | ------- | ----------------------------------------------------------------------------------------------------- | | `bump_pattern` | `str` | `None` | Regex to extract information from commit (subject and body) | -| `bump_map` | `dict` | `None` | Dictionary mapping the extracted information to a `SemVer` increment type (`MAJOR`, `MINOR`, `PATCH`) | +| `bump_map` | `dict` | `None` | Dictionary mapping the extracted information to a `SemVer` increment type (`MAJOR`, `MINOR`, `PATCH`). Use `None` when a matched rule should not bump the version. | Let's see an example. diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index 971d547784..73fe1cb482 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -1330,7 +1330,6 @@ def test_bump_allow_no_commit_with_no_eligible_commit( assert "bump: version 1.0.0 → 1.0.1" in out - def test_bump_allow_no_commit_with_invalid_custom_bump_map_raises( tmp_commitizen_project: Path, monkeypatch: pytest.MonkeyPatch, @@ -1369,7 +1368,6 @@ def test_bump_allow_no_commit_with_invalid_custom_bump_map_raises( assert git.tag_exist("0.1.2") is False - def test_bump_allow_no_commit_with_string_none_bump_map_raises( tmp_commitizen_project: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_version_increment.py b/tests/test_version_increment.py index 18eb1b4f3e..9db7f2b92a 100644 --- a/tests/test_version_increment.py +++ b/tests/test_version_increment.py @@ -221,7 +221,6 @@ def test_get_highest_by_messages_supports_custom_none_mapping() -> None: ) - def test_get_highest_by_messages_rejects_string_none_mapping() -> None: """The string NONE remains invalid at the bump-map computation boundary.""" with pytest.raises(ValueError, match="NONE"): @@ -232,7 +231,6 @@ def test_get_highest_by_messages_rejects_string_none_mapping() -> None: ) - def test_from_message_stops_after_major_match() -> None: """A major match on one line shields later invalid lines in that message.""" assert ( @@ -245,7 +243,6 @@ def test_from_message_stops_after_major_match() -> None: ) - def test_get_highest_by_messages_raises_for_invalid_bump_map_value() -> None: """A matched custom bump-map value must fail instead of becoming NONE.""" with pytest.raises(ValueError, match="MINORR"): @@ -256,7 +253,6 @@ def test_get_highest_by_messages_raises_for_invalid_bump_map_value() -> None: ) - def test_get_highest_by_messages_compiles_pattern_once( monkeypatch: pytest.MonkeyPatch, ) -> None: