diff --git a/commitizen/bump.py b/commitizen/bump.py index 030c8f1e5..a85853582 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 a44ba9edd..0122b9f1c 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 e8b8ea8e3..b1c338319 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 9320fe8f9..60d6d7952 100644 --- a/commitizen/version_increment.py +++ b/commitizen/version_increment.py @@ -1,6 +1,22 @@ +"""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 from enum import IntEnum +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + +RegexPattern = str | re.Pattern[str] class VersionIncrement(IntEnum): @@ -13,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 @@ -21,13 +43,109 @@ 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 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: return cls[value] 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 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( + 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_bump_map_value(increment) + return cls.NONE + + @classmethod + def from_message( + cls, message: str, regex: RegexPattern, 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 = cls._compile_pattern(regex) + increment = cls.NONE + + for line in message.split("\n"): + result = select_pattern.search(line) + if not result: + continue + + 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( + cls, + messages: Iterable[str], + regex: RegexPattern, + 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()``. + """ + select_pattern = cls._compile_pattern(regex) + return max( + ( + cls.from_message(message, select_pattern, increments_map) + for message in messages + ), + default=cls.NONE, + ) diff --git a/docs/customization/config_file.md b/docs/customization/config_file.md index 50185a758..97750e75a 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 84e448119..9d54d8072 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 55e055813..73fe1cb48 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -1330,6 +1330,82 @@ 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)" + 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 = [] + """ + ).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_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_bump_find_increment.py b/tests/test_bump_find_increment.py deleted file mode 100644 index 8209278ed..000000000 --- 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 5832140a8..9db7f2b92 100644 --- a/tests/test_version_increment.py +++ b/tests/test_version_increment.py @@ -1,7 +1,86 @@ +"""Tests for semantic-version increment ordering and extraction helpers.""" + +import re + 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 +97,185 @@ 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 + ) + + +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_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 ( + 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"): + 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