Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 4 additions & 50 deletions commitizen/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
15 changes: 10 additions & 5 deletions commitizen/commands/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"]
Expand All @@ -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] = []
Expand Down
19 changes: 14 additions & 5 deletions commitizen/commands/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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..
Expand Down
118 changes: 118 additions & 0 deletions commitizen/version_increment.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
Expand All @@ -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,
)
2 changes: 1 addition & 1 deletion docs/customization/config_file.md
Original file line number Diff line number Diff line change
Expand Up @@ -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] |
Expand Down
2 changes: 1 addition & 1 deletion docs/customization/python_class.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
76 changes: 76 additions & 0 deletions tests/commands/test_bump_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '^(?P<change_type>new|fix):\\s(?P<message>.+)$'
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 = '^(?P<change_type>docs|fix):\\s(?P<message>.+)$'
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
):
Expand Down
Loading
Loading