Skip to content

Commit 59fc269

Browse files
bearomorphismCopilot
authored andcommitted
feat(bump_rule): add BumpRule, VersionIncrement, Prerelease Enum
1 parent 4d99415 commit 59fc269

18 files changed

Lines changed: 1601 additions & 691 deletions

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/bump_rule.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from functools import cached_property
5+
from typing import TYPE_CHECKING, Protocol
6+
7+
from commitizen.exceptions import NoPatternMapError
8+
from commitizen.version_increment import VersionIncrement
9+
10+
if TYPE_CHECKING:
11+
from collections.abc import Mapping
12+
13+
14+
# Re-export for backward compatibility with code that uses
15+
# ``from commitizen.bump_rule import VersionIncrement``.
16+
__all__ = [
17+
"BumpRule",
18+
"ConventionalCommitBumpRule",
19+
"CustomBumpRule",
20+
"VersionIncrement",
21+
]
22+
23+
24+
class BumpRule(Protocol):
25+
"""A protocol defining the interface for version bump rules.
26+
27+
This protocol specifies the contract that all version bump rule implementations must follow.
28+
It defines how commit messages should be analyzed to determine the appropriate semantic
29+
version increment.
30+
31+
The protocol is used to ensure consistent behavior across different bump rule implementations,
32+
such as conventional commits or custom rules.
33+
"""
34+
35+
def extract_increment(
36+
self, commit_message: str, major_version_zero: bool
37+
) -> VersionIncrement:
38+
"""Determine the version increment based on a commit message.
39+
40+
This method analyzes a commit message to determine what kind of version increment
41+
is needed. It handles special cases for breaking changes and respects the major_version_zero flag.
42+
43+
See the following subclasses for more details:
44+
- ConventionalCommitBumpRule: For conventional commits
45+
- CustomBumpRule: For custom bump rules
46+
47+
Args:
48+
commit_message: The commit message to analyze.
49+
major_version_zero: If True, breaking changes will result in a MINOR version bump instead of MAJOR
50+
51+
Returns:
52+
VersionIncrement: The type of version increment needed:
53+
"""
54+
55+
56+
class ConventionalCommitBumpRule(BumpRule):
57+
_BREAKING_CHANGE_TYPES = {"BREAKING CHANGE", "BREAKING-CHANGE"}
58+
_MINOR_CHANGE_TYPES = {"feat"}
59+
_PATCH_CHANGE_TYPES = {"fix", "perf", "refactor"}
60+
61+
def extract_increment(
62+
self, commit_message: str, major_version_zero: bool
63+
) -> VersionIncrement:
64+
if not (m := self._head_pattern.match(commit_message)):
65+
return VersionIncrement.NONE
66+
67+
change_type = m.group("change_type")
68+
if m.group("bang") or change_type in self._BREAKING_CHANGE_TYPES:
69+
return (
70+
VersionIncrement.MINOR if major_version_zero else VersionIncrement.MAJOR
71+
)
72+
73+
if change_type in self._MINOR_CHANGE_TYPES:
74+
return VersionIncrement.MINOR
75+
76+
if change_type in self._PATCH_CHANGE_TYPES:
77+
return VersionIncrement.PATCH
78+
79+
return VersionIncrement.NONE
80+
81+
@cached_property
82+
def _head_pattern(self) -> re.Pattern:
83+
change_types = [
84+
*self._BREAKING_CHANGE_TYPES,
85+
*self._PATCH_CHANGE_TYPES,
86+
*self._MINOR_CHANGE_TYPES,
87+
"docs",
88+
"style",
89+
"test",
90+
"build",
91+
"ci",
92+
]
93+
re_change_type = r"(?P<change_type>" + "|".join(change_types) + r")"
94+
re_scope = r"(?P<scope>\(.+\))?"
95+
re_bang = r"(?P<bang>!)?"
96+
return re.compile(f"^{re_change_type}{re_scope}{re_bang}:")
97+
98+
99+
class CustomBumpRule(BumpRule):
100+
def __init__(
101+
self,
102+
bump_pattern: str,
103+
bump_map: Mapping[str, VersionIncrement],
104+
bump_map_major_version_zero: Mapping[str, VersionIncrement],
105+
) -> None:
106+
"""Initialize a custom bump rule for version incrementing.
107+
108+
This constructor creates a rule that determines how version numbers should be
109+
incremented based on commit messages. It validates and compiles the provided
110+
pattern and maps for use in version bumping.
111+
112+
The fallback logic is used for backward compatibility.
113+
114+
Args:
115+
bump_pattern: A regex pattern string used to match commit messages.
116+
Example: r"^((?P<major>major)|(?P<minor>minor)|(?P<patch>patch))(?P<scope>\\(.+\\))?(?P<bang>!)?:"
117+
118+
Or with fallback regex: r"^((BREAKING[\\-\\ ]CHANGE|\\w+)(\\(.+\\))?!?):" # First group is type
119+
bump_map: A mapping of commit types to their corresponding version increments.
120+
Example: {
121+
"major": VersionIncrement.MAJOR,
122+
"bang": VersionIncrement.MAJOR,
123+
"minor": VersionIncrement.MINOR,
124+
"patch": VersionIncrement.PATCH
125+
}
126+
Or with fallback: {
127+
(r"^.+!$", VersionIncrement.MAJOR),
128+
(r"^BREAKING[\\-\\ ]CHANGE", VersionIncrement.MAJOR),
129+
(r"^feat", VersionIncrement.MINOR),
130+
(r"^fix", VersionIncrement.PATCH),
131+
(r"^refactor", VersionIncrement.PATCH),
132+
(r"^perf", VersionIncrement.PATCH),
133+
}
134+
bump_map_major_version_zero: A mapping of commit types to version increments
135+
specifically for when the major version is 0. This allows for different
136+
versioning behavior during initial development.
137+
The format is the same as bump_map.
138+
139+
Raises:
140+
NoPatternMapError: If any of the required parameters are empty or None
141+
"""
142+
if not bump_map or not bump_pattern or not bump_map_major_version_zero:
143+
raise NoPatternMapError(
144+
f"Invalid bump rule: {bump_pattern=} and {bump_map=} and {bump_map_major_version_zero=}"
145+
)
146+
147+
self.bump_pattern = re.compile(bump_pattern)
148+
self.bump_map = bump_map
149+
self.bump_map_major_version_zero = bump_map_major_version_zero
150+
151+
def extract_increment(
152+
self, commit_message: str, major_version_zero: bool
153+
) -> VersionIncrement:
154+
if not (m := self.bump_pattern.search(commit_message)):
155+
return VersionIncrement.NONE
156+
157+
effective_bump_map = (
158+
self.bump_map_major_version_zero if major_version_zero else self.bump_map
159+
)
160+
161+
try:
162+
increments = (
163+
increment
164+
for name, increment in effective_bump_map.items()
165+
if m.group(name)
166+
)
167+
increment = max(increments, default=VersionIncrement.NONE)
168+
if increment != VersionIncrement.NONE:
169+
return increment
170+
except IndexError:
171+
pass
172+
173+
# Fallback to legacy bump rule, for backward compatibility
174+
found_keyword = m.group(1)
175+
for match_pattern, increment in effective_bump_map.items():
176+
if re.match(match_pattern, found_keyword):
177+
return increment
178+
return VersionIncrement.NONE

commitizen/commands/bump.py

Lines changed: 30 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import questionary
88

99
from commitizen import bump, factory, git, hooks, out
10+
from commitizen.bump_rule import VersionIncrement
1011
from commitizen.changelog_formats import get_changelog_format
1112
from commitizen.commands.changelog import Changelog
1213
from commitizen.defaults import Settings
@@ -18,14 +19,12 @@
1819
InvalidManualVersion,
1920
NoCommitsFoundError,
2021
NoneIncrementExit,
21-
NoPatternMapError,
2222
NotAGitProjectError,
2323
NotAllowed,
2424
)
2525
from commitizen.providers import get_provider
2626
from commitizen.tags import TagRules
2727
from commitizen.version_schemes import (
28-
Increment,
2928
InvalidVersion,
3029
Prerelease,
3130
VersionProtocol,
@@ -53,7 +52,7 @@ class BumpArgs(Settings, total=False):
5352
get_next: bool # TODO: maybe rename to `next_version_to_stdout`
5453
git_output_to_stderr: bool
5554
increment_mode: str
56-
increment: Increment | None
55+
increment: VersionIncrement | None
5756
local_version: bool
5857
manual_version: str | None
5958
no_verify: bool
@@ -144,28 +143,23 @@ def _is_initial_tag(
144143
)
145144
return bool(questionary.confirm("Is this the first tag created?").ask())
146145

147-
def _find_increment(self, commits: list[git.GitCommit]) -> Increment | None:
146+
def _find_increment(self, commits: list[git.GitCommit]) -> VersionIncrement:
148147
# Update the bump map to ensure major version doesn't increment.
149-
# self.cz.bump_map = defaults.bump_map_major_version_zero
150-
bump_map = (
151-
self.cz.bump_map_major_version_zero
152-
if self.bump_settings["major_version_zero"]
153-
else self.cz.bump_map
154-
)
155-
bump_pattern = self.cz.bump_pattern
148+
is_major_version_zero = self.bump_settings["major_version_zero"]
156149

157-
if not bump_map or not bump_pattern:
158-
raise NoPatternMapError(
159-
f"'{self.config.settings['name']}' rule does not support bump"
160-
)
161-
return bump.find_increment(commits, regex=bump_pattern, increments_map=bump_map)
150+
return VersionIncrement.get_highest_by_messages(
151+
(commit.message for commit in commits),
152+
lambda x: self.cz.bump_rule.extract_increment(x, is_major_version_zero),
153+
)
162154

163155
def _validate_arguments(self, current_version: VersionProtocol) -> None:
164156
errors: list[str] = []
157+
increment = VersionIncrement.safe_cast(self.arguments["increment"])
158+
prerelease = Prerelease.safe_cast(self.arguments["prerelease"])
165159
if self.arguments["manual_version"]:
166160
for val, option in (
167-
(self.arguments["increment"], "--increment"),
168-
(self.arguments["prerelease"], "--prerelease"),
161+
(increment != VersionIncrement.NONE, "--increment"),
162+
(prerelease, "--prerelease"),
169163
(self.arguments["devrelease"] is not None, "--devrelease"),
170164
(self.arguments["local_version"], "--local-version"),
171165
(self.arguments["build_metadata"], "--build-metadata"),
@@ -186,8 +180,9 @@ def _validate_arguments(self, current_version: VersionProtocol) -> None:
186180

187181
def _resolve_increment_and_new_version(
188182
self, current_version: VersionProtocol, current_tag: git.GitTag | None
189-
) -> tuple[Increment | None, VersionProtocol]:
190-
increment = self.arguments["increment"]
183+
) -> tuple[VersionIncrement, VersionProtocol]:
184+
increment = VersionIncrement.safe_cast(self.arguments["increment"])
185+
prerelease = Prerelease.safe_cast(self.arguments["prerelease"])
191186
if manual_version := self.arguments["manual_version"]:
192187
try:
193188
return increment, self.scheme(manual_version)
@@ -197,7 +192,7 @@ def _resolve_increment_and_new_version(
197192
f"Invalid manual version: '{manual_version}'"
198193
) from exc
199194

200-
if increment is None:
195+
if increment == VersionIncrement.NONE:
201196
commits = git.get_commits(current_tag.name if current_tag else None)
202197

203198
# No commits, there is no need to create an empty tag.
@@ -214,8 +209,8 @@ def _resolve_increment_and_new_version(
214209
# It may happen that there are commits, but they are not eligible
215210
# for an increment, this generates a problem when using prerelease (#281)
216211
if (
217-
self.arguments["prerelease"]
218-
and increment is None
212+
prerelease
213+
and increment == VersionIncrement.NONE
219214
and not current_version.is_prerelease
220215
):
221216
raise NoCommitsFoundError(
@@ -225,12 +220,12 @@ def _resolve_increment_and_new_version(
225220
)
226221

227222
# we create an empty PATCH increment for empty tag
228-
if increment is None and self.arguments["allow_no_commit"]:
229-
increment = "PATCH"
223+
if self.arguments["allow_no_commit"]:
224+
increment = max(increment, VersionIncrement.PATCH)
230225

231226
return increment, current_version.bump(
232227
increment,
233-
prerelease=self.arguments["prerelease"],
228+
prerelease=prerelease,
234229
prerelease_offset=self.bump_settings["prerelease_offset"],
235230
devrelease=self.arguments["devrelease"],
236231
is_local_version=self.arguments["local_version"],
@@ -277,7 +272,10 @@ def __call__(self) -> None:
277272

278273
new_tag_version = rules.normalize_tag(new_version)
279274
if next_version_to_stdout:
280-
if increment is None and new_tag_version == current_tag_version:
275+
if (
276+
increment == VersionIncrement.NONE
277+
and new_tag_version == current_tag_version
278+
):
281279
raise NoneIncrementExit(
282280
"[NO_COMMITS_TO_BUMP]\n"
283281
"The commits found are not eligible to be bumped"
@@ -290,7 +288,7 @@ def __call__(self) -> None:
290288
)
291289
# Report found information
292290
information = f"{message}\ntag to create: {new_tag_version}\n"
293-
if increment:
291+
if increment != VersionIncrement.NONE:
294292
information += f"increment detected: {increment}\n"
295293

296294
if self.changelog_to_stdout:
@@ -301,7 +299,10 @@ def __call__(self) -> None:
301299
else:
302300
out.write(information)
303301

304-
if increment is None and new_tag_version == current_tag_version:
302+
if (
303+
increment == VersionIncrement.NONE
304+
and new_tag_version == current_tag_version
305+
):
305306
raise NoneIncrementExit(
306307
"[NO_COMMITS_TO_BUMP]\nThe commits found are not eligible to be bumped"
307308
)

0 commit comments

Comments
 (0)