diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py index af365ecd2..b2ecd3130 100644 --- a/commitizen/commands/bump.py +++ b/commitizen/commands/bump.py @@ -24,7 +24,11 @@ ) from commitizen.changelog_formats import get_changelog_format from commitizen.providers import get_provider -from commitizen.version_schemes import InvalidVersion, get_version_scheme +from commitizen.version_schemes import ( + get_version_scheme, + InvalidVersion, + VersionProtocol, +) logger = getLogger("commitizen") @@ -226,11 +230,6 @@ def __call__(self): # noqa: C901 "To avoid this error, manually specify the type of increment with `--increment`" ) - # Increment is removed when current and next version - # are expected to be prereleases. - if prerelease and current_version.is_prerelease: - increment = None - new_version = current_version.bump( increment, prerelease=prerelease, @@ -398,3 +397,33 @@ def _get_commit_args(self): if self.no_verify: commit_args.append("--no-verify") return " ".join(commit_args) + + def find_previous_final_version( + self, current_version: VersionProtocol + ) -> VersionProtocol | None: + tag_format: str = self.bump_settings["tag_format"] + current = bump.normalize_tag( + current_version, + tag_format=tag_format, + scheme=self.scheme, + ) + + final_versions = [] + for tag in git.get_tag_names(): + assert tag + try: + version = self.scheme(tag) + if not version.is_prerelease or tag == current: + final_versions.append(version) + except InvalidVersion: + continue + + if not final_versions: + return None + + final_versions = sorted(final_versions) # type: ignore [type-var] + current_index = final_versions.index(current_version) + previous_index = current_index - 1 + if previous_index < 0: + return None + return final_versions[previous_index] diff --git a/commitizen/version_schemes.py b/commitizen/version_schemes.py index d7967ae92..c277d2efc 100644 --- a/commitizen/version_schemes.py +++ b/commitizen/version_schemes.py @@ -100,6 +100,7 @@ def bump( prerelease_offset: int = 0, devrelease: int | None = None, is_local_version: bool = False, + force_bump: bool = False, ) -> Self: """ Based on the given increment, generate the next bumped version according to the version scheme @@ -146,6 +147,12 @@ def generate_prerelease( if not prerelease: return "" + # prevent down-bumping the pre-release phase, e.g. from 'b1' to 'a2' + # https://packaging.python.org/en/latest/specifications/version-specifiers/#pre-releases + # https://semver.org/#spec-item-11 + if self.is_prerelease and self.pre: + prerelease = max(prerelease, self.pre[0]) + # version.pre is needed for mypy check if self.is_prerelease and self.pre and prerelease.startswith(self.pre[0]): prev_prerelease: int = self.pre[1] @@ -171,20 +178,15 @@ def increment_base(self, increment: str | None = None) -> str: increments = [MAJOR, MINOR, PATCH] base = dict(zip_longest(increments, prev_release, fillvalue=0)) - # This flag means that current version - # must remove its prerelease tag, - # so it doesn't matter the increment. - # Example: 1.0.0a0 with PATCH/MINOR -> 1.0.0 - if not self.is_prerelease: - if increment == MAJOR: - base[MAJOR] += 1 - base[MINOR] = 0 - base[PATCH] = 0 - elif increment == MINOR: - base[MINOR] += 1 - base[PATCH] = 0 - elif increment == PATCH: - base[PATCH] += 1 + if increment == MAJOR: + base[MAJOR] += 1 + base[MINOR] = 0 + base[PATCH] = 0 + elif increment == MINOR: + base[MINOR] += 1 + base[PATCH] = 0 + elif increment == PATCH: + base[PATCH] += 1 return f"{base[MAJOR]}.{base[MINOR]}.{base[PATCH]}" @@ -195,6 +197,7 @@ def bump( prerelease_offset: int = 0, devrelease: int | None = None, is_local_version: bool = False, + force_bump: bool = False, ) -> Self: """Based on the given increment a proper semver will be generated. @@ -212,9 +215,34 @@ def bump( local_version = self.scheme(self.local).bump(increment) return self.scheme(f"{self.public}+{local_version}") # type: ignore else: - base = self.increment_base(increment) + if not self.is_prerelease: + base = self.increment_base(increment) + elif force_bump: + base = self.increment_base(increment) + else: + base = f"{self.major}.{self.minor}.{self.micro}" + if increment == PATCH: + pass + elif increment == MINOR: + if self.micro != 0: + base = self.increment_base(increment) + elif increment == MAJOR: + if self.minor != 0 or self.micro != 0: + base = self.increment_base(increment) dev_version = self.generate_devrelease(devrelease) - pre_version = self.generate_prerelease(prerelease, offset=prerelease_offset) + release = list(self.release) + if len(release) < 3: + release += [0] * (3 - len(release)) + current_base = ".".join(str(part) for part in release) + if base == current_base: + pre_version = self.generate_prerelease( + prerelease, offset=prerelease_offset + ) + else: + base_version = cast(BaseVersion, self.scheme(base)) + pre_version = base_version.generate_prerelease( + prerelease, offset=prerelease_offset + ) # TODO: post version return self.scheme(f"{base}{pre_version}{dev_version}") # type: ignore diff --git a/docs/bump.md b/docs/bump.md index 75d6eb7c1..1df5e227d 100644 --- a/docs/bump.md +++ b/docs/bump.md @@ -113,6 +113,34 @@ Generate a **changelog** along with the new version and tag when bumping. cz bump --changelog ``` +### `--prerelease` + +The bump is a pre-release bump, meaning that in addition to a possible version bump the new version receives a +pre-release segment compatible with the bump’s version scheme, where the segment consist of a _phase_ and a +non-negative number. Supported options for `--prerelease` are the following phase names `alpha`, `beta`, or +`rc` (release candidate). For more details, refer to the +[Python Packaging User Guide](https://packaging.python.org/en/latest/specifications/version-specifiers/#pre-releases). + +Note that as per [semantic versioning spec](https://semver.org/#spec-item-9) + +> Pre-release versions have a lower precedence than the associated normal version. A pre-release version +> indicates that the version is unstable and might not satisfy the intended compatibility requirements +> as denoted by its associated normal version. + +For example, the following versions (using the [PEP 440](https://peps.python.org/pep-0440/) scheme) are ordered +by their precedence and showcase how a release might flow through a development cycle: + +- `1.0.0` is the current published version +- `1.0.1a0` after committing a `fix:` for pre-release +- `1.1.0a1` after committing an additional `feat:` for pre-release +- `1.1.0b0` after bumping a beta release +- `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. + ### `--check-consistency` Check whether the versions defined in `version_files` and the version in commitizen diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index 774ec0a95..d05c8f330 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -208,9 +208,9 @@ def test_bump_command_increment_option( @pytest.mark.usefixtures("tmp_commitizen_project") def test_bump_command_prelease(mocker: MockFixture): - # PRERELEASE create_file_and_commit("feat: location") + # Create an alpha pre-release. testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"] mocker.patch.object(sys, "argv", testargs) cli.main() @@ -218,7 +218,58 @@ def test_bump_command_prelease(mocker: MockFixture): tag_exists = git.tag_exist("0.2.0a0") assert tag_exists is True - # PRERELEASE BUMP CREATES VERSION WITHOUT PRERELEASE + # Create a beta pre-release. + testargs = ["cz", "bump", "--prerelease", "beta", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0b0") + assert tag_exists is True + + # With a current beta pre-release, bumping alpha must bump beta + # because we can't bump "backwards". + testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0a1") + assert tag_exists is False + tag_exists = git.tag_exist("0.2.0b1") + assert tag_exists is True + + # Create a rc pre-release. + testargs = ["cz", "bump", "--prerelease", "rc", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0rc0") + assert tag_exists is True + + # With a current rc pre-release, bumping alpha must bump rc. + testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0a1") + assert tag_exists is False + tag_exists = git.tag_exist("0.2.0b2") + assert tag_exists is False + tag_exists = git.tag_exist("0.2.0rc1") + assert tag_exists is True + + # With a current rc pre-release, bumping beta must bump rc. + testargs = ["cz", "bump", "--prerelease", "beta", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0a2") + assert tag_exists is False + tag_exists = git.tag_exist("0.2.0b2") + assert tag_exists is False + tag_exists = git.tag_exist("0.2.0rc2") + assert tag_exists is True + + # Create a final release from the current pre-release. testargs = ["cz", "bump"] mocker.patch.object(sys, "argv", testargs) cli.main() @@ -227,6 +278,42 @@ def test_bump_command_prelease(mocker: MockFixture): assert tag_exists is True +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_command_prelease_increment(mocker: MockFixture): + # FINAL RELEASE + create_file_and_commit("fix: location") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + assert git.tag_exist("0.1.1") + + # PRERELEASE + create_file_and_commit("fix: location") + + testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + assert git.tag_exist("0.1.2a0") + + create_file_and_commit("feat: location") + + testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + assert git.tag_exist("0.2.0a0") + + create_file_and_commit("feat!: breaking") + + testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + assert git.tag_exist("1.0.0a0") + + @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/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_beta_alpha_.md b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_beta_alpha_.md index 300ca9575..ec9ab11d7 100644 --- a/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_beta_alpha_.md +++ b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_beta_alpha_.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## 0.2.0a0 (2021-06-11) +## 0.2.0b1 (2021-06-11) ## 0.2.0b0 (2021-06-11) diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_rc_alpha_.md b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_rc_alpha_.md index 9a7e9f161..e1c20b7e0 100644 --- a/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_rc_alpha_.md +++ b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_rc_alpha_.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## 0.2.0a0 (2021-06-11) +## 0.2.0rc1 (2021-06-11) ## 0.2.0rc0 (2021-06-11) diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_rc_beta_.md b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_rc_beta_.md index b3be6f3c1..e1c20b7e0 100644 --- a/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_rc_beta_.md +++ b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_rc_beta_.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## 0.2.0b0 (2021-06-11) +## 0.2.0rc1 (2021-06-11) ## 0.2.0rc0 (2021-06-11) diff --git a/tests/test_version_scheme_pep440.py b/tests/test_version_scheme_pep440.py index 89f5a137a..aa2120fad 100644 --- a/tests/test_version_scheme_pep440.py +++ b/tests/test_version_scheme_pep440.py @@ -43,10 +43,11 @@ (("4.5.0+0.2.0", "MAJOR", None, 0, None), "4.5.0+1.0.0"), ] -# this cases should be handled gracefully -unexpected_cases = [ - (("0.1.1rc0", None, "alpha", 0, None), "0.1.1a0"), - (("0.1.1b1", None, "alpha", 0, None), "0.1.1a0"), +# never bump backwards on pre-releases +linear_prerelease_cases = [ + (("0.1.1b1", None, "alpha", 0, None), "0.1.1b2"), + (("0.1.1rc0", None, "alpha", 0, None), "0.1.1rc1"), + (("0.1.1rc0", None, "beta", 0, None), "0.1.1rc1"), ] weird_cases = [ @@ -78,10 +79,76 @@ (("1.0.0rc1", None, "rc", 0, None), "1.0.0rc2"), ] +# additional pre-release tests run through various release scenarios +prerelease_cases = [ + # + (("3.3.3", "PATCH", "alpha", 0, None), "3.3.4a0"), + (("3.3.4a0", "PATCH", "alpha", 0, None), "3.3.4a1"), + (("3.3.4a1", "MINOR", "alpha", 0, None), "3.4.0a0"), + (("3.4.0a0", "PATCH", "alpha", 0, None), "3.4.0a1"), + (("3.4.0a1", "MINOR", "alpha", 0, None), "3.4.0a2"), + (("3.4.0a2", "MAJOR", "alpha", 0, None), "4.0.0a0"), + (("4.0.0a0", "PATCH", "alpha", 0, None), "4.0.0a1"), + (("4.0.0a1", "MINOR", "alpha", 0, None), "4.0.0a2"), + (("4.0.0a2", "MAJOR", "alpha", 0, None), "4.0.0a3"), + # + (("1.0.0", "PATCH", "alpha", 0, None), "1.0.1a0"), + (("1.0.1a0", "PATCH", "alpha", 0, None), "1.0.1a1"), + (("1.0.1a1", "MINOR", "alpha", 0, None), "1.1.0a0"), + (("1.1.0a0", "PATCH", "alpha", 0, None), "1.1.0a1"), + (("1.1.0a1", "MINOR", "alpha", 0, None), "1.1.0a2"), + (("1.1.0a2", "MAJOR", "alpha", 0, None), "2.0.0a0"), + # + (("1.0.0", "MINOR", "alpha", 0, None), "1.1.0a0"), + (("1.1.0a0", "PATCH", "alpha", 0, None), "1.1.0a1"), + (("1.1.0a1", "MINOR", "alpha", 0, None), "1.1.0a2"), + (("1.1.0a2", "PATCH", "alpha", 0, None), "1.1.0a3"), + (("1.1.0a3", "MAJOR", "alpha", 0, None), "2.0.0a0"), + # + (("1.0.0", "MAJOR", "alpha", 0, None), "2.0.0a0"), + (("2.0.0a0", "MINOR", "alpha", 0, None), "2.0.0a1"), + (("2.0.0a1", "PATCH", "alpha", 0, None), "2.0.0a2"), + (("2.0.0a2", "MAJOR", "alpha", 0, None), "2.0.0a3"), + (("2.0.0a3", "MINOR", "alpha", 0, None), "2.0.0a4"), + (("2.0.0a4", "PATCH", "alpha", 0, None), "2.0.0a5"), + (("2.0.0a5", "MAJOR", "alpha", 0, None), "2.0.0a6"), + # + (("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"), + # + (("1.1.0a0", "PATCH", None, 0, None), "1.1.0"), + (("1.1.0a0", "MINOR", None, 0, None), "1.1.0"), + (("1.1.0a0", "MAJOR", None, 0, None), "2.0.0"), + # + (("2.0.0a0", "MINOR", None, 0, None), "2.0.0"), + (("2.0.0a0", "MAJOR", None, 0, None), "2.0.0"), + (("2.0.0a0", "PATCH", None, 0, None), "2.0.0"), + # + (("3.0.0a1", None, None, 0, None), "3.0.0"), + (("3.0.0b1", None, None, 0, None), "3.0.0"), + (("3.0.0rc1", None, None, 0, None), "3.0.0"), + # + (("3.1.4", None, "alpha", 0, None), "3.1.4a0"), + (("3.1.4", None, "beta", 0, None), "3.1.4b0"), + (("3.1.4", None, "rc", 0, None), "3.1.4rc0"), + # + (("3.1.4", None, "alpha", 0, None), "3.1.4a0"), + (("3.1.4a0", "PATCH", "alpha", 0, None), "3.1.4a1"), # UNEXPECTED! + (("3.1.4a0", "MINOR", "alpha", 0, None), "3.2.0a0"), + (("3.1.4a0", "MAJOR", "alpha", 0, None), "4.0.0a0"), +] + @pytest.mark.parametrize( "test_input,expected", - itertools.chain(tdd_cases, weird_cases, simple_flow, unexpected_cases), + itertools.chain( + tdd_cases, + weird_cases, + simple_flow, + linear_prerelease_cases, + prerelease_cases, + ), ) def test_bump_pep440_version(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 82ed33c22..85cfcf5df 100644 --- a/tests/test_version_scheme_semver.py +++ b/tests/test_version_scheme_semver.py @@ -44,10 +44,11 @@ (("4.5.0+0.2.0", "MAJOR", None, 0, None), "4.5.0+1.0.0"), ] -# this cases should be handled gracefully -unexpected_cases = [ - (("0.1.1rc0", None, "alpha", 0, None), "0.1.1-a0"), - (("0.1.1b1", None, "alpha", 0, None), "0.1.1-a0"), +# never bump backwards on pre-releases +linear_prerelease_cases = [ + (("0.1.1b1", None, "alpha", 0, None), "0.1.1-b2"), + (("0.1.1rc0", None, "alpha", 0, None), "0.1.1-rc1"), + (("0.1.1rc0", None, "beta", 0, None), "0.1.1-rc1"), ] weird_cases = [ @@ -84,7 +85,7 @@ @pytest.mark.parametrize( "test_input, expected", - itertools.chain(tdd_cases, weird_cases, simple_flow, unexpected_cases), + itertools.chain(tdd_cases, weird_cases, simple_flow, linear_prerelease_cases), ) def test_bump_semver_version(test_input, expected): current_version = test_input[0]