Skip to content

Commit 6585f2c

Browse files
committed
refactor(bump): replace find_increment with VersionIncrement max
1 parent 642b8be commit 6585f2c

9 files changed

Lines changed: 480 additions & 186 deletions

File tree

‎commitizen/bump.py‎

Lines changed: 4 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,64 +2,18 @@
22

33
import os
44
import re
5-
from collections import OrderedDict
65
from glob import iglob
7-
from logging import getLogger
86
from string import Template
9-
from typing import TYPE_CHECKING, cast
7+
from typing import TYPE_CHECKING
108

11-
from commitizen.defaults import BUMP_MESSAGE, MAJOR, MINOR, PATCH
9+
from commitizen.defaults import BUMP_MESSAGE
1210
from commitizen.exceptions import CurrentVersionNotFoundError
13-
from commitizen.git import GitCommit, smart_open
11+
from commitizen.git import smart_open
1412

1513
if TYPE_CHECKING:
1614
from collections.abc import Generator, Iterable
1715

18-
from commitizen.version_schemes import Increment, VersionProtocol
19-
20-
VERSION_TYPES = [None, PATCH, MINOR, MAJOR]
21-
22-
logger = getLogger("commitizen")
23-
24-
25-
def find_increment(
26-
commits: list[GitCommit], regex: str, increments_map: dict | OrderedDict
27-
) -> Increment | None:
28-
if isinstance(increments_map, dict):
29-
increments_map = OrderedDict(increments_map)
30-
31-
# Most important cases are major and minor.
32-
# Everything else will be considered patch.
33-
select_pattern = re.compile(regex)
34-
increment: str | None = None
35-
36-
for commit in commits:
37-
for message in commit.message.split("\n"):
38-
result = select_pattern.search(message)
39-
40-
if result:
41-
found_keyword = result.group(1)
42-
new_increment = None
43-
for match_pattern in increments_map.keys():
44-
if re.match(match_pattern, found_keyword):
45-
new_increment = increments_map[match_pattern]
46-
break
47-
48-
if new_increment is None:
49-
logger.debug(
50-
f"no increment needed for '{found_keyword}' in '{message}'"
51-
)
52-
53-
if VERSION_TYPES.index(increment) < VERSION_TYPES.index(new_increment):
54-
logger.debug(
55-
f"increment detected is '{new_increment}' due to '{found_keyword}' in '{message}'"
56-
)
57-
increment = new_increment
58-
59-
if increment == MAJOR:
60-
break
61-
62-
return cast("Increment", increment)
16+
from commitizen.version_schemes import VersionProtocol
6317

6418

6519
def update_version_in_files(

‎commitizen/commands/bump.py‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
)
2525
from commitizen.providers import get_provider
2626
from commitizen.tags import TagRules
27+
from commitizen.version_increment import VersionIncrement
2728
from commitizen.version_schemes import (
2829
Increment,
2930
InvalidVersion,
@@ -145,8 +146,8 @@ def _is_initial_tag(
145146
return bool(questionary.confirm("Is this the first tag created?").ask())
146147

147148
def _find_increment(self, commits: list[git.GitCommit]) -> Increment | None:
149+
"""Determine the highest configured increment across candidate commits."""
148150
# Update the bump map to ensure major version doesn't increment.
149-
# self.cz.bump_map = defaults.bump_map_major_version_zero
150151
bump_map = (
151152
self.cz.bump_map_major_version_zero
152153
if self.bump_settings["major_version_zero"]
@@ -158,11 +159,15 @@ def _find_increment(self, commits: list[git.GitCommit]) -> Increment | None:
158159
raise NoPatternMapError(
159160
f"'{self.config.settings['name']}' rule does not support bump"
160161
)
161-
return bump.find_increment(
162-
self.cz.filter_commits_before_bump(commits),
163-
regex=bump_pattern,
164-
increments_map=bump_map,
162+
163+
increment = VersionIncrement.get_highest_by_messages(
164+
(commit.message for commit in self.cz.filter_commits_before_bump(commits)),
165+
bump_pattern,
166+
bump_map,
165167
)
168+
if increment == VersionIncrement.NONE:
169+
return None
170+
return cast("Increment", str(increment))
166171

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

‎commitizen/commands/version.py‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from packaging.version import InvalidVersion
66

7-
from commitizen import bump, factory, git, out
7+
from commitizen import factory, git, out
88
from commitizen.__version__ import __version__
99
from commitizen.config import BaseConfig
1010
from commitizen.exceptions import (
@@ -180,11 +180,20 @@ def _get_next_git_version(
180180
raise NoPatternMapError(
181181
f"'{self.config.settings['name']}' rule does not support bump"
182182
)
183-
increment = bump.find_increment(
184-
self.cz.filter_commits_before_bump(commits),
185-
regex=bump_pattern,
186-
increments_map=bump_map,
183+
next_increment = VersionIncrement.get_highest_by_messages(
184+
(commit.message for commit in self.cz.filter_commits_before_bump(commits)),
185+
bump_pattern,
186+
bump_map,
187187
)
188+
increment: Increment | None
189+
if next_increment == VersionIncrement.NONE:
190+
increment = None
191+
elif next_increment == VersionIncrement.PATCH:
192+
increment = "PATCH"
193+
elif next_increment == VersionIncrement.MINOR:
194+
increment = "MINOR"
195+
else:
196+
increment = "MAJOR"
188197

189198
# TODO: Consider adding all the parameters `.bump` supports:
190199
# prerelease, prerelease_offset,exact_increment, etc..

‎commitizen/version_increment.py‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
1+
"""Helpers for comparing and deriving semantic-version increments.
2+
3+
This module centralizes the conversion between external increment labels and the
4+
ordered enum used internally by bump-related commands. The distinction matters
5+
because some callers intentionally coerce unknown values to ``NONE`` while
6+
matched ``bump_map`` outputs must preserve the historical fail-fast behavior for
7+
invalid custom rule values.
8+
"""
9+
110
from __future__ import annotations
211

12+
import re
313
from enum import IntEnum
14+
from typing import TYPE_CHECKING
15+
16+
if TYPE_CHECKING:
17+
from collections.abc import Iterable, Mapping
18+
19+
RegexPattern = str | re.Pattern[str]
420

521

622
class VersionIncrement(IntEnum):
@@ -13,6 +29,12 @@ class VersionIncrement(IntEnum):
1329
- PATCH: backwards-compatible bug fixes
1430
- MINOR: backwards-compatible features
1531
- MAJOR: incompatible API changes
32+
33+
Examples:
34+
```python
35+
max([VersionIncrement.PATCH, VersionIncrement.MINOR])
36+
# VersionIncrement.MINOR
37+
```
1638
"""
1739

1840
NONE = 0
@@ -21,13 +43,109 @@ class VersionIncrement(IntEnum):
2143
MAJOR = 3
2244

2345
def __str__(self) -> str:
46+
"""Return the legacy string representation used by existing callers."""
2447
return self.name
2548

2649
@classmethod
2750
def from_value(cls, value: object) -> VersionIncrement:
51+
"""Convert external increment input into a known increment.
52+
53+
This lenient path is used for inputs such as CLI arguments where an
54+
unknown string should behave like "no increment" instead of raising.
55+
Matched ``bump_map`` values are validated separately so custom rules keep
56+
their historical fail-fast behavior.
57+
"""
2858
if not isinstance(value, str):
2959
return VersionIncrement.NONE
3060
try:
3161
return cls[value]
3262
except KeyError:
3363
return VersionIncrement.NONE
64+
65+
@classmethod
66+
def _compile_pattern(cls, regex: RegexPattern) -> re.Pattern[str]:
67+
"""Normalize a bump-pattern input into a compiled regular expression."""
68+
if isinstance(regex, re.Pattern):
69+
return regex
70+
return re.compile(regex)
71+
72+
@classmethod
73+
def _from_bump_map_value(cls, value: object) -> VersionIncrement:
74+
"""Resolve a configured bump-map value or reject invalid rule output.
75+
76+
This preserves the historical behavior where a matched custom bump-map
77+
entry with an unsupported increment fails immediately instead of being
78+
treated as a no-op.
79+
"""
80+
if value is None:
81+
return cls.NONE
82+
if value == "PATCH":
83+
return cls.PATCH
84+
if value == "MINOR":
85+
return cls.MINOR
86+
if value == "MAJOR":
87+
return cls.MAJOR
88+
raise ValueError(f"Invalid bump increment: {value!r}")
89+
90+
@classmethod
91+
def from_match(
92+
cls, matched_text: str, increments_map: Mapping[str, object]
93+
) -> VersionIncrement:
94+
"""Resolve one extracted bump token against a configured bump map.
95+
96+
The first regular-expression key that matches wins, preserving the
97+
current user-visible behavior of ``bump_map`` ordering.
98+
"""
99+
for match_pattern, increment in increments_map.items():
100+
if re.match(match_pattern, matched_text):
101+
return cls._from_bump_map_value(increment)
102+
return cls.NONE
103+
104+
@classmethod
105+
def from_message(
106+
cls, message: str, regex: RegexPattern, increments_map: Mapping[str, object]
107+
) -> VersionIncrement:
108+
"""Compute the highest increment contributed by one commit message.
109+
110+
Commitizen historically evaluates each line of the full commit message
111+
against ``bump_pattern`` and then keeps the highest resulting bump.
112+
This method preserves that behavior while returning a comparable enum.
113+
"""
114+
select_pattern = cls._compile_pattern(regex)
115+
increment = cls.NONE
116+
117+
for line in message.split("\n"):
118+
result = select_pattern.search(line)
119+
if not result:
120+
continue
121+
122+
increment = max(
123+
increment,
124+
cls.from_match(result.group(1), increments_map),
125+
)
126+
if increment == cls.MAJOR:
127+
return increment
128+
129+
return increment
130+
131+
@classmethod
132+
def get_highest_by_messages(
133+
cls,
134+
messages: Iterable[str],
135+
regex: RegexPattern,
136+
increments_map: Mapping[str, object],
137+
) -> VersionIncrement:
138+
"""Compute the highest increment across many commit messages.
139+
140+
An empty iterable yields ``NONE`` so callers can preserve the existing
141+
"no increment" behavior explicitly instead of relying on exceptions from
142+
``max()``.
143+
"""
144+
select_pattern = cls._compile_pattern(regex)
145+
return max(
146+
(
147+
cls.from_message(message, select_pattern, increments_map)
148+
for message in messages
149+
),
150+
default=cls.NONE,
151+
)

‎docs/customization/config_file.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ Example:
160160
| `schema_pattern` | `str` | `""` | (OPTIONAL) The regular expression used to do commit message validation. Used by `cz check`. |
161161
| `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. |
162162
| `info` | `str` | `""` | (OPTIONAL) Explanation of the commit rules. Used by `cz info`. |
163-
| `bump_map` | `dict` | `None` | (OPTIONAL) Dictionary mapping the extracted information to a `SemVer` increment type (`MAJOR`, `MINOR`, `PATCH`) |
163+
| `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. |
164164
| `bump_pattern` | `str` | `None` | (OPTIONAL) Regex to extract information from commit (subject and body) |
165165
| `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"]` |
166166
| `commit_parser` | `str` | `None` | (OPTIONAL) Regex to extract information used in creating changelog. [See more][changelog-spec] |

‎docs/customization/python_class.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ You need to define 2 parameters inside your custom `BaseCommitizen`.
9999
| Parameter | Type | Default | Description |
100100
| -------------- | ------ | ------- | ----------------------------------------------------------------------------------------------------- |
101101
| `bump_pattern` | `str` | `None` | Regex to extract information from commit (subject and body) |
102-
| `bump_map` | `dict` | `None` | Dictionary mapping the extracted information to a `SemVer` increment type (`MAJOR`, `MINOR`, `PATCH`) |
102+
| `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. |
103103

104104
Let's see an example.
105105

‎tests/commands/test_bump_command.py‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1330,6 +1330,82 @@ def test_bump_allow_no_commit_with_no_eligible_commit(
13301330
assert "bump: version 1.0.0 → 1.0.1" in out
13311331

13321332

1333+
def test_bump_allow_no_commit_with_invalid_custom_bump_map_raises(
1334+
tmp_commitizen_project: Path,
1335+
monkeypatch: pytest.MonkeyPatch,
1336+
util: UtilFixture,
1337+
) -> None:
1338+
"""An invalid matched custom bump-map value must fail before PATCH fallback."""
1339+
monkeypatch.chdir(tmp_commitizen_project)
1340+
(tmp_commitizen_project / "pyproject.toml").write_text(
1341+
dedent(
1342+
"""
1343+
[tool.commitizen]
1344+
name = "cz_customize"
1345+
version = "0.1.0"
1346+
1347+
[tool.commitizen.customize]
1348+
bump_pattern = "^(new|fix)"
1349+
bump_map = { new = "MINORR", fix = "PATCH" }
1350+
change_type_map = { new = "Features", fix = "Bugs" }
1351+
changelog_pattern = "^(new|fix)"
1352+
commit_parser = '^(?P<change_type>new|fix):\\s(?P<message>.+)$'
1353+
schema_pattern = ".*"
1354+
questions = []
1355+
"""
1356+
).strip()
1357+
+ "\n",
1358+
encoding="utf-8",
1359+
)
1360+
1361+
util.create_file_and_commit("fix: seed release")
1362+
util.run_cli("bump", "--yes")
1363+
util.create_file_and_commit("new: add endpoint")
1364+
1365+
with pytest.raises(ValueError, match="MINORR"):
1366+
util.run_cli("bump", "--yes", "--allow-no-commit")
1367+
1368+
assert git.tag_exist("0.1.2") is False
1369+
1370+
1371+
def test_bump_allow_no_commit_with_string_none_bump_map_raises(
1372+
tmp_commitizen_project: Path,
1373+
monkeypatch: pytest.MonkeyPatch,
1374+
util: UtilFixture,
1375+
) -> None:
1376+
"""A matched bump-map string NONE must fail instead of falling back to PATCH."""
1377+
monkeypatch.chdir(tmp_commitizen_project)
1378+
(tmp_commitizen_project / "pyproject.toml").write_text(
1379+
dedent(
1380+
"""
1381+
[tool.commitizen]
1382+
name = "cz_customize"
1383+
version = "0.1.0"
1384+
1385+
[tool.commitizen.customize]
1386+
bump_pattern = "^(docs|fix)"
1387+
bump_map = { docs = "NONE", fix = "PATCH" }
1388+
change_type_map = { docs = "Docs", fix = "Bugs" }
1389+
changelog_pattern = "^(docs|fix)"
1390+
commit_parser = '^(?P<change_type>docs|fix):\\s(?P<message>.+)$'
1391+
schema_pattern = ".*"
1392+
questions = []
1393+
"""
1394+
).strip()
1395+
+ "\n",
1396+
encoding="utf-8",
1397+
)
1398+
1399+
util.create_file_and_commit("fix: seed release")
1400+
util.run_cli("bump", "--yes")
1401+
util.create_file_and_commit("docs: update guide")
1402+
1403+
with pytest.raises(ValueError, match="NONE"):
1404+
util.run_cli("bump", "--yes", "--allow-no-commit")
1405+
1406+
assert git.tag_exist("0.1.2") is False
1407+
1408+
13331409
def test_bump_allow_no_commit_with_increment(
13341410
tmp_commitizen_project, monkeypatch, util: UtilFixture, capsys
13351411
):

0 commit comments

Comments
 (0)