From ff56b4e6928c56f08875042d21f859645bf67e3e Mon Sep 17 00:00:00 2001 From: crai0 Date: Fri, 28 Apr 2023 17:52:49 +0200 Subject: [PATCH 01/11] feat(commit): add --write-message-to-file option --- commitizen/cli.py | 7 ++++++- commitizen/commands/commit.py | 13 +++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/commitizen/cli.py b/commitizen/cli.py index b59c257db3..f1f269a621 100644 --- a/commitizen/cli.py +++ b/commitizen/cli.py @@ -62,10 +62,15 @@ "action": "store_true", "help": "show output to stdout, no commit, no modified files", }, + { + "name": "--write-message-to-file", + "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..379f80ba1b 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,18 @@ 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: + if not isinstance(write_message_to_file, str): + raise NotAllowed( + "Commit message file name is broken.\n" + "Check the flag `--write-message-to-file` in the terminal" + ) + retry: bool = self.arguments.get("retry") if retry: @@ -76,6 +85,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() From af8e276eeceed8868a43a62fbffe87dc96444525 Mon Sep 17 00:00:00 2001 From: crai0 Date: Fri, 28 Apr 2023 17:53:39 +0200 Subject: [PATCH 02/11] test(commit): add test for --write-message-to-file option --- tests/commands/test_commit_command.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py index dd62fafe85..089132b839 100644 --- a/tests/commands/test_commit_command.py +++ b/tests/commands/test_commit_command.py @@ -109,6 +109,32 @@ 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": str(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_signoff_option(config, mocker: MockFixture): prompt_mock = mocker.patch("questionary.prompt") From a2d0cb897e998ab2768c8b57e4c3a47d3d67de9d Mon Sep 17 00:00:00 2001 From: crai0 Date: Fri, 28 Apr 2023 17:58:01 +0200 Subject: [PATCH 03/11] docs(commit): document --write-message-to-file option --- docs/commit.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/commit.md b/docs/commit.md index fb3fdd65ac..36ab328919 100644 --- a/docs/commit.md +++ b/docs/commit.md @@ -6,6 +6,10 @@ 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. + !!! 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). From dc0c86fe3ab295a140aaa4c8126597e5e7c6751a Mon Sep 17 00:00:00 2001 From: crai0 Date: Fri, 28 Apr 2023 18:01:14 +0200 Subject: [PATCH 04/11] docs(tutorials): add tutorial for automatically preparing commit message --- docs/tutorials/auto_prepare_commit_message.md | 27 +++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 28 insertions(+) create mode 100644 docs/tutorials/auto_prepare_commit_message.md diff --git a/docs/tutorials/auto_prepare_commit_message.md b/docs/tutorials/auto_prepare_commit_message.md new file mode 100644 index 0000000000..91c8817f09 --- /dev/null +++ b/docs/tutorials/auto_prepare_commit_message.md @@ -0,0 +1,27 @@ +# Automatically prepare message before commit + +## About + +To automatically prepare a commit message prior to committing, you can use a [Git hook](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks). + +## How to + +- Step 1: Create a new [`prepare-commit-msg`][prepare-commit-msg-docs] Git hook by running the following commands from the root of the Git repository: + +```sh +cd .git/hooks +touch prepare-commit-msg +chmod +x prepare-commit-msg +``` + +- Step 2: Edit the newly created file and add the following content: + +```sh +#!/bin/sh +COMMIT_MSG_FILE=$1 +exec < /dev/tty && cz commit --dry-run --write-message-to-file $COMMIT_MSG_FILE || true +``` + +See the Git hooks documentation on [`prepare-commit-msg` hooks][prepare-commit-msg-docs] for details on how this works. + +[prepare-commit-msg-docs]: https://git-scm.com/docs/githooks#_prepare_commit_msg 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" From cd93206f37c38dd4392cb8d7589f84d31db67836 Mon Sep 17 00:00:00 2001 From: crai0 Date: Fri, 28 Apr 2023 18:58:20 +0200 Subject: [PATCH 05/11] test(commit): add negative test cases for --write-message-to-file option --- tests/commands/test_commit_command.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py index 089132b839..b116fd9d45 100644 --- a/tests/commands/test_commit_command.py +++ b/tests/commands/test_commit_command.py @@ -12,6 +12,7 @@ NoAnswersError, NoCommitBackupError, NotAGitProjectError, + NotAllowed, NothingToCommitError, ) @@ -135,6 +136,27 @@ def test_commit_command_with_write_message_to_file_option( assert tmp_file.read_text() == "feat: user created" +@pytest.mark.usefixtures("staging_is_clean") +@pytest.mark.parametrize("message_file", [True, False, 0, 1]) +def test_commit_command_with_invalid_write_message_to_file_option( + config, message_file, 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): + print(isinstance(message_file, str)) + commit_cmd = commands.Commit(config, {"write_message_to_file": message_file}) + commit_cmd() + + @pytest.mark.usefixtures("staging_is_clean") def test_commit_command_with_signoff_option(config, mocker: MockFixture): prompt_mock = mocker.patch("questionary.prompt") From 14fac5b12581f799a8b122b6e6f6830fc8c301a1 Mon Sep 17 00:00:00 2001 From: crai0 Date: Sat, 29 Apr 2023 07:33:15 +0200 Subject: [PATCH 06/11] docs(commit): link to prepare-commit-msg hook tutorial --- docs/commit.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/commit.md b/docs/commit.md index 36ab328919..2d50b111ca 100644 --- a/docs/commit.md +++ b/docs/commit.md @@ -8,7 +8,8 @@ A commit can be signed off using `cz commit --signoff` or the shortcut `cz commi 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. +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. From 4e280758acf6e2e2c8e01831d475a538e7e99d0b Mon Sep 17 00:00:00 2001 From: crai0 Date: Sat, 29 Apr 2023 08:54:54 +0200 Subject: [PATCH 07/11] docs(prepare-commit-msg): add additional information to tutorial --- docs/tutorials/auto_prepare_commit_message.md | 20 ++++++++++++++++++- hooks/prepare-commit-msg.sh | 3 +++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100755 hooks/prepare-commit-msg.sh diff --git a/docs/tutorials/auto_prepare_commit_message.md b/docs/tutorials/auto_prepare_commit_message.md index 91c8817f09..1a1a1c40b5 100644 --- a/docs/tutorials/auto_prepare_commit_message.md +++ b/docs/tutorials/auto_prepare_commit_message.md @@ -2,7 +2,20 @@ ## About -To automatically prepare a commit message prior to committing, you can use a [Git hook](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks). +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` manually. + +To automatically prepare a commit message prior to committing, you can +use a [Git hook](prepare-commit-msg-docs): + +> The [prepare-commit-msg] hook is invoked by git-commit right after preparing the +> default log message, and before the editor is started. + +This 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. ## How to @@ -25,3 +38,8 @@ exec < /dev/tty && cz commit --dry-run --write-message-to-file $COMMIT_MSG_FILE See the Git hooks documentation on [`prepare-commit-msg` hooks][prepare-commit-msg-docs] for details on how this works. [prepare-commit-msg-docs]: https://git-scm.com/docs/githooks#_prepare_commit_msg + +## Drawbacks + +If additional hooks are used (e.g. pre-commit) that prevent a commit from being created, +the message has to be created from scratch when commiting again. diff --git a/hooks/prepare-commit-msg.sh b/hooks/prepare-commit-msg.sh new file mode 100755 index 0000000000..1561164fed --- /dev/null +++ b/hooks/prepare-commit-msg.sh @@ -0,0 +1,3 @@ +#!/bin/sh +COMMIT_MSG_FILE=$1 +exec < /dev/tty && cz commit --dry-run --write-message-to-file $COMMIT_MSG_FILE || true From ea3ecce7ab3e06d5a30c085e5e3aa9e6c286e68e Mon Sep 17 00:00:00 2001 From: crai0 Date: Sun, 30 Apr 2023 13:44:18 +0200 Subject: [PATCH 08/11] refactor(commit): change type of write_message_to_file to path --- commitizen/cli.py | 2 ++ commitizen/commands/commit.py | 8 ++------ hooks/prepare-commit-msg.sh | 3 --- 3 files changed, 4 insertions(+), 9 deletions(-) delete mode 100755 hooks/prepare-commit-msg.sh diff --git a/commitizen/cli.py b/commitizen/cli.py index f1f269a621..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 @@ -64,6 +65,7 @@ }, { "name": "--write-message-to-file", + "type": Path, "metavar": "FILE_PATH", "help": "write message to file before commiting (can be combined with --dry-run)", }, diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py index 379f80ba1b..4d9da5c3fa 100644 --- a/commitizen/commands/commit.py +++ b/commitizen/commands/commit.py @@ -69,12 +69,8 @@ def __call__(self): if git.is_staging_clean() and not dry_run: raise NothingToCommitError("No files added to staging!") - if write_message_to_file is not None: - if not isinstance(write_message_to_file, str): - raise NotAllowed( - "Commit message file name is broken.\n" - "Check the flag `--write-message-to-file` in the terminal" - ) + 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") diff --git a/hooks/prepare-commit-msg.sh b/hooks/prepare-commit-msg.sh deleted file mode 100755 index 1561164fed..0000000000 --- a/hooks/prepare-commit-msg.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -COMMIT_MSG_FILE=$1 -exec < /dev/tty && cz commit --dry-run --write-message-to-file $COMMIT_MSG_FILE || true From ab42fd4e42ef01f99196987f89cc029378e8bccc Mon Sep 17 00:00:00 2001 From: crai0 Date: Sat, 29 Apr 2023 11:18:15 +0200 Subject: [PATCH 09/11] test(commit): pass path for write_message_to_file --- tests/commands/test_commit_command.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py index b116fd9d45..b45ac3a552 100644 --- a/tests/commands/test_commit_command.py +++ b/tests/commands/test_commit_command.py @@ -130,16 +130,15 @@ def test_commit_command_with_write_message_to_file_option( commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) success_mock = mocker.patch("commitizen.out.success") - commands.Commit(config, {"write_message_to_file": str(tmp_file)})() + 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") -@pytest.mark.parametrize("message_file", [True, False, 0, 1]) def test_commit_command_with_invalid_write_message_to_file_option( - config, message_file, mocker: MockFixture + config, tmp_path, mocker: MockFixture ): prompt_mock = mocker.patch("questionary.prompt") prompt_mock.return_value = { @@ -152,8 +151,7 @@ def test_commit_command_with_invalid_write_message_to_file_option( } with pytest.raises(NotAllowed): - print(isinstance(message_file, str)) - commit_cmd = commands.Commit(config, {"write_message_to_file": message_file}) + commit_cmd = commands.Commit(config, {"write_message_to_file": tmp_path}) commit_cmd() From 5b4f94469da93463a7357aea6678525068cf313d Mon Sep 17 00:00:00 2001 From: crai0 Date: Sun, 30 Apr 2023 17:49:14 +0200 Subject: [PATCH 10/11] feat(hooks): add prepare-commit-msg and post-commit hooks --- hooks/post-commit.py | 18 ++++++++++ hooks/prepare-commit-msg.py | 65 +++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100755 hooks/post-commit.py create mode 100755 hooks/prepare-commit-msg.py 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])) From f04a719746594e9f4906dd44cc75f773fccd4e1f Mon Sep 17 00:00:00 2001 From: crai0 Date: Sun, 30 Apr 2023 17:49:41 +0200 Subject: [PATCH 11/11] docs(tutorials): add installation guide and feature overview for hooks --- docs/tutorials/auto_prepare_commit_message.md | 51 ++++++++++--------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/docs/tutorials/auto_prepare_commit_message.md b/docs/tutorials/auto_prepare_commit_message.md index 1a1a1c40b5..8def5f2e28 100644 --- a/docs/tutorials/auto_prepare_commit_message.md +++ b/docs/tutorials/auto_prepare_commit_message.md @@ -4,42 +4,43 @@ 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` manually. +without ever having to call `cz commit`. To automatically prepare a commit message prior to committing, you can -use a [Git hook](prepare-commit-msg-docs): +use a [prepare-commit-msg Git hook](prepare-commit-msg-docs): -> The [prepare-commit-msg] hook is invoked by git-commit right after preparing the +> This hook is invoked by git-commit right after preparing the > default log message, and before the editor is started. -This 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. +To automatically perform arbitrary cleanup steps after a succesful commit you can use a +[post-commit Git hook][post-commit-docs]: -## How to +> This hook is invoked by git-commit. It takes no parameters, and is invoked after a +> commit is made. -- Step 1: Create a new [`prepare-commit-msg`][prepare-commit-msg-docs] Git hook by running the following commands from the root of the Git repository: +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. -```sh -cd .git/hooks -touch prepare-commit-msg -chmod +x prepare-commit-msg -``` +## Installation -- Step 2: Edit the newly created file and add the following content: +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: -```sh -#!/bin/sh -COMMIT_MSG_FILE=$1 -exec < /dev/tty && cz commit --dry-run --write-message-to-file $COMMIT_MSG_FILE || true +```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 ``` -See the Git hooks documentation on [`prepare-commit-msg` hooks][prepare-commit-msg-docs] for details on how this works. - -[prepare-commit-msg-docs]: https://git-scm.com/docs/githooks#_prepare_commit_msg +## Features -## Drawbacks +- 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` -If additional hooks are used (e.g. pre-commit) that prevent a commit from being created, -the message has to be created from scratch when commiting again. +[post-commit-docs]: https://git-scm.com/docs/githooks#_post_commit +[prepare-commit-msg-docs]: https://git-scm.com/docs/githooks#_prepare_commit_msg