Skip to content

Add --exact option to bump, to force it to honor the increment for prereleases #981

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

Merged
merged 4 commits into from
Feb 26, 2024
Merged
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
7 changes: 4 additions & 3 deletions commitizen/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -42,7 +43,7 @@ def find_increment(
if increment == MAJOR:
break

return increment
return cast(Increment, increment)


def update_version_in_files(
Expand Down
13 changes: 13 additions & 0 deletions commitizen/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": (
Expand Down
42 changes: 25 additions & 17 deletions commitizen/commands/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@
from commitizen.providers import get_provider
from commitizen.version_schemes import (
get_version_scheme,
Increment,
InvalidVersion,
Prerelease,
)

logger = getLogger("commitizen")
Expand All @@ -50,6 +52,7 @@ def __init__(self, config: BaseConfig, arguments: dict):
"tag_format",
"prerelease",
"increment",
"increment_mode",
"bump_message",
"gpg_sign",
"annotated_tag",
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
36 changes: 27 additions & 9 deletions commitizen/version_schemes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<version>([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?(\w+)?)"


Expand Down Expand Up @@ -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.
"""


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

Expand All @@ -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}"
Expand Down
30 changes: 27 additions & 3 deletions docs/bump.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`

Expand Down
63 changes: 63 additions & 0 deletions tests/commands/test_bump_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down
Loading