diff --git a/commitizen/cli.py b/commitizen/cli.py index 264bddb2ff..2064ef3bf1 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -183,6 +183,13 @@ "default": False, "help": "retry commit if it fails the 1st time", }, + { + "name": "manual_version", + "type": str, + "nargs": "?", + "help": "bump to the given version (e.g: 1.5.3)", + "metavar": "MANUAL_VERSION", + }, ], }, { diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py index abc329ea8c..bc43032036 100644 --- a/commitizen/commands/bump.py +++ b/commitizen/commands/bump.py @@ -2,7 +2,7 @@ from typing import List, Optional import questionary -from packaging.version import Version +from packaging.version import InvalidVersion, Version from commitizen import bump, cmd, factory, git, out from commitizen.commands.changelog import Changelog @@ -12,10 +12,12 @@ BumpTagFailedError, DryRunExit, ExpectedExit, + InvalidManualVersion, NoCommitsFoundError, NoneIncrementExit, NoPatternMapError, NotAGitProjectError, + NotAllowed, NoVersionSpecifiedError, ) @@ -102,10 +104,26 @@ def __call__(self): # noqa: C901 dry_run: bool = self.arguments["dry_run"] is_yes: bool = self.arguments["yes"] increment: Optional[str] = self.arguments["increment"] - prerelease: str = self.arguments["prerelease"] + prerelease: Optional[str] = self.arguments["prerelease"] devrelease: Optional[int] = self.arguments["devrelease"] is_files_only: Optional[bool] = self.arguments["files_only"] is_local_version: Optional[bool] = self.arguments["local_version"] + manual_version = self.arguments["manual_version"] + + if manual_version: + if increment: + raise NotAllowed("--increment cannot be combined with MANUAL_VERSION") + + if prerelease: + raise NotAllowed("--prerelease cannot be combined with MANUAL_VERSION") + + if devrelease is not None: + raise NotAllowed("--devrelease cannot be combined with MANUAL_VERSION") + + if is_local_version: + raise NotAllowed( + "--local-version cannot be combined with MANUAL_VERSION" + ) current_tag_version: str = bump.normalize_tag( current_version, tag_format=tag_format @@ -127,34 +145,43 @@ def __call__(self): # noqa: C901 if not commits and not current_version_instance.is_prerelease: raise NoCommitsFoundError("[NO_COMMITS_FOUND]\n" "No new commits found.") - if increment is None: - increment = self.find_increment(commits) - - # It may happen that there are commits, but they are not elegible - # for an increment, this generates a problem when using prerelease (#281) - if ( - prerelease - and increment is None - and not current_version_instance.is_prerelease - ): - raise NoCommitsFoundError( - "[NO_COMMITS_FOUND]\n" - "No commits found to generate a pre-release.\n" - "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_instance.is_prerelease: - increment = None + if manual_version: + try: + new_version = Version(manual_version) + except InvalidVersion as exc: + raise InvalidManualVersion( + "[INVALID_MANUAL_VERSION]\n" + f"Invalid manual version: '{manual_version}'" + ) from exc + else: + if increment is None: + increment = self.find_increment(commits) + + # It may happen that there are commits, but they are not eligible + # for an increment, this generates a problem when using prerelease (#281) + if ( + prerelease + and increment is None + and not current_version_instance.is_prerelease + ): + raise NoCommitsFoundError( + "[NO_COMMITS_FOUND]\n" + "No commits found to generate a pre-release.\n" + "To avoid this error, manually specify the type of increment with `--increment`" + ) - new_version = bump.generate_version( - current_version, - increment, - prerelease=prerelease, - devrelease=devrelease, - is_local_version=is_local_version, - ) + # Increment is removed when current and next version + # are expected to be prereleases. + if prerelease and current_version_instance.is_prerelease: + increment = None + + new_version = bump.generate_version( + current_version, + increment, + prerelease=prerelease, + devrelease=devrelease, + is_local_version=is_local_version, + ) new_tag_version = bump.normalize_tag(new_version, tag_format=tag_format) message = bump.create_commit_message( @@ -162,11 +189,9 @@ def __call__(self): # noqa: C901 ) # Report found information - information = ( - f"{message}\n" - f"tag to create: {new_tag_version}\n" - f"increment detected: {increment}\n" - ) + information = f"{message}\n" f"tag to create: {new_tag_version}\n" + if increment: + information += f"increment detected: {increment}\n" if self.changelog_to_stdout: # When the changelog goes to stdout, we want to send @@ -179,7 +204,7 @@ def __call__(self): # noqa: C901 if increment is None and new_tag_version == current_tag_version: raise NoneIncrementExit( "[NO_COMMITS_TO_BUMP]\n" - "The commits found are not elegible to be bumped" + "The commits found are not eligible to be bumped" ) if self.changelog: diff --git a/commitizen/exceptions.py b/commitizen/exceptions.py index 17a91778df..cc923ab988 100644 --- a/commitizen/exceptions.py +++ b/commitizen/exceptions.py @@ -28,6 +28,7 @@ class ExitCode(enum.IntEnum): NO_INCREMENT = 21 UNRECOGNIZED_CHARACTERSET_ENCODING = 22 GIT_COMMAND_ERROR = 23 + INVALID_MANUAL_VERSION = 24 class CommitizenException(Exception): @@ -158,3 +159,7 @@ class CharacterSetDecodeError(CommitizenException): class GitCommandError(CommitizenException): exit_code = ExitCode.GIT_COMMAND_ERROR + + +class InvalidManualVersion(CommitizenException): + exit_code = ExitCode.INVALID_MANUAL_VERSION diff --git a/docs/bump.md b/docs/bump.md index dcd10158fc..bf6f00d8e8 100644 --- a/docs/bump.md +++ b/docs/bump.md @@ -56,10 +56,13 @@ Some examples: $ cz bump --help usage: cz bump [-h] [--dry-run] [--files-only] [--local-version] [--changelog] [--no-verify] [--yes] [--tag-format TAG_FORMAT] - [--bump-message BUMP_MESSAGE] [--increment {MAJOR,MINOR,PATCH}] - [--prerelease {alpha,beta,rc}] [--devrelease {DEV}] + [--bump-message BUMP_MESSAGE] [--prerelease {alpha,beta,rc}] + [--devrelease DEVRELEASE] [--increment {MAJOR,MINOR,PATCH}] [--check-consistency] [--annotated-tag] [--gpg-sign] - [--changelog-to-stdout] [--retry] + [--changelog-to-stdout] [--retry] [MANUAL_VERSION] + +positional arguments: + MANUAL_VERSION bump to the given version (e.g: 1.5.3) options: -h, --help show this help message and exit @@ -78,14 +81,15 @@ options: when working with CI --prerelease {alpha,beta,rc}, -pr {alpha,beta,rc} choose type of prerelease - --devrelease {DEV} specify dev release + --devrelease DEVRELEASE, -d DEVRELEASE + specify non-negative integer for dev. release --increment {MAJOR,MINOR,PATCH} manually specify the desired increment --check-consistency, -cc check consistency among versions defined in commitizen configuration and version_files - --gpg-sign, -s create a signed tag instead of lightweight one or annotated tag --annotated-tag, -at create annotated tag instead of lightweight one + --gpg-sign, -s sign tag instead of lightweight one --changelog-to-stdout Output changelog to the stdout --retry retry commit if it fails the 1st time diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py index 2eb1b1d2ed..7374e5aa52 100644 --- a/tests/commands/test_bump_command.py +++ b/tests/commands/test_bump_command.py @@ -14,10 +14,12 @@ DryRunExit, ExitCode, ExpectedExit, + InvalidManualVersion, NoCommitsFoundError, NoneIncrementExit, NoPatternMapError, NotAGitProjectError, + NotAllowed, NoVersionSpecifiedError, ) from tests.utils import create_file_and_commit @@ -614,3 +616,67 @@ def test_bump_changelog_command_commits_untracked_changelog_and_version_files( commit_file_names = git.get_filenames_in_commit() assert "CHANGELOG.md" in commit_file_names assert version_filepath in commit_file_names + + +@pytest.mark.parametrize( + "testargs", + [ + ["cz", "bump", "--local-version", "1.2.3"], + ["cz", "bump", "--prerelease", "rc", "1.2.3"], + ["cz", "bump", "--devrelease", "0", "1.2.3"], + ["cz", "bump", "--devrelease", "1", "1.2.3"], + ["cz", "bump", "--increment", "PATCH", "1.2.3"], + ], +) +def test_bump_invalid_manual_args_raises_exception(mocker, testargs): + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(NotAllowed): + cli.main() + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.parametrize( + "manual_version", + [ + "noversion", + "1.2..3", + ], +) +def test_bump_invalid_manual_version_raises_exception(mocker, manual_version): + create_file_and_commit("feat: new file") + + testargs = ["cz", "bump", "--yes", manual_version] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(InvalidManualVersion) as excinfo: + cli.main() + + expected_error_message = ( + "[INVALID_MANUAL_VERSION]\n" f"Invalid manual version: '{manual_version}'" + ) + assert expected_error_message in str(excinfo.value) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.parametrize( + "manual_version", + [ + "0.0.1", + "0.1.0rc2", + "0.1.0.dev2", + "0.1.0+1.0.0", + "0.1.0rc2.dev2+1.0.0", + "0.1.1", + "0.2.0", + "1.0.0", + ], +) +def test_bump_manual_version(mocker, manual_version): + create_file_and_commit("feat: new file") + + testargs = ["cz", "bump", "--yes", manual_version] + mocker.patch.object(sys, "argv", testargs) + cli.main() + tag_exists = git.tag_exist(manual_version) + assert tag_exists is True