diff --git a/commitizen/bump.py b/commitizen/bump.py index 74d08381dc..f0e45e3432 100644 --- a/commitizen/bump.py +++ b/commitizen/bump.py @@ -4,18 +4,19 @@ import re from collections import OrderedDict from string import Template +from typing import cast from commitizen.defaults import MAJOR, MINOR, PATCH, bump_message, encoding from commitizen.exceptions import CurrentVersionNotFoundError from commitizen.git import GitCommit, smart_open -from commitizen.version_schemes import DEFAULT_SCHEME, Version, VersionScheme +from commitizen.version_schemes import DEFAULT_SCHEME, Increment, Version, VersionScheme VERSION_TYPES = [None, PATCH, MINOR, MAJOR] def find_increment( commits: list[GitCommit], regex: str, increments_map: dict | OrderedDict -) -> str | None: +) -> Increment | None: if isinstance(increments_map, dict): increments_map = OrderedDict(increments_map) @@ -42,7 +43,7 @@ def find_increment( if increment == MAJOR: break - return increment + return cast(Increment, increment) def update_version_in_files( diff --git a/commitizen/cli.py b/commitizen/cli.py index 12e3aa6451..c25bd4f713 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -230,6 +230,19 @@ def __call__( "choices": ["MAJOR", "MINOR", "PATCH"], "type": str.upper, }, + { + "name": ["--increment-mode"], + "choices": ["linear", "exact"], + "default": "linear", + "help": ( + "set the method by which the new version is chosen. " + "'linear' (default) guesses the next version based on typical linear version progression, " + "such that bumping of a pre-release with lower precedence than the current pre-release " + "phase maintains the current phase of higher precedence. " + "'exact' applies the changes that have been specified (or determined from the commit log) " + "without interpretation, such that the increment and pre-release are always honored" + ), + }, { "name": ["--check-consistency", "-cc"], "help": ( diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py index 7eb9ead144..c29c4c35c5 100644 --- a/commitizen/commands/bump.py +++ b/commitizen/commands/bump.py @@ -26,7 +26,9 @@ from commitizen.providers import get_provider from commitizen.version_schemes import ( get_version_scheme, + Increment, InvalidVersion, + Prerelease, ) logger = getLogger("commitizen") @@ -50,6 +52,7 @@ def __init__(self, config: BaseConfig, arguments: dict): "tag_format", "prerelease", "increment", + "increment_mode", "bump_message", "gpg_sign", "annotated_tag", @@ -112,7 +115,7 @@ def is_initial_tag(self, current_tag_version: str, is_yes: bool = False) -> bool is_initial = questionary.confirm("Is this the first tag created?").ask() return is_initial - def find_increment(self, commits: list[git.GitCommit]) -> str | None: + def find_increment(self, commits: list[git.GitCommit]) -> Increment | 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 @@ -132,7 +135,7 @@ def find_increment(self, commits: list[git.GitCommit]) -> str | None: ) return increment - def __call__(self): # noqa: C901 + def __call__(self) -> None: # noqa: C901 """Steps executed to bump.""" provider = get_provider(self.config) @@ -149,13 +152,14 @@ def __call__(self): # noqa: C901 dry_run: bool = self.arguments["dry_run"] is_yes: bool = self.arguments["yes"] - increment: str | None = self.arguments["increment"] - prerelease: str | None = self.arguments["prerelease"] + increment: Increment | None = self.arguments["increment"] + prerelease: Prerelease | None = self.arguments["prerelease"] devrelease: int | None = self.arguments["devrelease"] is_files_only: bool | None = self.arguments["files_only"] - is_local_version: bool | None = self.arguments["local_version"] + is_local_version: bool = self.arguments["local_version"] manual_version = self.arguments["manual_version"] build_metadata = self.arguments["build_metadata"] + increment_mode: str = self.arguments["increment_mode"] if manual_version: if increment: @@ -205,21 +209,10 @@ def __call__(self): # noqa: C901 scheme=self.scheme, ) - is_initial = self.is_initial_tag(current_tag_version, is_yes) - if is_initial: - commits = git.get_commits() - else: - commits = git.get_commits(current_tag_version) - # If user specified changelog_to_stdout, they probably want the # changelog to be generated as well, this is the most intuitive solution self.changelog = self.changelog or bool(self.changelog_to_stdout) - # No commits, there is no need to create an empty tag. - # Unless we previously had a prerelease. - if not commits and not current_version.is_prerelease: - raise NoCommitsFoundError("[NO_COMMITS_FOUND]\n" "No new commits found.") - if manual_version: try: new_version = self.scheme(manual_version) @@ -230,6 +223,19 @@ def __call__(self): # noqa: C901 ) from exc else: if increment is None: + is_initial = self.is_initial_tag(current_tag_version, is_yes) + if is_initial: + commits = git.get_commits() + else: + commits = git.get_commits(current_tag_version) + + # No commits, there is no need to create an empty tag. + # Unless we previously had a prerelease. + if not commits and not current_version.is_prerelease: + raise NoCommitsFoundError( + "[NO_COMMITS_FOUND]\n" "No new commits found." + ) + increment = self.find_increment(commits) # It may happen that there are commits, but they are not eligible @@ -248,6 +254,7 @@ def __call__(self): # noqa: C901 devrelease=devrelease, is_local_version=is_local_version, build_metadata=build_metadata, + exact_increment=increment_mode == "exact", ) new_tag_version = bump.normalize_tag( @@ -349,6 +356,7 @@ def __call__(self): # noqa: C901 if is_files_only: raise ExpectedExit() + # FIXME: check if any changes have been staged c = git.commit(message, args=self._get_commit_args()) if self.retry and c.return_code != 0 and self.changelog: # Maybe pre-commit reformatted some files? Retry once @@ -404,7 +412,7 @@ def __call__(self): # noqa: C901 else: out.success("Done!") - def _get_commit_args(self): + def _get_commit_args(self) -> str: commit_args = ["-a"] if self.no_verify: commit_args.append("--no-verify") diff --git a/commitizen/version_schemes.py b/commitizen/version_schemes.py index 0b04c4bc36..ec04fde1e7 100644 --- a/commitizen/version_schemes.py +++ b/commitizen/version_schemes.py @@ -4,7 +4,16 @@ import sys import warnings from itertools import zip_longest -from typing import TYPE_CHECKING, Any, ClassVar, Protocol, Type, cast, runtime_checkable +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Literal, + Protocol, + Type, + cast, + runtime_checkable, +) import importlib_metadata as metadata from packaging.version import InvalidVersion # noqa: F401: Rexpose the common exception @@ -28,6 +37,8 @@ from typing import Self +Increment: TypeAlias = Literal["MAJOR", "MINOR", "PATCH"] +Prerelease: TypeAlias = Literal["alpha", "beta", "rc"] DEFAULT_VERSION_PARSER = r"v?(?P([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?(\w+)?)" @@ -113,16 +124,23 @@ def __ne__(self, other: object) -> bool: def bump( self, - increment: str, - prerelease: str | None = None, + increment: Increment | None, + prerelease: Prerelease | None = None, prerelease_offset: int = 0, devrelease: int | None = None, is_local_version: bool = False, build_metadata: str | None = None, - force_bump: bool = False, + exact_increment: bool = False, ) -> Self: """ Based on the given increment, generate the next bumped version according to the version scheme + + Args: + increment: The component to increase + prerelease: The type of prerelease, if Any + is_local_version: Whether to increment the local version instead + exact_increment: Treat the increment and prerelease arguments explicitly. Disables logic + that attempts to deduce the correct increment when a prelease suffix is present. """ @@ -203,7 +221,7 @@ def generate_build_metadata(self, build_metadata: str | None) -> str: return f"+{build_metadata}" - def increment_base(self, increment: str | None = None) -> str: + def increment_base(self, increment: Increment | None = None) -> str: prev_release = list(self.release) increments = [MAJOR, MINOR, PATCH] base = dict(zip_longest(increments, prev_release, fillvalue=0)) @@ -222,13 +240,13 @@ def increment_base(self, increment: str | None = None) -> str: def bump( self, - increment: str, - prerelease: str | None = None, + increment: Increment | None, + prerelease: Prerelease | None = None, prerelease_offset: int = 0, devrelease: int | None = None, is_local_version: bool = False, build_metadata: str | None = None, - force_bump: bool = False, + exact_increment: bool = False, ) -> Self: """Based on the given increment a proper semver will be generated. @@ -248,7 +266,7 @@ def bump( else: if not self.is_prerelease: base = self.increment_base(increment) - elif force_bump: + elif exact_increment: base = self.increment_base(increment) else: base = f"{self.major}.{self.minor}.{self.micro}" diff --git a/docs/bump.md b/docs/bump.md index 6045024dd4..9a968f7500 100644 --- a/docs/bump.md +++ b/docs/bump.md @@ -79,6 +79,12 @@ options: specify non-negative integer for dev. release --increment {MAJOR,MINOR,PATCH} manually specify the desired increment + --increment-mode + set the method by which the new version is chosen. 'linear' (default) guesses the next version based + on typical linear version progression, such that bumping of a pre-release with lower precedence than + the current pre-release phase maintains the current phase of higher precedence. 'exact' applies the + changes that have been specified (or determined from the commit log) without interpretation, such that + the increment and pre-release are always honored --check-consistency, -cc check consistency among versions defined in commitizen configuration and version_files --annotated-tag, -at create annotated tag instead of lightweight one @@ -139,9 +145,27 @@ by their precedence and showcase how a release might flow through a development - `1.1.0rc0` after bumping the release candidate - `1.1.0` next feature release -Also note that bumping pre-releases _maintains linearity_: bumping of a pre-release with lower precedence than -the current pre-release phase maintains the current phase of higher precedence. For example, if the current -version is `1.0.0b1` then bumping with `--prerelease alpha` will continue to bump the “beta” phase. +### `--increment-mode` + +By default, `--increment-mode` is set to `linear`, which ensures taht bumping pre-releases _maintains linearity_: +bumping of a pre-release with lower precedence than the current pre-release phase maintains the current phase of +higher precedence. For example, if the current version is `1.0.0b1` then bumping with `--prerelease alpha` will +continue to bump the “beta” phase. + +Setting `--increment-mode` to `exact` instructs `cz bump` to instead apply the +exact changes that have been specified with `--increment` or determined from the commit log. For example, +`--prerelease beta` will always result in a `b` tag, and `--increment PATCH` will always increase the patch component. + +Below are some examples that illustrate the difference in behavior: + +| Increment | Pre-release | Start Version | `--increment-mode=linear` | `--increment-mode=exact` | +|-----------|-------------|---------------|---------------------------|--------------------------| +| `MAJOR` | | `2.0.0b0` | `2.0.0` | `3.0.0` | +| `MINOR` | | `2.0.0b0` | `2.0.0` | `2.1.0` | +| `PATCH` | | `2.0.0b0` | `2.0.0` | `2.0.1` | +| `MAJOR` | `alpha` | `2.0.0b0` | `3.0.0a0` | `3.0.0a0` | +| `MINOR` | `alpha` | `2.0.0b0` | `2.0.0b1` | `2.1.0a0` | +| `PATCH` | `alpha` | `2.0.0b0` | `2.0.0b1` | `2.0.1a0` | ### `--check-consistency` diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index 9a877c9ac0..b39271f284 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -314,6 +314,69 @@ def test_bump_command_prelease_increment(mocker: MockFixture): assert git.tag_exist("1.0.0a0") +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_command_prelease_exact_mode(mocker: MockFixture): + # PRERELEASE + create_file_and_commit("feat: location") + + testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0a0") + assert tag_exists is True + + # PRERELEASE + PATCH BUMP + testargs = [ + "cz", + "bump", + "--prerelease", + "alpha", + "--yes", + "--increment-mode=exact", + ] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0a1") + assert tag_exists is True + + # PRERELEASE + MINOR BUMP + # --increment-mode allows the minor version to bump, and restart the prerelease + create_file_and_commit("feat: location") + + testargs = [ + "cz", + "bump", + "--prerelease", + "alpha", + "--yes", + "--increment-mode=exact", + ] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.3.0a0") + assert tag_exists is True + + # PRERELEASE + MAJOR BUMP + # --increment-mode=exact allows the major version to bump, and restart the prerelease + testargs = [ + "cz", + "bump", + "--prerelease", + "alpha", + "--yes", + "--increment=MAJOR", + "--increment-mode=exact", + ] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("1.0.0a0") + assert tag_exists is True + + @pytest.mark.usefixtures("tmp_commitizen_project") def test_bump_on_git_with_hooks_no_verify_disabled(mocker: MockFixture): """Bump commit without --no-verify""" diff --git a/tests/test_version_scheme_pep440.py b/tests/test_version_scheme_pep440.py index ac99450652..6b1f621cb8 100644 --- a/tests/test_version_scheme_pep440.py +++ b/tests/test_version_scheme_pep440.py @@ -115,6 +115,9 @@ (("2.0.0a4", "PATCH", "alpha", 0, None), "2.0.0a5"), (("2.0.0a5", "MAJOR", "alpha", 0, None), "2.0.0a6"), # + (("2.0.0b0", "MINOR", "alpha", 0, None), "2.0.0b1"), + (("2.0.0b0", "PATCH", "alpha", 0, None), "2.0.0b1"), + # (("1.0.1a0", "PATCH", None, 0, None), "1.0.1"), (("1.0.1a0", "MINOR", None, 0, None), "1.1.0"), (("1.0.1a0", "MAJOR", None, 0, None), "2.0.0"), @@ -141,27 +144,43 @@ (("3.1.4a0", "MAJOR", "alpha", 0, None), "4.0.0a0"), ] - -# test driven development -sortability = [ - "0.10.0a0", - "0.1.1", - "0.1.2", - "2.1.1", - "3.0.0", - "0.9.1a0", - "1.0.0a1", - "1.0.0b1", - "1.0.0a1", - "1.0.0a2.dev1", - "1.0.0rc2", - "1.0.0a3.dev0", - "1.0.0a2.dev0", - "1.0.0a3.dev1", - "1.0.0a2.dev0", - "1.0.0b0", - "1.0.0rc0", - "1.0.0rc1", +excact_cases = [ + (("1.0.0", "PATCH", None, 0, None), "1.0.1"), + (("1.0.0", "MINOR", None, 0, None), "1.1.0"), + # with exact_increment=False: "1.0.0b0" + (("1.0.0a1", "PATCH", "beta", 0, None), "1.0.1b0"), + # with exact_increment=False: "1.0.0b1" + (("1.0.0b0", "PATCH", "beta", 0, None), "1.0.1b0"), + # with exact_increment=False: "1.0.0rc0" + (("1.0.0b1", "PATCH", "rc", 0, None), "1.0.1rc0"), + # with exact_increment=False: "1.0.0-rc1" + (("1.0.0rc0", "PATCH", "rc", 0, None), "1.0.1rc0"), + # with exact_increment=False: "1.0.0rc1-dev1" + (("1.0.0rc0", "PATCH", "rc", 0, 1), "1.0.1rc0.dev1"), + # with exact_increment=False: "1.0.0b0" + (("1.0.0a1", "MINOR", "beta", 0, None), "1.1.0b0"), + # with exact_increment=False: "1.0.0b1" + (("1.0.0b0", "MINOR", "beta", 0, None), "1.1.0b0"), + # with exact_increment=False: "1.0.0b1" + (("1.0.0b0", "MINOR", "alpha", 0, None), "1.1.0a0"), + # with exact_increment=False: "1.0.0rc0" + (("1.0.0b1", "MINOR", "rc", 0, None), "1.1.0rc0"), + # with exact_increment=False: "1.0.0rc1" + (("1.0.0rc0", "MINOR", "rc", 0, None), "1.1.0rc0"), + # with exact_increment=False: "1.0.0rc1-dev1" + (("1.0.0rc0", "MINOR", "rc", 0, 1), "1.1.0rc0.dev1"), + # with exact_increment=False: "2.0.0" + (("2.0.0b0", "MAJOR", None, 0, None), "3.0.0"), + # with exact_increment=False: "2.0.0" + (("2.0.0b0", "MINOR", None, 0, None), "2.1.0"), + # with exact_increment=False: "2.0.0" + (("2.0.0b0", "PATCH", None, 0, None), "2.0.1"), + # same with exact_increment=False + (("2.0.0b0", "MAJOR", "alpha", 0, None), "3.0.0a0"), + # with exact_increment=False: "2.0.0b1" + (("2.0.0b0", "MINOR", "alpha", 0, None), "2.1.0a0"), + # with exact_increment=False: "2.0.0b1" + (("2.0.0b0", "PATCH", "alpha", 0, None), "2.0.1a0"), ] @@ -194,6 +213,27 @@ def test_bump_pep440_version(test_input, expected): ) +@pytest.mark.parametrize("test_input, expected", excact_cases) +def test_bump_pep440_version_force(test_input, expected): + current_version = test_input[0] + increment = test_input[1] + prerelease = test_input[2] + prerelease_offset = test_input[3] + devrelease = test_input[4] + assert ( + str( + Pep440(current_version).bump( + increment=increment, + prerelease=prerelease, + prerelease_offset=prerelease_offset, + devrelease=devrelease, + exact_increment=True, + ) + ) + == expected + ) + + @pytest.mark.parametrize("test_input,expected", local_versions) def test_bump_pep440_version_local(test_input, expected): current_version = test_input[0] diff --git a/tests/test_version_scheme_semver.py b/tests/test_version_scheme_semver.py index a0d6e14b50..71d5e5876c 100644 --- a/tests/test_version_scheme_semver.py +++ b/tests/test_version_scheme_semver.py @@ -83,6 +83,43 @@ (("1.0.0-alpha1", None, "alpha", 0, None), "1.0.0-a2"), ] +excact_cases = [ + (("1.0.0", "PATCH", None, 0, None), "1.0.1"), + (("1.0.0", "MINOR", None, 0, None), "1.1.0"), + # with exact_increment=False: "1.0.0-b0" + (("1.0.0a1", "PATCH", "beta", 0, None), "1.0.1-b0"), + # with exact_increment=False: "1.0.0-b1" + (("1.0.0b0", "PATCH", "beta", 0, None), "1.0.1-b0"), + # with exact_increment=False: "1.0.0-rc0" + (("1.0.0b1", "PATCH", "rc", 0, None), "1.0.1-rc0"), + # with exact_increment=False: "1.0.0-rc1" + (("1.0.0rc0", "PATCH", "rc", 0, None), "1.0.1-rc0"), + # with exact_increment=False: "1.0.0-rc1-dev1" + (("1.0.0rc0", "PATCH", "rc", 0, 1), "1.0.1-rc0-dev1"), + # with exact_increment=False: "1.0.0-b0" + (("1.0.0a1", "MINOR", "beta", 0, None), "1.1.0-b0"), + # with exact_increment=False: "1.0.0-b1" + (("1.0.0b0", "MINOR", "beta", 0, None), "1.1.0-b0"), + # with exact_increment=False: "1.0.0-rc0" + (("1.0.0b1", "MINOR", "rc", 0, None), "1.1.0-rc0"), + # with exact_increment=False: "1.0.0-rc1" + (("1.0.0rc0", "MINOR", "rc", 0, None), "1.1.0-rc0"), + # with exact_increment=False: "1.0.0-rc1-dev1" + (("1.0.0rc0", "MINOR", "rc", 0, 1), "1.1.0-rc0-dev1"), + # with exact_increment=False: "2.0.0" + (("2.0.0b0", "MAJOR", None, 0, None), "3.0.0"), + # with exact_increment=False: "2.0.0" + (("2.0.0b0", "MINOR", None, 0, None), "2.1.0"), + # with exact_increment=False: "2.0.0" + (("2.0.0b0", "PATCH", None, 0, None), "2.0.1"), + # same with exact_increment=False + (("2.0.0b0", "MAJOR", "alpha", 0, None), "3.0.0-a0"), + # with exact_increment=False: "2.0.0b1" + (("2.0.0b0", "MINOR", "alpha", 0, None), "2.1.0-a0"), + # with exact_increment=False: "2.0.0b1" + (("2.0.0b0", "PATCH", "alpha", 0, None), "2.0.1-a0"), +] + @pytest.mark.parametrize( "test_input, expected", @@ -107,6 +144,27 @@ def test_bump_semver_version(test_input, expected): ) +@pytest.mark.parametrize("test_input, expected", excact_cases) +def test_bump_semver_version_force(test_input, expected): + current_version = test_input[0] + increment = test_input[1] + prerelease = test_input[2] + prerelease_offset = test_input[3] + devrelease = test_input[4] + assert ( + str( + SemVer(current_version).bump( + increment=increment, + prerelease=prerelease, + prerelease_offset=prerelease_offset, + devrelease=devrelease, + exact_increment=True, + ) + ) + == expected + ) + + @pytest.mark.parametrize("test_input,expected", local_versions) def test_bump_semver_version_local(test_input, expected): current_version = test_input[0]