diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 2a3a08848..708942b15 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -25,3 +25,12 @@ language: python language_version: python3 minimum_pre_commit_version: "1.4.3" + +- id: commitizen-prepare-commit-msg + name: commitizen prepare commit msg + description: "prepare commit message" + entry: cz commit --commit-msg-file + language: python + language_version: python3 + require_serial: true + minimum_pre_commit_version: "1.4.3" diff --git a/commitizen/cli.py b/commitizen/cli.py index 0b411cba6..b5425f5c6 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -174,6 +174,14 @@ def __call__( "dest": "double_dash", "help": "Positional arguments separator (recommended)", }, + { + "name": "--commit-msg-file", + "help": ( + "ask for the name of the temporal file that contains " + "the commit message. " + "Using it in a git hook script: MSG_FILE=$1" + ), + }, ], }, { diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index abecb3b3c..d2fc8fc2b 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -5,6 +5,7 @@ import shutil import subprocess import tempfile +from os.path import exists import questionary @@ -24,6 +25,7 @@ NothingToCommitError, ) from commitizen.git import smart_open +from commitizen.wrap_stdio import unwrap_stdio, wrap_stdio class Commit: @@ -92,6 +94,17 @@ def manual_edit(self, message: str) -> str: file.unlink() return message + def is_blank_commit_file(self, filename) -> bool: + if not exists(filename): + return True + with open(filename) as f: + for x in f.readlines(): + if len(x) == 0 or x[0] == "#": + continue + elif x[0] != "\r" and x[0] != "\n": + return False + return True + def __call__(self): extra_args: str = self.arguments.get("extra_cli_args", "") @@ -105,6 +118,12 @@ def __call__(self): if is_all: c = git.add("-u") + commit_msg_file: str = self.arguments.get("commit_msg_file") + if commit_msg_file: + if not self.is_blank_commit_file(commit_msg_file): + return + wrap_stdio() + if git.is_staging_clean() and not (dry_run or allow_empty): raise NothingToCommitError("No files added to staging!") @@ -126,9 +145,11 @@ def __call__(self): else: m = self.prompt_commit_questions() + if commit_msg_file: + unwrap_stdio() + if manual_edit: m = self.manual_edit(m) - out.info(f"\n{m}\n") if write_message_to_file: @@ -138,9 +159,18 @@ def __call__(self): if dry_run: raise DryRunExit() + if commit_msg_file: + default_message = "" + with open(commit_msg_file) as f: + default_message = f.read() + with open(commit_msg_file, "w") as f: + f.write(m) + f.write(default_message) + out.success("Commit message is successful!") + return + always_signoff: bool = self.config.settings["always_signoff"] signoff: bool = self.arguments.get("signoff") - if signoff: out.warn( "signoff mechanic is deprecated, please use `cz commit -- -s` instead." diff --git a/commitizen/wrap_stdio/__init__.py b/commitizen/wrap_stdio/__init__.py new file mode 100644 index 000000000..f5be85ec9 --- /dev/null +++ b/commitizen/wrap_stdio/__init__.py @@ -0,0 +1,16 @@ +import sys + +if sys.platform == "win32": # pragma: no cover + from .windows import _unwrap_stdio, _wrap_stdio +elif sys.platform == "linux": + from .linux import _unwrap_stdio, _wrap_stdio # pragma: no cover +else: + from .unix import _unwrap_stdio, _wrap_stdio # pragma: no cover + + +def wrap_stdio(): + _wrap_stdio() + + +def unwrap_stdio(): + _unwrap_stdio() diff --git a/commitizen/wrap_stdio/linux.py b/commitizen/wrap_stdio/linux.py new file mode 100644 index 000000000..b0f39778a --- /dev/null +++ b/commitizen/wrap_stdio/linux.py @@ -0,0 +1,59 @@ +import sys + +if sys.platform == "linux": # pragma: no cover + import os + + class WrapStdinLinux: + def __init__(self): + fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY) + tty = open(fd, "wb+", buffering=0) + self.tty = tty + + def __getattr__(self, key): + if key == "encoding": + return "UTF-8" + return getattr(self.tty, key) + + def __del__(self): + self.tty.close() + + class WrapStdoutLinux: + def __init__(self): + tty = open("/dev/tty", "w") + self.tty = tty + + def __getattr__(self, key): + return getattr(self.tty, key) + + def __del__(self): + self.tty.close() + + backup_stdin = None + backup_stdout = None + backup_stderr = None + + def _wrap_stdio(): + global backup_stdin + backup_stdin = sys.stdin + sys.stdin = WrapStdinLinux() + + global backup_stdout + backup_stdout = sys.stdout + sys.stdout = WrapStdoutLinux() + + global backup_stderr + backup_stderr = sys.stderr + sys.stderr = WrapStdoutLinux() + + def _unwrap_stdio(): + global backup_stdin + sys.stdin.close() + sys.stdin = backup_stdin + + global backup_stdout + sys.stdout.close() + sys.stdout = backup_stdout + + global backup_stderr + sys.stderr.close() + sys.stderr = backup_stderr diff --git a/commitizen/wrap_stdio/unix.py b/commitizen/wrap_stdio/unix.py new file mode 100644 index 000000000..e864b4d4d --- /dev/null +++ b/commitizen/wrap_stdio/unix.py @@ -0,0 +1,68 @@ +import sys + +if sys.platform != "win32" and sys.platform != "linux": # pragma: no cover + import os + import selectors + from asyncio import ( + DefaultEventLoopPolicy, + SelectorEventLoop, + get_event_loop_policy, + set_event_loop_policy, + ) + from io import IOBase + + class WrapStdioUnix: + def __init__(self, stdx: IOBase): + self._fileno = stdx.fileno() + fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY) + if self._fileno == 0: + tty = open(fd, "wb+", buffering=0) + else: + tty = open(fd, "rb+", buffering=0) + self.tty = tty + + def __getattr__(self, key): + if key == "encoding": + return "UTF-8" + return getattr(self.tty, key) + + backup_event_loop_policy = None + backup_stdin = None + backup_stdout = None + backup_stderr = None + + def _wrap_stdio(): + global backup_event_loop_policy + backup_event_loop_policy = get_event_loop_policy() + + event_loop = DefaultEventLoopPolicy() + event_loop.set_event_loop(SelectorEventLoop(selectors.SelectSelector())) + set_event_loop_policy(event_loop) + + global backup_stdin + backup_stdin = sys.stdin + sys.stdin = WrapStdioUnix(sys.stdin) + + global backup_stdout + backup_stdout = sys.stdout + sys.stdout = WrapStdioUnix(sys.stdout) + + global backup_stderr + backup_stdout = sys.stderr + sys.stderr = WrapStdioUnix(sys.stderr) + + def _unwrap_stdio(): + global backup_event_loop_policy + set_event_loop_policy(backup_event_loop_policy) + + global backup_stdin + sys.stdin.close() + sys.stdin = backup_stdin + + global backup_stdout + sys.stdout.close() + sys.stdout = backup_stdout + + global backup_stderr + sys.stderr.close() + sys.stderr = backup_stderr diff --git a/commitizen/wrap_stdio/windows.py b/commitizen/wrap_stdio/windows.py new file mode 100644 index 000000000..2c0856273 --- /dev/null +++ b/commitizen/wrap_stdio/windows.py @@ -0,0 +1,58 @@ +import sys + +if sys.platform == "win32": # pragma: no cover + import msvcrt + import os + from ctypes import c_ulong, windll # noqa + from ctypes.wintypes import HANDLE + from io import IOBase + + STD_INPUT_HANDLE = c_ulong(-10) + STD_OUTPUT_HANDLE = c_ulong(-11) + + class WrapStdioWindows: + def __init__(self, stdx: IOBase): + self._fileno = stdx.fileno() + if self._fileno == 0: + fd = os.open("CONIN$", os.O_RDWR | os.O_BINARY) + tty = open(fd) + handle = HANDLE(msvcrt.get_osfhandle(fd)) # noqa + windll.kernel32.SetStdHandle(STD_INPUT_HANDLE, handle) + elif self._fileno == 1: + fd = os.open("CONOUT$", os.O_RDWR | os.O_BINARY) + tty = open(fd, "w") + handle = HANDLE(msvcrt.get_osfhandle(fd)) # noqa + windll.kernel32.SetStdHandle(STD_OUTPUT_HANDLE, handle) + else: + raise Exception("not defined type") + self._tty = tty + + def __getattr__(self, key): + if key == "encoding" and self._fileno == 0: + return "UTF-8" + return getattr(self._tty, key) + + def __del__(self): + if "_tty" in self.__dict__: + self._tty.close() + + backup_stdin = None + backup_stdout = None + + def _wrap_stdio(): + global backup_stdin + backup_stdin = sys.stdin + sys.stdin = WrapStdioWindows(sys.stdin) + + global backup_stdout + backup_stdout = sys.stdout + sys.stdout = WrapStdioWindows(sys.stdout) + + def _unwrap_stdio(): + global backup_stdin + sys.stdin.close() + sys.stdin = backup_stdin + + global backup_stdout + sys.stdout.close() + sys.stdout = backup_stdout diff --git a/docs/getting_started.md b/docs/getting_started.md index 378b81919..594216c3d 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -95,12 +95,14 @@ repos: - id: commitizen - id: commitizen-branch stages: [push] + - id: commitizen-prepare-commit-msg + stages: [prepare-commit-msg] ``` After the configuration is added, you'll need to run: ```sh -pre-commit install --hook-type commit-msg --hook-type pre-push +pre-commit install --hook-type commit-msg --hook-type pre-push --hook-type prepare-commit-msg ``` If you aren't using both hooks, you needn't install both stages. @@ -109,6 +111,7 @@ If you aren't using both hooks, you needn't install both stages. | ----------------- | ----------------- | | commitizen | commit-msg | | commitizen-branch | pre-push | +| commitizen-prepare-commit-msg | prepare-commit-msg | Note that pre-commit discourages using `master` as a revision, and the above command will print a warning. You should replace the `master` revision with the [latest tag](https://github.com/commitizen-tools/commitizen/tags). This can be done automatically with: diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py index 55751f690..eabe8a223 100644 --- a/tests/commands/test_commit_command.py +++ b/tests/commands/test_commit_command.py @@ -525,3 +525,86 @@ def test_commit_command_shows_description_when_use_help_option( out, _ = capsys.readouterr() file_regression.check(out, extension=".txt") + + +def test_commit_from_pre_commit_msg_hook(config, mocker, capsys): + testargs = ["cz", "commit", "--commit-msg-file", "some_file"] + mocker.patch.object(sys, "argv", testargs) + + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "", + "footer": "", + } + + is_blank_commit_file_mock = mocker.patch( + "commitizen.commands.commit.Commit.is_blank_commit_file" + ) + is_blank_commit_file_mock.return_value = True + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", "", "", 0) + + mocker.patch("commitizen.commands.commit.wrap_stdio") + mocker.patch("commitizen.commands.commit.unwrap_stdio") + reader_mock = mocker.mock_open(read_data="\n\n#test\n") + mocker.patch("builtins.open", reader_mock, create=True) + + cli.main() + + out, _ = capsys.readouterr() + assert "Commit message is successful!" in out + commit_mock.assert_not_called() + + +def test_commit_with_msg_from_pre_commit_msg_hook(config, mocker, capsys): + testargs = ["cz", "commit", "--commit-msg-file", "some_file"] + mocker.patch.object(sys, "argv", testargs) + + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "", + "footer": "", + } + + is_blank_commit_file_mock = mocker.patch( + "commitizen.commands.commit.Commit.is_blank_commit_file" + ) + is_blank_commit_file_mock.return_value = False + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", "", "", 0) + + cli.main() + + prompt_mock.assert_not_called() + commit_mock.assert_not_called() + + +@pytest.mark.parametrize( + "isexist, commitmsg, returnvalue", + [ + [False, "", True], + [True, "\n#test", True], + [True, "test: test\n#test", False], + [True, "#test: test\n#test", True], + ], +) +def test_is_blank_commit_file(config, mocker, isexist, commitmsg, returnvalue): + exists_mock = mocker.patch("commitizen.commands.commit.exists") + exists_mock.return_value = isexist + + reader_mock = mocker.mock_open(read_data=commitmsg) + mocker.patch("builtins.open", reader_mock) + + commit_cmd = commands.Commit(config, {}) + ret = commit_cmd.is_blank_commit_file("test") + assert ret == returnvalue 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 dd1f53f3d..3f61c697f 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,6 +1,7 @@ usage: cz commit [-h] [--retry] [--no-retry] [--dry-run] [--write-message-to-file FILE_PATH] [-s] [-a] [-e] [-l MESSAGE_LENGTH_LIMIT] [--] + [--commit-msg-file COMMIT_MSG_FILE] create new commit @@ -20,3 +21,7 @@ options: -l, --message-length-limit MESSAGE_LENGTH_LIMIT length limit of the commit message; 0 for no limit -- Positional arguments separator (recommended) + --commit-msg-file COMMIT_MSG_FILE + ask for the name of the temporal file that contains + the commit message. Using it in a git hook script: + MSG_FILE=$1 diff --git a/tests/wrap_stdio/test_wrap_stdio.py b/tests/wrap_stdio/test_wrap_stdio.py new file mode 100644 index 000000000..152506956 --- /dev/null +++ b/tests/wrap_stdio/test_wrap_stdio.py @@ -0,0 +1,62 @@ +import sys + +from commitizen import wrap_stdio + + +def test_import_sub_files(): + import commitizen.wrap_stdio.linux # noqa: F401 + import commitizen.wrap_stdio.unix # noqa: F401 + import commitizen.wrap_stdio.windows # noqa: F401 + + +if sys.platform == "win32": # pragma: no cover + pass +elif sys.platform == "linux": + from commitizen.wrap_stdio.linux import WrapStdinLinux, WrapStdoutLinux + + def test_wrap_stdin_linux(mocker): + tmp_stdin = sys.stdin + + mocker.patch("os.open") + readerwriter_mock = mocker.mock_open(read_data="data") + mocker.patch("builtins.open", readerwriter_mock, create=True) + + mocker.patch.object(sys.stdin, "fileno", return_value=0) + + wrap_stdio.wrap_stdio() + + assert sys.stdin != tmp_stdin + assert isinstance(sys.stdin, WrapStdinLinux) + assert sys.stdin.encoding == "UTF-8" + assert sys.stdin.read() == "data" + + wrap_stdio.unwrap_stdio() + + assert sys.stdin == tmp_stdin + + def test_wrap_stdout_linux(mocker): + tmp_stdout = sys.stdout + tmp_stderr = sys.stderr + + mocker.patch("os.open") + readerwriter_mock = mocker.mock_open(read_data="data") + mocker.patch("builtins.open", readerwriter_mock, create=True) + + wrap_stdio.wrap_stdio() + + assert sys.stdout != tmp_stdout + assert isinstance(sys.stdout, WrapStdoutLinux) + sys.stdout.write("stdout") + readerwriter_mock().write.assert_called_with("stdout") + + assert sys.stderr != tmp_stderr + assert isinstance(sys.stderr, WrapStdoutLinux) + sys.stdout.write("stderr") + readerwriter_mock().write.assert_called_with("stderr") + + wrap_stdio.unwrap_stdio() + assert sys.stdout == tmp_stdout + assert sys.stderr == tmp_stderr + +else: + pass