diff --git a/commitizen/cli.py b/commitizen/cli.py index 3c529e4210..e911b6da58 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 0816b2e508..c955d02a51 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 manual_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") + manual_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 manual_edit: + m = self.manual_edit(m) + out.info(f"\n{m}\n") if write_message_to_file: diff --git a/commitizen/git.py b/commitizen/git.py index 1f758889ed..7de8e1f1c8 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/docs/contributing.md b/docs/contributing.md index a41843d753..a49196277e 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -22,7 +22,7 @@ If you're a first-time contributor, you can check the issues with [good first is (We use [CodeCov](https://codecov.io/) to ensure our test coverage does not drop.) 7. Use [commitizen](https://github.com/commitizen-tools/commitizen) to do git commit. We follow [conventional commits](https://www.conventionalcommits.org/). 8. Run `./scripts/format` and `./scripts/test` to ensure you follow the coding style and the tests pass. -9. Optionally, update the `./docs/README.md`. +9. Optionally, update the `./docs/README.md` or `docs/images/cli_help` (through running `scripts/gen_cli_help_screenshots.py`). 9. **Do not** update the `CHANGELOG.md`, it will be automatically created after merging to `master`. 10. **Do not** update the versions in the project, they will be automatically updated. 10. If your changes are about documentation. Run `poetry run mkdocs serve` to serve documentation locally and check whether there is any warning or error. diff --git a/docs/images/cli_help/cz_commit___help.svg b/docs/images/cli_help/cz_commit___help.svg index 1b7c98953c..452e419908 100644 --- a/docs/images/cli_help/cz_commit___help.svg +++ b/docs/images/cli_help/cz_commit___help.svg @@ -1,4 +1,4 @@ - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + - + - + - - $ cz commit --help -usage: cz commit [-h][--retry][--no-retry][--dry-run] -[--write-message-to-file FILE_PATH][-s][-a] -[-l MESSAGE_LENGTH_LIMIT] - -create new commit - -options: -  -h, --help            show this help message and exit -  --retry               retry last commit -  --no-retry            skip retry if retry_after_failure is set to true -  --dry-run             show output to stdout, no commit, no modified files -  --write-message-to-file FILE_PATH -                        write message to file before committing (can be -                        combined with --dry-run) -  -s, --signoff         sign off the commit -  -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. -  -l MESSAGE_LENGTH_LIMIT, --message-length-limit MESSAGE_LENGTH_LIMIT -                        length limit of the commit message; 0 for no limit - + + $ cz commit --help +usage: cz commit [-h][--retry][--no-retry][--dry-run] +[--write-message-to-file FILE_PATH][-s][-a][-e] +[-l MESSAGE_LENGTH_LIMIT] + +create new commit + +options: +  -h, --help            show this help message and exit +  --retry               retry last commit +  --no-retry            skip retry if retry_after_failure is set to true +  --dry-run             show output to stdout, no commit, no modified files +  --write-message-to-file FILE_PATH +                        write message to file before committing (can be +                        combined with --dry-run) +  -s, --signoff         sign off the commit +  -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 MESSAGE_LENGTH_LIMIT +                        length limit of the commit message; 0 for no limit + diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py index 03ff51c42c..91a75e0970 100644 --- a/tests/commands/test_commit_command.py +++ b/tests/commands/test_commit_command.py @@ -410,6 +410,36 @@ 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") +@pytest.mark.parametrize("editor", ["vim", None]) +def test_manual_edit(editor, config, mocker: MockFixture, tmp_path): + mocker.patch("commitizen.git.get_core_editor", return_value=editor) + subprocess_mock = mocker.patch("subprocess.call") + + mocker.patch("shutil.which", return_value=editor) + + 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_cmd = commands.Commit(config, {"edit": True}) + + if editor is None: + with pytest.raises(RuntimeError): + commit_cmd.manual_edit(test_message) + else: + edited_message = commit_cmd.manual_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 92f3cf5e87..955b3d8fd7 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 6ada76be6d..8bf995e8a8 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")