Repository: commitizen-tools/commitizen

Issue:
Manually bump entire version, not just manually specify which part to increment
The [docs](https://commitizen-tools.github.io/commitizen/bump/#about) state that the version can be manually bumped but I believe this is incorrect as there's no other mention of this in the docs or in the CLI help beyond `--increment {MAJOR,MINOR,PATCH}` which is merely specifying the part to auto-increment.

Why do I want to manually specify the version to bump to? Well, I don't but unfortunately, dev versions are not supported - but I still want to make use of commitizen's version-updating inside of files. Otherwise, I will need to use a separate tool in conjunction, such as [bumpversion](https://github.com/c4urself/bump2version).

I want to be able to do `cz bump 1.0.1` for example (optional positional arg or `--manual <foo>` option would be fine).

A question on this though - whilst commitizen does not support dev versions, will it still be able to successfully parse a PEP440-compatible version that includes a dev version, for the sake of bumping? I.e. will commitizen be able to bump `1.0.1.dev0+foo.bar.baz` to `1.0.2` or `1.1.0` etc.?

And the ultimate related question - is there any planned support for dev versions?

Comments:

Patch:
diff --git a/commitizen/cli.py b/commitizen/cli.py
index 264bddb2..2064ef3b 100644
--- a/commitizen/cli.py
+++ b/commitizen/cli.py
@@ -183,6 +183,13 @@ data = {
                         "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 abc329ea..bc430320 100644
--- a/commitizen/commands/bump.py
+++ b/commitizen/commands/bump.py
@@ -2,7 +2,7 @@ from logging import getLogger
 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 @@ from commitizen.exceptions import (
     BumpTagFailedError,
     DryRunExit,
     ExpectedExit,
+    InvalidManualVersion,
     NoCommitsFoundError,
     NoneIncrementExit,
     NoPatternMapError,
     NotAGitProjectError,
+    NotAllowed,
     NoVersionSpecifiedError,
 )
 
@@ -102,10 +104,26 @@ class Bump:
         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 @@ class Bump:
         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 @@ class Bump:
         )
 
         # 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 @@ class Bump:
         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 17a91778..cc923ab9 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 dcd10158..bf6f00d8 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


Test patch:
diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py
index 2eb1b1d2..7374e5aa 100644
--- a/tests/commands/test_bump_command.py
+++ b/tests/commands/test_bump_command.py
@@ -14,10 +14,12 @@ from commitizen.exceptions import (
     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