diff --git a/commitizen/cli.py b/commitizen/cli.py index b59c257db3..ed89b5675a 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -1,6 +1,7 @@ import argparse import logging import sys +from pathlib import Path from functools import partial from types import TracebackType from typing import List @@ -62,10 +63,16 @@ "action": "store_true", "help": "show output to stdout, no commit, no modified files", }, + { + "name": "--write-message-to-file", + "type": Path, + "metavar": "FILE_PATH", + "help": "write message to file before commiting (can be combined with --dry-run)", + }, { "name": ["-s", "--signoff"], "action": "store_true", - "help": "Sign off the commit", + "help": "sign off the commit", }, ], }, diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index 13e61abe6d..4d9da5c3fa 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -14,6 +14,7 @@ NoAnswersError, NoCommitBackupError, NotAGitProjectError, + NotAllowed, NothingToCommitError, ) from commitizen.git import smart_open @@ -63,10 +64,14 @@ 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") if git.is_staging_clean() and not dry_run: raise NothingToCommitError("No files added to staging!") + if write_message_to_file is not None and write_message_to_file.is_dir(): + raise NotAllowed(f"{write_message_to_file} is a directory") + retry: bool = self.arguments.get("retry") if retry: @@ -76,6 +81,10 @@ def __call__(self): out.info(f"\n{m}\n") + if write_message_to_file: + with smart_open(write_message_to_file, "w") as file: + file.write(m) + if dry_run: raise DryRunExit() diff --git a/docs/commit.md b/docs/commit.md index fb3fdd65ac..2d50b111ca 100644 --- a/docs/commit.md +++ b/docs/commit.md @@ -6,6 +6,11 @@ In your terminal run `cz commit` or the shortcut `cz c` to generate a guided git A commit can be signed off using `cz commit --signoff` or the shortcut `cz commit -s`. +You can run `cz commit --write-message-to-file COMMIT_MSG_FILE` to additionally save the +generated message to a file. This can be combined with the `--dry-run` flag to only +write the message to a file and not modify files and create a commit. A possible use +case for this is to [automatically prepare a commit message](./tutorials/auto_prepare_commit_message.md). + !!! note To maintain platform compatibility, the `commit` command disable ANSI escaping in its output. In particular pre-commit hooks coloring will be deactivated as discussed in [commitizen-tools/commitizen#417](https://github.com/commitizen-tools/commitizen/issues/417). diff --git a/docs/tutorials/auto_prepare_commit_message.md b/docs/tutorials/auto_prepare_commit_message.md new file mode 100644 index 0000000000..8def5f2e28 --- /dev/null +++ b/docs/tutorials/auto_prepare_commit_message.md @@ -0,0 +1,46 @@ +# Automatically prepare message before commit + +## About + +It can be desirable to use commitizen for all types of commits (i.e. regular, merge, +squash) so that the complete git history adheres to the commit message convention +without ever having to call `cz commit`. + +To automatically prepare a commit message prior to committing, you can +use a [prepare-commit-msg Git hook](prepare-commit-msg-docs): + +> This hook is invoked by git-commit right after preparing the +> default log message, and before the editor is started. + +To automatically perform arbitrary cleanup steps after a succesful commit you can use a +[post-commit Git hook][post-commit-docs]: + +> This hook is invoked by git-commit. It takes no parameters, and is invoked after a +> commit is made. + +A combination of these two hooks allows for enforcing the usage of commitizen so that +whenever a commit is about to be created, commitizen is used for creating the commit +message. Running `git commit` or `git commit -m "..."` for example, would trigger +commitizen and use the generated commit message for the commit. + +## Installation + +Copy the hooks from [here](https://github.com/commitizen-tools/hooks) into the `.git/hooks` folder and make them + executable by running the following commands from the root of your Git repository: + +```bash +wget -o .git/hooks/prepare-commit-msg https://github.com/commitizen-tools/hooks/prepare-commit-msg.py +chmod +x .git/hooks/prepare-commit-msg +wget -o .git/hooks/post-commit https://github.com/commitizen-tools/hooks/post-commit.py +chmod +x .git/hooks/post-commit +``` + +## Features + +- Commits can be created using both `cz commit` and the regular `git commit` +- The hooks automatically create a backup of the commit message that can be reused if + the commit failed +- The commit message backup can also be used via `cz commit --retry` + +[post-commit-docs]: https://git-scm.com/docs/githooks#_post_commit +[prepare-commit-msg-docs]: https://git-scm.com/docs/githooks#_prepare_commit_msg diff --git a/hooks/post-commit.py b/hooks/post-commit.py new file mode 100755 index 0000000000..c2faebb738 --- /dev/null +++ b/hooks/post-commit.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python +import os +import tempfile +from pathlib import Path + + +def post_commit(): + backup_file = Path( + tempfile.gettempdir(), f"cz.commit{os.environ.get('USER', '')}.backup" + ) + + # remove backup file if it exists + if backup_file.is_file(): + backup_file.unlink() + + +if __name__ == "__main__": + exit(post_commit()) diff --git a/hooks/prepare-commit-msg.py b/hooks/prepare-commit-msg.py new file mode 100755 index 0000000000..58beb3a0f8 --- /dev/null +++ b/hooks/prepare-commit-msg.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from subprocess import CalledProcessError + + +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 + + # check if the commit message needs to be generated using commitizen + if ( + subprocess.run( + [ + "cz", + "check", + "--commit-msg-file", + commit_msg_file, + ], + capture_output=True, + ).returncode + != 0 + ): + backup_file = Path( + tempfile.gettempdir(), f"cz.commit{os.environ.get('USER', '')}.backup" + ) + + if backup_file.is_file(): + # confirm if commit message from backup file should be reused + answer = input("retry with previous message? [y/N]: ") + if answer.lower() == "y": + shutil.copyfile(backup_file, commit_msg_file) + return 0 + + # use commitizen to generate the commit message + try: + subprocess.run( + [ + "cz", + "commit", + "--dry-run", + "--write-message-to-file", + commit_msg_file, + ], + stdin=sys.stdin, + stdout=sys.stdout, + ).check_returncode() + except CalledProcessError as error: + return error.returncode + + # write message to backup file + shutil.copyfile(commit_msg_file, backup_file) + + +if __name__ == "__main__": + # make hook interactive by attaching /dev/tty to stdin + with open("/dev/tty") as tty: + sys.stdin = tty + exit(prepare_commit_msg(sys.argv[1])) diff --git a/mkdocs.yml b/mkdocs.yml index da71e30abe..57cf6af46b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,6 +43,7 @@ nav: - Tutorials: - Writing commits: "tutorials/writing_commits.md" - Auto check commits: "tutorials/auto_check.md" + - Auto prepare commit message: "tutorials/auto_prepare_commit_message.md" - GitLab CI: "tutorials/gitlab_ci.md" - Github Actions: "tutorials/github_actions.md" - Jenkins pipeline: "tutorials/jenkins_pipeline.md" diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py index dd62fafe85..b45ac3a552 100644 --- a/tests/commands/test_commit_command.py +++ b/tests/commands/test_commit_command.py @@ -12,6 +12,7 @@ NoAnswersError, NoCommitBackupError, NotAGitProjectError, + NotAllowed, NothingToCommitError, ) @@ -109,6 +110,51 @@ def test_commit_command_with_dry_run_option(config, mocker: MockFixture): commit_cmd() +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_with_write_message_to_file_option( + config, tmp_path, mocker: MockFixture +): + tmp_file = tmp_path / "message" + + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "", + "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, {"write_message_to_file": tmp_file})() + success_mock.assert_called_once() + assert tmp_file.exists() + assert tmp_file.read_text() == "feat: user created" + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_with_invalid_write_message_to_file_option( + config, tmp_path, mocker: MockFixture +): + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "", + "footer": "", + } + + with pytest.raises(NotAllowed): + commit_cmd = commands.Commit(config, {"write_message_to_file": tmp_path}) + commit_cmd() + + @pytest.mark.usefixtures("staging_is_clean") def test_commit_command_with_signoff_option(config, mocker: MockFixture): prompt_mock = mocker.patch("questionary.prompt")