Skip to content

Commit d2377dd

Browse files
feat: properly bump versions between prereleases (#799)
* 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]>
1 parent db97a5f commit d2377dd

9 files changed

+277
-37
lines changed

commitizen/commands/bump.py

+35-6
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@
2424
)
2525
from commitizen.changelog_formats import get_changelog_format
2626
from commitizen.providers import get_provider
27-
from commitizen.version_schemes import InvalidVersion, get_version_scheme
27+
from commitizen.version_schemes import (
28+
get_version_scheme,
29+
InvalidVersion,
30+
VersionProtocol,
31+
)
2832

2933
logger = getLogger("commitizen")
3034

@@ -226,11 +230,6 @@ def __call__(self): # noqa: C901
226230
"To avoid this error, manually specify the type of increment with `--increment`"
227231
)
228232

229-
# Increment is removed when current and next version
230-
# are expected to be prereleases.
231-
if prerelease and current_version.is_prerelease:
232-
increment = None
233-
234233
new_version = current_version.bump(
235234
increment,
236235
prerelease=prerelease,
@@ -398,3 +397,33 @@ def _get_commit_args(self):
398397
if self.no_verify:
399398
commit_args.append("--no-verify")
400399
return " ".join(commit_args)
400+
401+
def find_previous_final_version(
402+
self, current_version: VersionProtocol
403+
) -> VersionProtocol | None:
404+
tag_format: str = self.bump_settings["tag_format"]
405+
current = bump.normalize_tag(
406+
current_version,
407+
tag_format=tag_format,
408+
scheme=self.scheme,
409+
)
410+
411+
final_versions = []
412+
for tag in git.get_tag_names():
413+
assert tag
414+
try:
415+
version = self.scheme(tag)
416+
if not version.is_prerelease or tag == current:
417+
final_versions.append(version)
418+
except InvalidVersion:
419+
continue
420+
421+
if not final_versions:
422+
return None
423+
424+
final_versions = sorted(final_versions) # type: ignore [type-var]
425+
current_index = final_versions.index(current_version)
426+
previous_index = current_index - 1
427+
if previous_index < 0:
428+
return None
429+
return final_versions[previous_index]

commitizen/version_schemes.py

+44-16
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ def bump(
100100
prerelease_offset: int = 0,
101101
devrelease: int | None = None,
102102
is_local_version: bool = False,
103+
force_bump: bool = False,
103104
) -> Self:
104105
"""
105106
Based on the given increment, generate the next bumped version according to the version scheme
@@ -146,6 +147,12 @@ def generate_prerelease(
146147
if not prerelease:
147148
return ""
148149

150+
# prevent down-bumping the pre-release phase, e.g. from 'b1' to 'a2'
151+
# https://packaging.python.org/en/latest/specifications/version-specifiers/#pre-releases
152+
# https://semver.org/#spec-item-11
153+
if self.is_prerelease and self.pre:
154+
prerelease = max(prerelease, self.pre[0])
155+
149156
# version.pre is needed for mypy check
150157
if self.is_prerelease and self.pre and prerelease.startswith(self.pre[0]):
151158
prev_prerelease: int = self.pre[1]
@@ -171,20 +178,15 @@ def increment_base(self, increment: str | None = None) -> str:
171178
increments = [MAJOR, MINOR, PATCH]
172179
base = dict(zip_longest(increments, prev_release, fillvalue=0))
173180

174-
# This flag means that current version
175-
# must remove its prerelease tag,
176-
# so it doesn't matter the increment.
177-
# Example: 1.0.0a0 with PATCH/MINOR -> 1.0.0
178-
if not self.is_prerelease:
179-
if increment == MAJOR:
180-
base[MAJOR] += 1
181-
base[MINOR] = 0
182-
base[PATCH] = 0
183-
elif increment == MINOR:
184-
base[MINOR] += 1
185-
base[PATCH] = 0
186-
elif increment == PATCH:
187-
base[PATCH] += 1
181+
if increment == MAJOR:
182+
base[MAJOR] += 1
183+
base[MINOR] = 0
184+
base[PATCH] = 0
185+
elif increment == MINOR:
186+
base[MINOR] += 1
187+
base[PATCH] = 0
188+
elif increment == PATCH:
189+
base[PATCH] += 1
188190

189191
return f"{base[MAJOR]}.{base[MINOR]}.{base[PATCH]}"
190192

@@ -195,6 +197,7 @@ def bump(
195197
prerelease_offset: int = 0,
196198
devrelease: int | None = None,
197199
is_local_version: bool = False,
200+
force_bump: bool = False,
198201
) -> Self:
199202
"""Based on the given increment a proper semver will be generated.
200203
@@ -212,9 +215,34 @@ def bump(
212215
local_version = self.scheme(self.local).bump(increment)
213216
return self.scheme(f"{self.public}+{local_version}") # type: ignore
214217
else:
215-
base = self.increment_base(increment)
218+
if not self.is_prerelease:
219+
base = self.increment_base(increment)
220+
elif force_bump:
221+
base = self.increment_base(increment)
222+
else:
223+
base = f"{self.major}.{self.minor}.{self.micro}"
224+
if increment == PATCH:
225+
pass
226+
elif increment == MINOR:
227+
if self.micro != 0:
228+
base = self.increment_base(increment)
229+
elif increment == MAJOR:
230+
if self.minor != 0 or self.micro != 0:
231+
base = self.increment_base(increment)
216232
dev_version = self.generate_devrelease(devrelease)
217-
pre_version = self.generate_prerelease(prerelease, offset=prerelease_offset)
233+
release = list(self.release)
234+
if len(release) < 3:
235+
release += [0] * (3 - len(release))
236+
current_base = ".".join(str(part) for part in release)
237+
if base == current_base:
238+
pre_version = self.generate_prerelease(
239+
prerelease, offset=prerelease_offset
240+
)
241+
else:
242+
base_version = cast(BaseVersion, self.scheme(base))
243+
pre_version = base_version.generate_prerelease(
244+
prerelease, offset=prerelease_offset
245+
)
218246
# TODO: post version
219247
return self.scheme(f"{base}{pre_version}{dev_version}") # type: ignore
220248

docs/bump.md

+28
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,34 @@ Generate a **changelog** along with the new version and tag when bumping.
113113
cz bump --changelog
114114
```
115115
116+
### `--prerelease`
117+
118+
The bump is a pre-release bump, meaning that in addition to a possible version bump the new version receives a
119+
pre-release segment compatible with the bump’s version scheme, where the segment consist of a _phase_ and a
120+
non-negative number. Supported options for `--prerelease` are the following phase names `alpha`, `beta`, or
121+
`rc` (release candidate). For more details, refer to the
122+
[Python Packaging User Guide](https://packaging.python.org/en/latest/specifications/version-specifiers/#pre-releases).
123+
124+
Note that as per [semantic versioning spec](https://semver.org/#spec-item-9)
125+
126+
> Pre-release versions have a lower precedence than the associated normal version. A pre-release version
127+
> indicates that the version is unstable and might not satisfy the intended compatibility requirements
128+
> as denoted by its associated normal version.
129+
130+
For example, the following versions (using the [PEP 440](https://peps.python.org/pep-0440/) scheme) are ordered
131+
by their precedence and showcase how a release might flow through a development cycle:
132+
133+
- `1.0.0` is the current published version
134+
- `1.0.1a0` after committing a `fix:` for pre-release
135+
- `1.1.0a1` after committing an additional `feat:` for pre-release
136+
- `1.1.0b0` after bumping a beta release
137+
- `1.1.0rc0` after bumping the release candidate
138+
- `1.1.0` next feature release
139+
140+
Also note that bumping pre-releases _maintains linearity_: bumping of a pre-release with lower precedence than
141+
the current pre-release phase maintains the current phase of higher precedence. For example, if the current
142+
version is `1.0.0b1` then bumping with `--prerelease alpha` will continue to bump the “beta” phase.
143+
116144
### `--check-consistency`
117145
118146
Check whether the versions defined in `version_files` and the version in commitizen

tests/commands/test_bump_command.py

+89-2
Original file line numberDiff line numberDiff line change
@@ -208,17 +208,68 @@ def test_bump_command_increment_option(
208208

209209
@pytest.mark.usefixtures("tmp_commitizen_project")
210210
def test_bump_command_prelease(mocker: MockFixture):
211-
# PRERELEASE
212211
create_file_and_commit("feat: location")
213212

213+
# Create an alpha pre-release.
214214
testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"]
215215
mocker.patch.object(sys, "argv", testargs)
216216
cli.main()
217217

218218
tag_exists = git.tag_exist("0.2.0a0")
219219
assert tag_exists is True
220220

221-
# PRERELEASE BUMP CREATES VERSION WITHOUT PRERELEASE
221+
# Create a beta pre-release.
222+
testargs = ["cz", "bump", "--prerelease", "beta", "--yes"]
223+
mocker.patch.object(sys, "argv", testargs)
224+
cli.main()
225+
226+
tag_exists = git.tag_exist("0.2.0b0")
227+
assert tag_exists is True
228+
229+
# With a current beta pre-release, bumping alpha must bump beta
230+
# because we can't bump "backwards".
231+
testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"]
232+
mocker.patch.object(sys, "argv", testargs)
233+
cli.main()
234+
235+
tag_exists = git.tag_exist("0.2.0a1")
236+
assert tag_exists is False
237+
tag_exists = git.tag_exist("0.2.0b1")
238+
assert tag_exists is True
239+
240+
# Create a rc pre-release.
241+
testargs = ["cz", "bump", "--prerelease", "rc", "--yes"]
242+
mocker.patch.object(sys, "argv", testargs)
243+
cli.main()
244+
245+
tag_exists = git.tag_exist("0.2.0rc0")
246+
assert tag_exists is True
247+
248+
# With a current rc pre-release, bumping alpha must bump rc.
249+
testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"]
250+
mocker.patch.object(sys, "argv", testargs)
251+
cli.main()
252+
253+
tag_exists = git.tag_exist("0.2.0a1")
254+
assert tag_exists is False
255+
tag_exists = git.tag_exist("0.2.0b2")
256+
assert tag_exists is False
257+
tag_exists = git.tag_exist("0.2.0rc1")
258+
assert tag_exists is True
259+
260+
# With a current rc pre-release, bumping beta must bump rc.
261+
testargs = ["cz", "bump", "--prerelease", "beta", "--yes"]
262+
mocker.patch.object(sys, "argv", testargs)
263+
cli.main()
264+
265+
tag_exists = git.tag_exist("0.2.0a2")
266+
assert tag_exists is False
267+
tag_exists = git.tag_exist("0.2.0b2")
268+
assert tag_exists is False
269+
tag_exists = git.tag_exist("0.2.0rc2")
270+
assert tag_exists is True
271+
272+
# Create a final release from the current pre-release.
222273
testargs = ["cz", "bump"]
223274
mocker.patch.object(sys, "argv", testargs)
224275
cli.main()
@@ -227,6 +278,42 @@ def test_bump_command_prelease(mocker: MockFixture):
227278
assert tag_exists is True
228279

229280

281+
@pytest.mark.usefixtures("tmp_commitizen_project")
282+
def test_bump_command_prelease_increment(mocker: MockFixture):
283+
# FINAL RELEASE
284+
create_file_and_commit("fix: location")
285+
286+
testargs = ["cz", "bump", "--yes"]
287+
mocker.patch.object(sys, "argv", testargs)
288+
cli.main()
289+
assert git.tag_exist("0.1.1")
290+
291+
# PRERELEASE
292+
create_file_and_commit("fix: location")
293+
294+
testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"]
295+
mocker.patch.object(sys, "argv", testargs)
296+
cli.main()
297+
298+
assert git.tag_exist("0.1.2a0")
299+
300+
create_file_and_commit("feat: location")
301+
302+
testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"]
303+
mocker.patch.object(sys, "argv", testargs)
304+
cli.main()
305+
306+
assert git.tag_exist("0.2.0a0")
307+
308+
create_file_and_commit("feat!: breaking")
309+
310+
testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"]
311+
mocker.patch.object(sys, "argv", testargs)
312+
cli.main()
313+
314+
assert git.tag_exist("1.0.0a0")
315+
316+
230317
@pytest.mark.usefixtures("tmp_commitizen_project")
231318
def test_bump_on_git_with_hooks_no_verify_disabled(mocker: MockFixture):
232319
"""Bump commit without --no-verify"""

tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_beta_alpha_.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7-
## 0.2.0a0 (2021-06-11)
7+
## 0.2.0b1 (2021-06-11)
88

99
## 0.2.0b0 (2021-06-11)
1010

tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_rc_alpha_.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7-
## 0.2.0a0 (2021-06-11)
7+
## 0.2.0rc1 (2021-06-11)
88

99
## 0.2.0rc0 (2021-06-11)
1010

tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_rc_beta_.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7-
## 0.2.0b0 (2021-06-11)
7+
## 0.2.0rc1 (2021-06-11)
88

99
## 0.2.0rc0 (2021-06-11)
1010

0 commit comments

Comments
 (0)