Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit ffdaa62

Browse files
feat(bump_rule): add BumpRule, VersionIncrement, Prerelease Enum
Closes #129
1 parent 4b6b3fb commit ffdaa62

14 files changed

+1335
-512
lines changed

‎commitizen/bump.py‎

Lines changed: 3 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,60 +2,13 @@
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 cast
107

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

6013

6114
def update_version_in_files(

‎commitizen/bump_rule.py‎

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from collections.abc import Iterable, Mapping
5+
from enum import IntEnum, auto
6+
from functools import cached_property
7+
from typing import Callable, Protocol
8+
9+
from commitizen.exceptions import NoPatternMapError
10+
11+
12+
class VersionIncrement(IntEnum):
13+
"""An enumeration representing semantic versioning increments.
14+
15+
This class defines the three types of version increments according to semantic versioning:
16+
- PATCH: For backwards-compatible bug fixes
17+
- MINOR: For backwards-compatible functionality additions
18+
- MAJOR: For incompatible API changes
19+
"""
20+
21+
PATCH = auto()
22+
MINOR = auto()
23+
MAJOR = auto()
24+
25+
def __str__(self) -> str:
26+
return self.name
27+
28+
@classmethod
29+
def safe_cast(cls, value: object) -> VersionIncrement | None:
30+
if not isinstance(value, str):
31+
return None
32+
try:
33+
return cls[value]
34+
except KeyError:
35+
return None
36+
37+
@classmethod
38+
def safe_cast_dict(cls, d: Mapping[str, object]) -> dict[str, VersionIncrement]:
39+
return {
40+
k: v
41+
for k, v in ((k, VersionIncrement.safe_cast(v)) for k, v in d.items())
42+
if v is not None
43+
}
44+
45+
@staticmethod
46+
def get_highest_by_messages(
47+
commit_messages: Iterable[str],
48+
get_increment: Callable[[str], VersionIncrement | None],
49+
) -> VersionIncrement | None:
50+
"""Find the highest version increment from a list of messages.
51+
52+
This function processes a list of messages and determines the highest version
53+
increment needed based on the commit messages. It splits multi-line commit messages
54+
and evaluates each line using the provided get_increment callable.
55+
56+
Args:
57+
commit_messages: A list of messages to analyze.
58+
get_increment: A callable that takes a commit message string and returns an
59+
VersionIncrement value (MAJOR, MINOR, PATCH) or None if no increment is needed.
60+
61+
Returns:
62+
The highest version increment needed (MAJOR, MINOR, PATCH) or None if no
63+
increment is needed. The order of precedence is MAJOR > MINOR > PATCH.
64+
65+
Example:
66+
>>> commit_messages = ["feat: new feature", "fix: bug fix"]
67+
>>> rule = ConventionalCommitBumpRule()
68+
>>> VersionIncrement.get_highest_by_messages(commit_messages, lambda x: rule.get_increment(x, False))
69+
'MINOR'
70+
"""
71+
return VersionIncrement.get_highest(
72+
get_increment(line)
73+
for message in commit_messages
74+
for line in message.split("\n")
75+
)
76+
77+
@staticmethod
78+
def get_highest(
79+
increments: Iterable[VersionIncrement | None],
80+
) -> VersionIncrement | None:
81+
return max(filter(None, increments), default=None)
82+
83+
84+
class BumpRule(Protocol):
85+
"""A protocol defining the interface for version bump rules.
86+
87+
This protocol specifies the contract that all version bump rule implementations must follow.
88+
It defines how commit messages should be analyzed to determine the appropriate semantic
89+
version increment.
90+
91+
The protocol is used to ensure consistent behavior across different bump rule implementations,
92+
such as conventional commits or custom rules.
93+
"""
94+
95+
def get_increment(
96+
self, commit_message: str, major_version_zero: bool
97+
) -> VersionIncrement | None:
98+
"""Determine the version increment based on a commit message.
99+
100+
This method analyzes a commit message to determine what kind of version increment
101+
is needed according to the Conventional Commits specification. It handles special
102+
cases for breaking changes and respects the major_version_zero flag.
103+
104+
Args:
105+
commit_message: The commit message to analyze. Should follow conventional commit format.
106+
major_version_zero: If True, breaking changes will result in a MINOR version bump
107+
instead of MAJOR. This is useful for projects in 0.x.x versions.
108+
109+
Returns:
110+
VersionIncrement | None: The type of version increment needed:
111+
- MAJOR: For breaking changes when major_version_zero is False
112+
- MINOR: For breaking changes when major_version_zero is True, or for new features
113+
- PATCH: For bug fixes, performance improvements, or refactors
114+
- None: For commits that don't require a version bump (docs, style, etc.)
115+
"""
116+
117+
118+
class ConventionalCommitBumpRule(BumpRule):
119+
_BREAKING_CHANGE_TYPES = set(["BREAKING CHANGE", "BREAKING-CHANGE"])
120+
_MINOR_CHANGE_TYPES = set(["feat"])
121+
_PATCH_CHANGE_TYPES = set(["fix", "perf", "refactor"])
122+
123+
def get_increment(
124+
self, commit_message: str, major_version_zero: bool
125+
) -> VersionIncrement | None:
126+
if not (m := self._head_pattern.match(commit_message)):
127+
return None
128+
129+
change_type = m.group("change_type")
130+
if m.group("bang") or change_type in self._BREAKING_CHANGE_TYPES:
131+
return (
132+
VersionIncrement.MINOR if major_version_zero else VersionIncrement.MAJOR
133+
)
134+
135+
if change_type in self._MINOR_CHANGE_TYPES:
136+
return VersionIncrement.MINOR
137+
138+
if change_type in self._PATCH_CHANGE_TYPES:
139+
return VersionIncrement.PATCH
140+
141+
return None
142+
143+
@cached_property
144+
def _head_pattern(self) -> re.Pattern:
145+
change_types = [
146+
*self._BREAKING_CHANGE_TYPES,
147+
*self._PATCH_CHANGE_TYPES,
148+
*self._MINOR_CHANGE_TYPES,
149+
"docs",
150+
"style",
151+
"test",
152+
"build",
153+
"ci",
154+
]
155+
re_change_type = r"(?P<change_type>" + "|".join(change_types) + r")"
156+
re_scope = r"(?P<scope>\(.+\))?"
157+
re_bang = r"(?P<bang>!)?"
158+
return re.compile(f"^{re_change_type}{re_scope}{re_bang}:")
159+
160+
161+
class CustomBumpRule(BumpRule):
162+
def __init__(
163+
self,
164+
bump_pattern: str,
165+
bump_map: Mapping[str, VersionIncrement],
166+
bump_map_major_version_zero: Mapping[str, VersionIncrement],
167+
):
168+
"""Initialize a custom bump rule for version incrementing.
169+
170+
This constructor creates a rule that determines how version numbers should be
171+
incremented based on commit messages. It validates and compiles the provided
172+
pattern and maps for use in version bumping.
173+
174+
The fallback logic is used for backward compatibility.
175+
176+
Args:
177+
bump_pattern: A regex pattern string used to match commit messages.
178+
Example: r"^((?P<major>major)|(?P<minor>minor)|(?P<patch>patch))(?P<scope>\(.+\))?(?P<bang>!)?:"
179+
Or with fallback regex: r"^((BREAKING[\-\ ]CHANGE|\w+)(\(.+\))?!?):" # First group is type
180+
bump_map: A mapping of commit types to their corresponding version increments.
181+
Example: {
182+
"major": VersionIncrement.MAJOR,
183+
"bang": VersionIncrement.MAJOR,
184+
"minor": VersionIncrement.MINOR,
185+
"patch": VersionIncrement.PATCH
186+
}
187+
Or with fallback: {
188+
(r"^.+!$", VersionIncrement.MAJOR),
189+
(r"^BREAKING[\-\ ]CHANGE", VersionIncrement.MAJOR),
190+
(r"^feat", VersionIncrement.MINOR),
191+
(r"^fix", VersionIncrement.PATCH),
192+
(r"^refactor", VersionIncrement.PATCH),
193+
(r"^perf", VersionIncrement.PATCH),
194+
}
195+
bump_map_major_version_zero: A mapping of commit types to version increments
196+
specifically for when the major version is 0. This allows for different
197+
versioning behavior during initial development.
198+
The format is the same as bump_map.
199+
Example: {
200+
"major": VersionIncrement.MINOR, # MAJOR becomes MINOR in version zero
201+
"bang": VersionIncrement.MINOR, # Breaking changes become MINOR in version zero
202+
"minor": VersionIncrement.MINOR,
203+
"patch": VersionIncrement.PATCH
204+
}
205+
Or with fallback: {
206+
(r"^.+!$", VersionIncrement.MINOR),
207+
(r"^BREAKING[\-\ ]CHANGE", VersionIncrement.MINOR),
208+
(r"^feat", VersionIncrement.MINOR),
209+
(r"^fix", VersionIncrement.PATCH),
210+
(r"^refactor", VersionIncrement.PATCH),
211+
(r"^perf", VersionIncrement.PATCH),
212+
}
213+
214+
Raises:
215+
NoPatternMapError: If any of the required parameters are empty or None
216+
"""
217+
if not bump_map or not bump_pattern or not bump_map_major_version_zero:
218+
raise NoPatternMapError(
219+
f"Invalid bump rule: {bump_pattern=} and {bump_map=} and {bump_map_major_version_zero=}"
220+
)
221+
222+
self.bump_pattern = re.compile(bump_pattern)
223+
self.bump_map = bump_map
224+
self.bump_map_major_version_zero = bump_map_major_version_zero
225+
226+
def get_increment(
227+
self, commit_message: str, major_version_zero: bool
228+
) -> VersionIncrement | None:
229+
if not (m := self.bump_pattern.search(commit_message)):
230+
return None
231+
232+
effective_bump_map = (
233+
self.bump_map_major_version_zero if major_version_zero else self.bump_map
234+
)
235+
236+
try:
237+
if ret := VersionIncrement.get_highest(
238+
(
239+
increment
240+
for name, increment in effective_bump_map.items()
241+
if m.group(name)
242+
),
243+
):
244+
return ret
245+
except IndexError:
246+
pass
247+
248+
# Fallback to legacy bump rule, for backward compatibility
249+
found_keyword = m.group(1)
250+
for match_pattern, increment in effective_bump_map.items():
251+
if re.match(match_pattern, found_keyword):
252+
return increment
253+
return None

‎commitizen/commands/bump.py‎

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

99
from commitizen import bump, factory, git, hooks, out
10+
from commitizen.bump_rule import (
11+
VersionIncrement,
12+
)
1013
from commitizen.changelog_formats import get_changelog_format
1114
from commitizen.commands.changelog import Changelog
1215
from commitizen.config import BaseConfig
@@ -20,15 +23,13 @@
2023
InvalidManualVersion,
2124
NoCommitsFoundError,
2225
NoneIncrementExit,
23-
NoPatternMapError,
2426
NotAGitProjectError,
2527
NotAllowed,
2628
NoVersionSpecifiedError,
2729
)
2830
from commitizen.providers import get_provider
2931
from commitizen.tags import TagRules
3032
from commitizen.version_schemes import (
31-
Increment,
3233
InvalidVersion,
3334
Prerelease,
3435
get_version_scheme,
@@ -119,25 +120,14 @@ def is_initial_tag(
119120
is_initial = questionary.confirm("Is this the first tag created?").ask()
120121
return is_initial
121122

122-
def find_increment(self, commits: list[git.GitCommit]) -> Increment | None:
123+
def find_increment(self, commits: list[git.GitCommit]) -> VersionIncrement | None:
123124
# Update the bump map to ensure major version doesn't increment.
124-
is_major_version_zero: bool = self.bump_settings["major_version_zero"]
125-
# self.cz.bump_map = defaults.bump_map_major_version_zero
126-
bump_map = (
127-
self.cz.bump_map_major_version_zero
128-
if is_major_version_zero
129-
else self.cz.bump_map
130-
)
131-
bump_pattern = self.cz.bump_pattern
125+
is_major_version_zero = bool(self.bump_settings["major_version_zero"])
132126

133-
if not bump_map or not bump_pattern:
134-
raise NoPatternMapError(
135-
f"'{self.config.settings['name']}' rule does not support bump"
136-
)
137-
increment = bump.find_increment(
138-
commits, regex=bump_pattern, increments_map=bump_map
127+
return VersionIncrement.get_highest_by_messages(
128+
(commit.message for commit in commits),
129+
lambda x: self.cz.bump_rule.get_increment(x, is_major_version_zero),
139130
)
140-
return increment
141131

142132
def __call__(self) -> None: # noqa: C901
143133
"""Steps executed to bump."""
@@ -155,8 +145,8 @@ def __call__(self) -> None: # noqa: C901
155145

156146
dry_run: bool = self.arguments["dry_run"]
157147
is_yes: bool = self.arguments["yes"]
158-
increment: Increment|None=self.arguments["increment"]
159-
prerelease: Prerelease|None=self.arguments["prerelease"]
148+
increment=VersionIncrement.safe_cast(self.arguments["increment"])
149+
prerelease=Prerelease.safe_cast(self.arguments["prerelease"])
160150
devrelease: int | None = self.arguments["devrelease"]
161151
is_files_only: bool | None = self.arguments["files_only"]
162152
is_local_version: bool = self.arguments["local_version"]
@@ -272,7 +262,7 @@ def __call__(self) -> None: # noqa: C901
272262

273263
# we create an empty PATCH increment for empty tag
274264
if increment is None and allow_no_commit:
275-
increment = "PATCH"
265+
increment = VersionIncrement.PATCH
276266

277267
new_version = current_version.bump(
278268
increment,

0 commit comments

Comments
(0)

AltStyle によって変換されたページ (->オリジナル) /