diff --git a/commitizen/cli.py b/commitizen/cli.py index c25bd4f713..a442803d7f 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -123,6 +123,12 @@ def __call__( "action": "store_true", "help": "retry last commit", }, + { + "name": ["--no-retry"], + "action": "store_true", + "default": False, + "help": "skip retry if retry_after_failure is set to true", + }, { "name": "--dry-run", "action": "store_true", diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index b8e5eed6d4..7591a28658 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -1,12 +1,15 @@ +from __future__ import annotations + import contextlib import os -import tempfile + import questionary from commitizen import factory, git, out from commitizen.config import BaseConfig from commitizen.cz.exceptions import CzException +from commitizen.cz.utils import get_backup_file_path from commitizen.exceptions import ( CommitError, CustomError, @@ -31,15 +34,12 @@ def __init__(self, config: BaseConfig, arguments: dict): self.encoding = config.settings["encoding"] self.cz = factory.commiter_factory(self.config) self.arguments = arguments - self.temp_file: str = os.path.join( - tempfile.gettempdir(), - "cz.commit{user}.backup".format(user=os.environ.get("USER", "")), - ) + self.temp_file: str = get_backup_file_path() - def read_backup_message(self) -> str: + def read_backup_message(self) -> str | None: # Check the commit backup file exists if not os.path.isfile(self.temp_file): - raise NoCommitBackupError() + return None # Read commit message from backup with open(self.temp_file, encoding=self.encoding) as f: @@ -65,7 +65,7 @@ def prompt_commit_questions(self) -> str: def __call__(self): dry_run: bool = self.arguments.get("dry_run") - write_message_to_file = self.arguments.get("write_message_to_file") + write_message_to_file: bool = self.arguments.get("write_message_to_file") is_all: bool = self.arguments.get("all") if is_all: @@ -78,9 +78,17 @@ def __call__(self): raise NotAllowed(f"{write_message_to_file} is a directory") retry: bool = self.arguments.get("retry") + no_retry: bool = self.arguments.get("no_retry") + retry_after_failure: bool = self.config.settings.get("retry_after_failure") if retry: m = self.read_backup_message() + if m is None: + raise NoCommitBackupError() + elif retry_after_failure and not no_retry: + m = self.read_backup_message() + if m is None: + m = self.prompt_commit_questions() else: m = self.prompt_commit_questions() diff --git a/commitizen/cz/utils.py b/commitizen/cz/utils.py index 71dac105a7..4df2edf39c 100644 --- a/commitizen/cz/utils.py +++ b/commitizen/cz/utils.py @@ -1,6 +1,9 @@ import re +import os +import tempfile from commitizen.cz import exceptions +from commitizen import git def required_validator(answer, msg=None): @@ -15,3 +18,15 @@ def multiple_line_breaker(answer, sep="|"): def strip_local_version(version: str) -> str: return re.sub(r"\+.+", "", version) + + +def get_backup_file_path() -> str: + project_root = git.find_git_project_root() + + if project_root is None: + project = "" + else: + project = project_root.as_posix().replace("/", "%") + + user = os.environ.get("USER", "") + return os.path.join(tempfile.gettempdir(), f"cz.commit%{user}%{project}.backup") diff --git a/commitizen/defaults.py b/commitizen/defaults.py index 7aa2c793c5..071efd10b9 100644 --- a/commitizen/defaults.py +++ b/commitizen/defaults.py @@ -37,6 +37,7 @@ class Settings(TypedDict, total=False): version_type: str | None tag_format: str bump_message: str | None + retry_after_failure: bool allow_abort: bool allowed_prefixes: list[str] changelog_file: str @@ -77,6 +78,7 @@ class Settings(TypedDict, total=False): "version_scheme": None, "tag_format": "$version", # example v$version "bump_message": None, # bumped v$current_version to $new_version + "retry_after_failure": False, "allow_abort": False, "allowed_prefixes": [ "Merge", diff --git a/docs/commit.md b/docs/commit.md index 2215e0d805..54c792f743 100644 --- a/docs/commit.md +++ b/docs/commit.md @@ -29,3 +29,10 @@ For example, using the `-S` option on `git commit` to sign a commit is now commi !!! note Deprecation warning: A commit can be signed off using `cz commit --signoff` or the shortcut `cz commit -s`. This syntax is now deprecated in favor of the new `cz commit -- -s` syntax. + +### Retry + +You can use `cz commit --retry` to reuse the last commit message when the previous commit attempt failed. +To automatically retry when running `cz commit`, you can set the `retry_after_failure` +configuration option to `true`. Running `cz commit --no-retry` makes commitizen ignore `retry_after_failure`, forcing +a new commit message to be prompted. diff --git a/docs/config.md b/docs/config.md index 391c20f0fc..d6ab12abcd 100644 --- a/docs/config.md +++ b/docs/config.md @@ -82,6 +82,14 @@ Default: `None` Create custom commit message, useful to skip ci. [Read more][bump_message] +### `retry_after_failure` + +Type: `bool` + +Default: `false` + +Automatically retry failed commit when running `cz commit`. [Read more][retry_after_failure] + ### `allow_abort` Type: `bool` @@ -380,6 +388,7 @@ setup( [bump_message]: bump.md#bump_message [major-version-zero]: bump.md#-major-version-zero [prerelease-offset]: bump.md#-prerelease_offset +[retry_after_failure]: commit.md#retry [allow_abort]: check.md#allow-abort [version-scheme]: bump.md#version-scheme [pre_bump_hooks]: bump.md#pre_bump_hooks diff --git a/hooks/post-commit.py b/hooks/post-commit.py index c2faebb738..6444e5ca84 100755 --- a/hooks/post-commit.py +++ b/hooks/post-commit.py @@ -1,13 +1,15 @@ #!/usr/bin/env python -import os -import tempfile from pathlib import Path +try: + from commitizen.cz.utils import get_backup_file_path +except ImportError as error: + print(f"could not import commitizen:\n{error}") + exit(1) + def post_commit(): - backup_file = Path( - tempfile.gettempdir(), f"cz.commit{os.environ.get('USER', '')}.backup" - ) + backup_file = Path(get_backup_file_path()) # remove backup file if it exists if backup_file.is_file(): diff --git a/hooks/prepare-commit-msg.py b/hooks/prepare-commit-msg.py index 58beb3a0f8..d1ccf169cf 100755 --- a/hooks/prepare-commit-msg.py +++ b/hooks/prepare-commit-msg.py @@ -1,19 +1,19 @@ #!/usr/bin/env python -import os import shutil import subprocess import sys -import tempfile from pathlib import Path from subprocess import CalledProcessError +try: + from commitizen.cz.utils import get_backup_file_path +except ImportError as error: + print("could not import commitizen:") + print(error) + exit(1) -def prepare_commit_msg(commit_msg_file: Path) -> int: - # check that commitizen is installed - if shutil.which("cz") is None: - print("commitizen is not installed!") - return 0 +def prepare_commit_msg(commit_msg_file: Path) -> int: # check if the commit message needs to be generated using commitizen if ( subprocess.run( @@ -27,9 +27,7 @@ def prepare_commit_msg(commit_msg_file: Path) -> int: ).returncode != 0 ): - backup_file = Path( - tempfile.gettempdir(), f"cz.commit{os.environ.get('USER', '')}.backup" - ) + backup_file = Path(get_backup_file_path()) if backup_file.is_file(): # confirm if commit message from backup file should be reused diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py index e3f9989823..1930b2eaee 100644 --- a/tests/commands/test_commit_command.py +++ b/tests/commands/test_commit_command.py @@ -6,6 +6,7 @@ from commitizen import cmd, commands from commitizen.cz.exceptions import CzException +from commitizen.cz.utils import get_backup_file_path from commitizen.exceptions import ( CommitError, CustomError, @@ -25,6 +26,12 @@ def staging_is_clean(mocker: MockFixture, tmp_git_project): return tmp_git_project +@pytest.fixture +def backup_file(tmp_git_project): + with open(get_backup_file_path(), "w") as backup_file: + backup_file.write("backup commit") + + @pytest.mark.usefixtures("staging_is_clean") def test_commit(config, mocker: MockFixture): prompt_mock = mocker.patch("questionary.prompt") @@ -45,6 +52,32 @@ def test_commit(config, mocker: MockFixture): success_mock.assert_called_once() +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_backup_on_failure(config, mocker: MockFixture): + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "closes #21", + "footer": "", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("", "error", b"", b"", 9) + error_mock = mocker.patch("commitizen.out.error") + + with pytest.raises(CommitError): + commit_cmd = commands.Commit(config, {}) + temp_file = commit_cmd.temp_file + commit_cmd() + + prompt_mock.assert_called_once() + error_mock.assert_called_once() + assert os.path.isfile(temp_file) + + @pytest.mark.usefixtures("staging_is_clean") def test_commit_retry_fails_no_backup(config, mocker: MockFixture): commit_mock = mocker.patch("commitizen.git.commit") @@ -56,9 +89,27 @@ def test_commit_retry_fails_no_backup(config, mocker: MockFixture): assert NoCommitBackupError.message in str(excinfo.value) -@pytest.mark.usefixtures("staging_is_clean") +@pytest.mark.usefixtures("staging_is_clean", "backup_file") def test_commit_retry_works(config, mocker: MockFixture): prompt_mock = mocker.patch("questionary.prompt") + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + + commit_cmd = commands.Commit(config, {"retry": True}) + temp_file = commit_cmd.temp_file + commit_cmd() + + commit_mock.assert_called_with("backup commit", args="") + prompt_mock.assert_not_called() + success_mock.assert_called_once() + assert not os.path.isfile(temp_file) + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_retry_after_failure_no_backup(config, mocker: MockFixture): + prompt_mock = mocker.patch("questionary.prompt") prompt_mock.return_value = { "prefix": "feat", "subject": "user created", @@ -69,24 +120,56 @@ def test_commit_retry_works(config, mocker: MockFixture): } commit_mock = mocker.patch("commitizen.git.commit") - commit_mock.return_value = cmd.Command("", "error", b"", b"", 9) - error_mock = mocker.patch("commitizen.out.error") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") - with pytest.raises(CommitError): - commit_cmd = commands.Commit(config, {}) - temp_file = commit_cmd.temp_file - commit_cmd() + config.settings["retry_after_failure"] = True + commands.Commit(config, {})() + commit_mock.assert_called_with("feat: user created\n\ncloses #21", args="") prompt_mock.assert_called_once() - error_mock.assert_called_once() - assert os.path.isfile(temp_file) + success_mock.assert_called_once() + + +@pytest.mark.usefixtures("staging_is_clean", "backup_file") +def test_commit_retry_after_failure_works(config, mocker: MockFixture): + prompt_mock = mocker.patch("questionary.prompt") + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + + config.settings["retry_after_failure"] = True + commit_cmd = commands.Commit(config, {}) + temp_file = commit_cmd.temp_file + commit_cmd() + + commit_mock.assert_called_with("backup commit", args="") + prompt_mock.assert_not_called() + success_mock.assert_called_once() + assert not os.path.isfile(temp_file) + - # Previous commit failed, so retry should pick up the backup commit - # commit_mock = mocker.patch("commitizen.git.commit") +@pytest.mark.usefixtures("staging_is_clean", "backup_file") +def test_commit_retry_after_failure_with_no_retry_works(config, mocker: MockFixture): + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "closes #21", + "footer": "", + } + + commit_mock = mocker.patch("commitizen.git.commit") commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) success_mock = mocker.patch("commitizen.out.success") - commands.Commit(config, {"retry": True})() + config.settings["retry_after_failure"] = True + commit_cmd = commands.Commit(config, {"no_retry": True}) + temp_file = commit_cmd.temp_file + commit_cmd() commit_mock.assert_called_with("feat: user created\n\ncloses #21", args="") prompt_mock.assert_called_once() diff --git a/tests/test_conf.py b/tests/test_conf.py index dcac8e015c..a12bcdd35d 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -52,6 +52,7 @@ "version_scheme": None, "tag_format": "$version", "bump_message": None, + "retry_after_failure": False, "allow_abort": False, "allowed_prefixes": ["Merge", "Revert", "Pull request", "fixup!", "squash!"], "version_files": ["commitizen/__version__.py", "pyproject.toml"], @@ -80,6 +81,7 @@ "version_scheme": None, "tag_format": "$version", "bump_message": None, + "retry_after_failure": False, "allow_abort": False, "allowed_prefixes": ["Merge", "Revert", "Pull request", "fixup!", "squash!"], "version_files": ["commitizen/__version__.py", "pyproject.toml"], diff --git a/tests/test_cz_utils.py b/tests/test_cz_utils.py index 94cc7f5b87..25c960c9a7 100644 --- a/tests/test_cz_utils.py +++ b/tests/test_cz_utils.py @@ -1,4 +1,5 @@ import pytest +from pytest_mock import MockFixture from commitizen.cz import exceptions, utils @@ -17,3 +18,9 @@ def test_multiple_line_breaker(): result = utils.multiple_line_breaker(message, "is") assert result == "th\n\nthe first line | and th\n\nthe second line" + + +def test_get_backup_file_path_no_project_root(mocker: MockFixture): + project_root_mock = mocker.patch("commitizen.git.find_git_project_root") + project_root_mock.return_value = None + assert utils.get_backup_file_path()