diff --git a/commitizen/cli.py b/commitizen/cli.py index e25c3e1a77..519ae1ec43 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -133,6 +133,23 @@ }, ], }, + { + "name": "undo", + "help": "revert bump or commit", + "func": commands.Undo, + "arguments": [ + { + "name": ["--bump", "-b"], + "action": "store_true", + "help": "revert bump", + }, + { + "name": ["--commit", "-c"], + "action": "store_true", + "help": "revert latest commit, equal to git reset HEAD~", + }, + ], + }, { "name": ["changelog", "ch"], "help": ( diff --git a/commitizen/commands/__init__.py b/commitizen/commands/__init__.py index 806e384522..040edf7faa 100644 --- a/commitizen/commands/__init__.py +++ b/commitizen/commands/__init__.py @@ -7,6 +7,7 @@ from .init import Init from .list_cz import ListCz from .schema import Schema +from .undo import Undo from .version import Version __all__ = ( @@ -18,6 +19,7 @@ "Info", "ListCz", "Schema", + "Undo", "Version", "Init", ) diff --git a/commitizen/commands/init.py b/commitizen/commands/init.py index 1c459d5ed4..1bfb643f0e 100644 --- a/commitizen/commands/init.py +++ b/commitizen/commands/init.py @@ -47,7 +47,7 @@ def __call__(self): def _ask_config_path(self) -> str: name = questionary.select( "Please choose a supported config file: (default: pyproject.toml)", - choices=config_files, + choices=config_files, # type: ignore default="pyproject.toml", style=self.cz.style, ).ask() @@ -79,7 +79,7 @@ def _ask_tag(self) -> str: latest_tag = questionary.select( "Please choose the latest tag: ", - choices=get_tag_names(), + choices=get_tag_names(), # type: ignore style=self.cz.style, ).ask() diff --git a/commitizen/commands/undo.py b/commitizen/commands/undo.py new file mode 100644 index 0000000000..12ba1b2bf0 --- /dev/null +++ b/commitizen/commands/undo.py @@ -0,0 +1,61 @@ +from commitizen import cmd, factory, git, out +from commitizen.config import BaseConfig +from commitizen.exceptions import InvalidCommandArgumentError + + +class Undo: + """Reset the latest git commit or git tag.""" + + def __init__(self, config: BaseConfig, arguments: dict): + self.config: BaseConfig = config + self.cz = factory.commiter_factory(self.config) + self.arguments = arguments + + def _get_bump_command(self): + created_tag = git.get_latest_tag() + commits = git.get_commits() + + if created_tag and commits: + created_commit = commits[0] + else: + raise InvalidCommandArgumentError("There is no tag or commit to undo") + + if created_tag.rev != created_commit.rev: + raise InvalidCommandArgumentError( + "The revision of the latest tag is not equal to the latest commit, use git undo --commit instead\n\n" + f"Latest Tag: {created_tag.name}, {created_tag.rev}, {created_tag.date}\n" + f"Latest Commit: {created_commit.title}, {created_commit.rev}" + ) + + command = f"git tag --delete {created_tag.name} && git reset HEAD~ && git reset --hard HEAD" + + out.info("Reverting version bump, running:") + out.info(f"{command}") + out.info( + f"The tag can be removed from a remote by running `git push origin :{created_tag.name}`" + ) + + return command + + def __call__(self): + bump: bool = self.arguments.get("bump") + commit: bool = self.arguments.get("commit") + + if bump: + command = self._get_bump_command() + elif commit: + command = "git reset HEAD~" + else: + raise InvalidCommandArgumentError( + ( + "One and only one argument is required for check command! " + "See 'cz undo -h' for more information" + ) + ) + + c = cmd.run(command) + if c.err: + out.error(c.err) + + out.write(c.out) + out.success("Undo successful!") diff --git a/commitizen/git.py b/commitizen/git.py index 81aa9166cf..6fcd21a46e 100644 --- a/commitizen/git.py +++ b/commitizen/git.py @@ -123,7 +123,22 @@ def get_latest_tag_name() -> Optional[str]: return c.out.strip() -def get_tag_names() -> Optional[List[str]]: +def get_latest_tag() -> Optional[GitTag]: + tags = get_tags() + latest_tag_name = get_latest_tag_name() + + if not tags or not latest_tag_name: + return None + + if tags[0].name == latest_tag_name: + return tags[0] + + tag_names = [tag.name for tag in tags] + latest_tag_index = tag_names.index(latest_tag_name) + return tags[latest_tag_index] + + +def get_tag_names() -> List[Optional[str]]: c = cmd.run("git tag --list") if c.err: return [] diff --git a/docs/README.md b/docs/README.md index 189504fb3b..7daf53e555 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ versions](https://img.shields.io/pypi/pyversions/commitizen.svg?style=flat-squar --- -**Documentation**: https://commitizen-tools.github.io/ +**Documentation**: https://commitizen-tools.github.io/commitizen/ --- @@ -105,7 +105,7 @@ Read more about the `check` command [here](https://commitizen-tools.github.io/co ```bash $ cz --help usage: cz [-h] [--debug] [-n NAME] [--version] - {init,commit,c,ls,example,info,schema,bump,changelog,ch,check,version} + {init,commit,c,ls,example,info,schema,bump,changelog,ch,check,version,undo} ... Commitizen is a cli tool to generate conventional commits. @@ -119,7 +119,7 @@ optional arguments: --version get the version of the installed commitizen commands: - {init,commit,c,ls,example,info,schema,bump,changelog,ch,check,version} + {init,commit,c,ls,example,info,schema,bump,undo,changelog,ch,check,version} init init commitizen configuration commit (c) create new commit ls show available commitizens @@ -127,6 +127,7 @@ commands: info show information about the cz schema show commit schema bump bump semantic version based on the git log + undo revert the latest bump or commit changelog (ch) generate changelog (note that it will overwrite existing file) check validates that a commit message matches the commitizen diff --git a/tests/commands/test_undo_command.py b/tests/commands/test_undo_command.py new file mode 100644 index 0000000000..8af8d3433f --- /dev/null +++ b/tests/commands/test_undo_command.py @@ -0,0 +1,68 @@ +import sys + +import pytest + +from commitizen import cli, git +from tests.utils import create_file_and_commit + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_undo_commit(config, mocker): + create_file_and_commit("feat: new file") + # We can not revert the first commit, thus we commit twice. + create_file_and_commit("feat: extra file") + + testargs = ["cz", "undo", "--commit"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + commits = git.get_commits() + + assert len(commits) == 1 + + +def _execute_command(mocker, testargs): + mocker.patch.object(sys, "argv", testargs) + cli.main() + + +def _undo_bump(mocker, tag_num: int = 0): + testargs = ["cz", "undo", "--bump"] + _execute_command(mocker, testargs) + + tags = git.get_tags() + assert len(tags) == tag_num + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_undo_bump(config, mocker): + # MINOR + create_file_and_commit("feat: new file") + _execute_command(mocker, ["cz", "bump", "--yes"]) + _undo_bump(mocker) + + # PATCH + create_file_and_commit("feat: new file") + _execute_command(mocker, ["cz", "bump", "--yes"]) + + create_file_and_commit("fix: username exception") + _execute_command(mocker, ["cz", "bump"]) + _undo_bump(mocker, 1) + + # PRERELEASE + create_file_and_commit("feat: location") + _execute_command(mocker, ["cz", "bump", "--prerelease", "alpha"]) + _undo_bump(mocker, 1) + + # PRERELEASE BUMP CREATES VERSION WITHOUT PRERELEASE + create_file_and_commit("feat: location") + _execute_command(mocker, ["cz", "bump", "--prerelease", "alpha"]) + _execute_command(mocker, ["cz", "bump"]) + _undo_bump(mocker, 2) + + # MAJOR + create_file_and_commit( + "feat: new user interface\n\nBREAKING CHANGE: age is no longer supported" + ) + _execute_command(mocker, ["cz", "bump"]) + _undo_bump(mocker, 2)