Skip to content

Commit

Permalink
feat: properly bump versions between prereleases (#799)
Browse files Browse the repository at this point in the history
* fix: properly bump versions between prereleases

* refactor: incorporate PR feedback

* refactor: lower version bump into BaseVersion class and simplify callers of BaseVersion.bump()

* feat: preserve prerelease linearity

* docs: document the `--prerelease` option

---------

Co-authored-by: Jens Troeger <[email protected]>
  • Loading branch information
eduardocardoso and jenstroeger authored Feb 1, 2024
1 parent db97a5f commit d2377dd
Show file tree
Hide file tree
Showing 9 changed files with 277 additions and 37 deletions.
41 changes: 35 additions & 6 deletions commitizen/commands/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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]
60 changes: 44 additions & 16 deletions commitizen/version_schemes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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]}"

Expand All @@ -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.
Expand All @@ -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

Expand Down
28 changes: 28 additions & 0 deletions docs/bump.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 89 additions & 2 deletions tests/commands/test_bump_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,17 +208,68 @@ 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()

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()
Expand All @@ -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"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading

0 comments on commit d2377dd

Please sign in to comment.