Skip to content

Semver refactor #1432

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
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
53 changes: 3 additions & 50 deletions commitizen/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,60 +2,13 @@

import os
import re
from collections import OrderedDict
from glob import iglob
from logging import getLogger
from string import Template
from typing import cast

from commitizen.defaults import MAJOR, MINOR, PATCH, bump_message, encoding
from commitizen.defaults import bump_message, encoding
from commitizen.exceptions import CurrentVersionNotFoundError
from commitizen.git import GitCommit, smart_open
from commitizen.version_schemes import Increment, Version

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.git import smart_open
from commitizen.version_schemes import Version


def update_version_in_files(
Expand Down
194 changes: 194 additions & 0 deletions commitizen/bump_rule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
from __future__ import annotations

import re
from collections.abc import Iterable
from enum import Enum, auto
from functools import cached_property
from typing import Any, Callable, Protocol

from commitizen.exceptions import NoPatternMapError


class SemVerIncrement(Enum):
MAJOR = auto()
MINOR = auto()
PATCH = auto()

def __str__(self) -> str:
return self.name

@classmethod
def safe_cast(cls, value: str | None) -> SemVerIncrement | None:
if value is None:
return None
try:
return cls[value]
except ValueError:
return None

@classmethod
def safe_cast_dict(cls, d: dict[str, Any]) -> dict[str, SemVerIncrement]:
return {
k: v
for k, v in ((k, SemVerIncrement.safe_cast(v)) for k, v in d.items())
if v is not None
}


_VERSION_ORDERING = dict(
zip(
(None, SemVerIncrement.PATCH, SemVerIncrement.MINOR, SemVerIncrement.MAJOR),
range(4),
)
)


def find_increment_by_callable(
commit_messages: Iterable[str],
get_increment: Callable[[str], SemVerIncrement | None],
) -> SemVerIncrement | None:
"""Find the highest version increment from a list of messages.

This function processes a list of messages and determines the highest version
increment needed based on the commit messages. It splits multi-line commit messages
and evaluates each line using the provided get_increment callable.

Args:
commit_messages: A list of messages to analyze.
get_increment: A callable that takes a commit message string and returns an
SemVerIncrement value (MAJOR, MINOR, PATCH) or None if no increment is needed.

Returns:
The highest version increment needed (MAJOR, MINOR, PATCH) or None if no
increment is needed. The order of precedence is MAJOR > MINOR > PATCH.

Example:
>>> commit_messages = ["feat: new feature", "fix: bug fix"]
>>> rule = ConventionalCommitBumpRule()
>>> find_increment_by_callable(commit_messages, lambda x: rule.get_increment(x, False))
'MINOR'
"""
lines = (line for message in commit_messages for line in message.split("\n"))
increments = map(get_increment, lines)
return _find_highest_increment(increments)


def _find_highest_increment(
increments: Iterable[SemVerIncrement | None],
) -> SemVerIncrement | None:
return max(increments, key=lambda x: _VERSION_ORDERING[x], default=None)


class BumpRule(Protocol):
def get_increment(
self, commit_message: str, major_version_zero: bool
) -> SemVerIncrement | None:
"""Determine the version increment based on a commit message.

This method analyzes a commit message to determine what kind of version increment
is needed according to the Conventional Commits specification. It handles special
cases for breaking changes and respects the major_version_zero flag.

Args:
commit_message: The commit message to analyze. Should follow conventional commit format.
major_version_zero: If True, breaking changes will result in a MINOR version bump
instead of MAJOR. This is useful for projects in 0.x.x versions.

Returns:
SemVerIncrement | None: The type of version increment needed:
- "MAJOR": For breaking changes when major_version_zero is False
- "MINOR": For breaking changes when major_version_zero is True, or for new features
- "PATCH": For bug fixes, performance improvements, or refactors
- None: For commits that don't require a version bump (docs, style, etc.)
"""
...


class ConventionalCommitBumpRule(BumpRule):
_PATCH_CHANGE_TYPES = set(["fix", "perf", "refactor"])
_BREAKING_CHANGE = r"BREAKING[\-\ ]CHANGE"
_RE_BREAKING_CHANGE = re.compile(_BREAKING_CHANGE)

def get_increment(
self, commit_message: str, major_version_zero: bool
) -> SemVerIncrement | None:
if not (m := self._head_pattern.match(commit_message)):
return None

change_type = m.group("change_type")
if m.group("bang") or self._RE_BREAKING_CHANGE.match(change_type):
return (
SemVerIncrement.MINOR if major_version_zero else SemVerIncrement.MAJOR
)

if change_type == "feat":
return SemVerIncrement.MINOR

if change_type in self._PATCH_CHANGE_TYPES:
return SemVerIncrement.PATCH

return None

@cached_property
def _head_pattern(self) -> re.Pattern:
change_types = [
self._BREAKING_CHANGE,
"fix",
"feat",
"docs",
"style",
"refactor",
"perf",
"test",
"build",
"ci",
]
re_change_type = r"(?P<change_type>" + "|".join(change_types) + r")"
re_scope = r"(?P<scope>\(.+\))?"
re_bang = r"(?P<bang>!)?"
return re.compile(f"^{re_change_type}{re_scope}{re_bang}:")


class OldSchoolBumpRule(BumpRule):
"""TODO: rename?"""

def __init__(
self,
bump_pattern: str,
bump_map: dict[str, SemVerIncrement],
bump_map_major_version_zero: dict[str, SemVerIncrement],
):
if not bump_map or not bump_pattern or not bump_map_major_version_zero:
raise NoPatternMapError(
f"Invalid bump rule: {bump_pattern=} and {bump_map=} and {bump_map_major_version_zero=}"
)

self.bump_pattern = re.compile(bump_pattern)
self.bump_map = bump_map
self.bump_map_major_version_zero = bump_map_major_version_zero

def get_increment(
self, commit_message: str, major_version_zero: bool
) -> SemVerIncrement | None:
if not (m := self.bump_pattern.search(commit_message)):
return None

bump_map = (
self.bump_map_major_version_zero if major_version_zero else self.bump_map
)

try:
if ret := _find_highest_increment(
(increment for name, increment in bump_map.items() if m.group(name))
):
return ret
except IndexError:
# Fallback to old school bump rule
pass

# Fallback to old school bump rule
found_keyword = m.group(1)
for match_pattern, increment in bump_map.items():
if re.match(match_pattern, found_keyword):
return increment
return None
46 changes: 32 additions & 14 deletions commitizen/commands/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
import questionary

from commitizen import bump, factory, git, hooks, out
from commitizen.bump_rule import (
OldSchoolBumpRule,
SemVerIncrement,
find_increment_by_callable,
)
from commitizen.changelog_formats import get_changelog_format
from commitizen.commands.changelog import Changelog
from commitizen.config import BaseConfig
Expand All @@ -28,7 +33,6 @@
from commitizen.providers import get_provider
from commitizen.tags import TagRules
from commitizen.version_schemes import (
Increment,
InvalidVersion,
Prerelease,
get_version_scheme,
Expand Down Expand Up @@ -119,25 +123,37 @@ def is_initial_tag(
is_initial = questionary.confirm("Is this the first tag created?").ask()
return is_initial

def find_increment(self, commits: list[git.GitCommit]) -> Increment | None:
def find_increment(self, commits: list[git.GitCommit]) -> SemVerIncrement | None:
# Update the bump map to ensure major version doesn't increment.
is_major_version_zero: bool = self.bump_settings["major_version_zero"]
# self.cz.bump_map = defaults.bump_map_major_version_zero
bump_map = (
self.cz.bump_map_major_version_zero
if is_major_version_zero
else self.cz.bump_map

# Fallback to old school bump rule if no bump rule is provided
rule = self.cz.bump_rule or OldSchoolBumpRule(
*self._get_validated_cz_bump(),
)

return find_increment_by_callable(
(commit.message for commit in commits),
lambda x: rule.get_increment(x, is_major_version_zero),
)
bump_pattern = self.cz.bump_pattern

if not bump_map or not bump_pattern:
def _get_validated_cz_bump(
self,
) -> tuple[str, dict[str, SemVerIncrement], dict[str, SemVerIncrement]]:
"""For fixing the type errors"""
bump_pattern = self.cz.bump_pattern
bump_map = self.cz.bump_map
bump_map_major_version_zero = self.cz.bump_map_major_version_zero
if not bump_pattern or not bump_map or not bump_map_major_version_zero:
raise NoPatternMapError(
f"'{self.config.settings['name']}' rule does not support bump"
)
increment = bump.find_increment(
commits, regex=bump_pattern, increments_map=bump_map

return (
bump_pattern,
SemVerIncrement.safe_cast_dict(bump_map),
SemVerIncrement.safe_cast_dict(bump_map_major_version_zero),
)
return increment

def __call__(self) -> None: # noqa: C901
"""Steps executed to bump."""
Expand All @@ -155,7 +171,9 @@ def __call__(self) -> None: # noqa: C901

dry_run: bool = self.arguments["dry_run"]
is_yes: bool = self.arguments["yes"]
increment: Increment | None = self.arguments["increment"]
increment: SemVerIncrement | None = SemVerIncrement.safe_cast(
self.arguments["increment"]
)
prerelease: Prerelease | None = self.arguments["prerelease"]
devrelease: int | None = self.arguments["devrelease"]
is_files_only: bool | None = self.arguments["files_only"]
Expand Down Expand Up @@ -272,7 +290,7 @@ def __call__(self) -> None: # noqa: C901

# we create an empty PATCH increment for empty tag
if increment is None and allow_no_commit:
increment = "PATCH"
increment = SemVerIncrement.PATCH

new_version = current_version.bump(
increment,
Expand Down
5 changes: 5 additions & 0 deletions commitizen/cz/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from prompt_toolkit.styles import Style, merge_styles

from commitizen import git
from commitizen.bump_rule import BumpRule
from commitizen.config.base_config import BaseConfig
from commitizen.defaults import Questions

Expand All @@ -25,9 +26,13 @@ def __call__(


class BaseCommitizen(metaclass=ABCMeta):
bump_rule: BumpRule | None = None

# TODO: deprecate these
bump_pattern: str | None = None
bump_map: dict[str, str] | None = None
bump_map_major_version_zero: dict[str, str] | None = None

default_style_config: list[tuple[str, str]] = [
("qmark", "fg:#ff9d00 bold"),
("question", "bold"),
Expand Down
Loading
Loading