diff --git a/commitizen/cli.py b/commitizen/cli.py index 3c529e421..e911b6da5 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -156,6 +156,12 @@ def __call__( "action": "store_true", "help": "Tell the command to automatically stage files that have been modified and deleted, but new files you have not told Git about are not affected.", }, + { + "name": ["-e", "--edit"], + "action": "store_true", + "default": False, + "help": "edit the commit message before committing", + }, { "name": ["-l", "--message-length-limit"], "type": int, diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index 0816b2e50..4545cf8c7 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -2,6 +2,9 @@ import contextlib import os +import shutil +import subprocess +import tempfile import questionary @@ -72,9 +75,27 @@ def prompt_commit_questions(self) -> str: return message + def force_edit(self, message: str) -> str: + editor = git.get_core_editor() + if editor is None: + raise RuntimeError("No 'editor' value given and no default available.") + exec_path = shutil.which(editor) + if exec_path is None: + raise RuntimeError(f"Editor '{editor}' not found.") + with tempfile.NamedTemporaryFile(mode="w", delete=False) as file: + file.write(message) + file_path = file.name + argv = [exec_path, file_path] + subprocess.call(argv) + with open(file_path) as temp_file: + message = temp_file.read().strip() + file.unlink() + return message + def __call__(self): dry_run: bool = self.arguments.get("dry_run") write_message_to_file: bool = self.arguments.get("write_message_to_file") + force_edit: bool = self.arguments.get("edit") is_all: bool = self.arguments.get("all") if is_all: @@ -101,6 +122,9 @@ def __call__(self): else: m = self.prompt_commit_questions() + if force_edit: + m = self.force_edit(m) + out.info(f"\n{m}\n") if write_message_to_file: diff --git a/commitizen/git.py b/commitizen/git.py index 1f758889e..7de8e1f1c 100644 --- a/commitizen/git.py +++ b/commitizen/git.py @@ -271,6 +271,13 @@ def get_eol_style() -> EOLTypes: return map["native"] +def get_core_editor() -> str | None: + c = cmd.run("git var GIT_EDITOR") + if c.out: + return c.out.strip() + return None + + def smart_open(*args, **kargs): """Open a file with the EOL style determined from Git.""" return open(*args, newline=get_eol_style().get_eol_for_open(), **kargs) diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py index 03ff51c42..a4714df8d 100644 --- a/tests/commands/test_commit_command.py +++ b/tests/commands/test_commit_command.py @@ -410,6 +410,31 @@ def test_commit_command_with_message_length_limit(config, mocker: MockFixture): commands.Commit(config, {"message_length_limit": message_length - 1})() +@pytest.mark.usefixtures("staging_is_clean") +def test_force_edit(config, mocker: MockFixture, tmp_path): + mocker.patch("commitizen.git.get_core_editor", return_value="vim") + subprocess_mock = mocker.patch("subprocess.call") + + mocker.patch("shutil.which", return_value="vim") + + test_message = "Initial commit message" + temp_file = tmp_path / "temp_commit_message" + temp_file.write_text(test_message) + + mock_temp_file = mocker.patch("tempfile.NamedTemporaryFile") + mock_temp_file.return_value.__enter__.return_value.name = str(temp_file) + + commit_instance = commands.Commit(config, {"edit": True}) + + edited_message = commit_instance.force_edit(test_message) + + subprocess_mock.assert_called_once_with(["vim", str(temp_file)]) + + assert edited_message == test_message.strip() + + temp_file.unlink() + + @skip_below_py_3_13 def test_commit_command_shows_description_when_use_help_option( mocker: MockFixture, capsys, file_regression diff --git a/tests/commands/test_commit_command/test_commit_command_shows_description_when_use_help_option.txt b/tests/commands/test_commit_command/test_commit_command_shows_description_when_use_help_option.txt index 92f3cf5e8..955b3d8fd 100644 --- a/tests/commands/test_commit_command/test_commit_command_shows_description_when_use_help_option.txt +++ b/tests/commands/test_commit_command/test_commit_command_shows_description_when_use_help_option.txt @@ -1,5 +1,5 @@ usage: cz commit [-h] [--retry] [--no-retry] [--dry-run] - [--write-message-to-file FILE_PATH] [-s] [-a] + [--write-message-to-file FILE_PATH] [-s] [-a] [-e] [-l MESSAGE_LENGTH_LIMIT] create new commit @@ -16,5 +16,6 @@ options: -a, --all Tell the command to automatically stage files that have been modified and deleted, but new files you have not told Git about are not affected. + -e, --edit edit the commit message before committing -l, --message-length-limit MESSAGE_LENGTH_LIMIT length limit of the commit message; 0 for no limit diff --git a/tests/test_git.py b/tests/test_git.py index 6ada76be6..8bf995e8a 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -283,6 +283,26 @@ def test_eoltypes_get_eol_for_open(): assert git.EOLTypes.get_eol_for_open(git.EOLTypes.CRLF) == "\r\n" +def test_get_core_editor(mocker): + mocker.patch.dict(os.environ, {"GIT_EDITOR": "nano"}) + assert git.get_core_editor() == "nano" + + mocker.patch.dict(os.environ, clear=True) + mocker.patch( + "commitizen.cmd.run", + return_value=cmd.Command( + out="vim", err="", stdout=b"", stderr=b"", return_code=0 + ), + ) + assert git.get_core_editor() == "vim" + + mocker.patch( + "commitizen.cmd.run", + return_value=cmd.Command(out="", err="", stdout=b"", stderr=b"", return_code=1), + ) + assert git.get_core_editor() is None + + def test_create_tag_with_message(tmp_commitizen_project): with tmp_commitizen_project.as_cwd(): create_file_and_commit("feat(test): test")