Skip to content

Commit 4271c36

Browse files
feat(bump_rule): add BumpRule, VersionIncrement enum, ConventionalCommitBumpRule
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4d99415 commit 4271c36

19 files changed

Lines changed: 1370 additions & 518 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

0 commit comments

Comments
 (0)