Skip to content

feat(commit): add retry_after_failure config option and --no-retry flag #1027

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 12 commits into from
Mar 30, 2024
Merged
Show file tree
Hide file tree
Changes from 11 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
6 changes: 6 additions & 0 deletions commitizen/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 15 additions & 8 deletions commitizen/commands/commit.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import contextlib
import os
import tempfile

from typing import Optional
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
from typing import Optional


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,
Expand All @@ -31,15 +33,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) -> Optional[str]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def read_backup_message(self) -> Optional[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:
Expand All @@ -65,7 +64,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:
Expand All @@ -78,9 +77,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()

Expand Down
20 changes: 20 additions & 0 deletions commitizen/cz/utils.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -15,3 +18,20 @@ 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("/", "%")

return os.path.join(
tempfile.gettempdir(),
"cz.commit%{user}%{project}.backup".format(
user=os.environ.get("USER", ""),
project=project,
),
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return os.path.join(
tempfile.gettempdir(),
"cz.commit%{user}%{project}.backup".format(
user=os.environ.get("USER", ""),
project=project,
),
)
user = os.environ.get("USER", "")
return os.path.join(
tempfile.gettempdir(),
f"cz.commit%{user}%{project}.backup"
)

2 changes: 2 additions & 0 deletions commitizen/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions docs/commit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 9 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions hooks/post-commit.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
#!/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("could not import commitizen:")
print(error)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
print("could not import commitizen:")
print(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():
Expand Down
18 changes: 8 additions & 10 deletions hooks/prepare-commit-msg.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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
Expand Down
107 changes: 95 additions & 12 deletions tests/commands/test_commit_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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",
Expand All @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions tests/test_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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"],
Expand Down
7 changes: 7 additions & 0 deletions tests/test_cz_utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pytest
from pytest_mock import MockFixture

from commitizen.cz import exceptions, utils

Expand All @@ -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()