Skip to content

Commit 0354a9d

Browse files
authored
Merge pull request #731 from crai0/write-message-to-file
feat(commit): add --write-message-to-file option
2 parents 6656cb4 + f04a719 commit 0354a9d

File tree

8 files changed

+198
-1
lines changed

8 files changed

+198
-1
lines changed

commitizen/cli.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import argparse
22
import logging
33
import sys
4+
from pathlib import Path
45
from functools import partial
56
from types import TracebackType
67
from typing import List
@@ -62,10 +63,16 @@
6263
"action": "store_true",
6364
"help": "show output to stdout, no commit, no modified files",
6465
},
66+
{
67+
"name": "--write-message-to-file",
68+
"type": Path,
69+
"metavar": "FILE_PATH",
70+
"help": "write message to file before commiting (can be combined with --dry-run)",
71+
},
6572
{
6673
"name": ["-s", "--signoff"],
6774
"action": "store_true",
68-
"help": "Sign off the commit",
75+
"help": "sign off the commit",
6976
},
7077
],
7178
},

commitizen/commands/commit.py

+9
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
NoAnswersError,
1515
NoCommitBackupError,
1616
NotAGitProjectError,
17+
NotAllowed,
1718
NothingToCommitError,
1819
)
1920
from commitizen.git import smart_open
@@ -63,10 +64,14 @@ def prompt_commit_questions(self) -> str:
6364

6465
def __call__(self):
6566
dry_run: bool = self.arguments.get("dry_run")
67+
write_message_to_file = self.arguments.get("write_message_to_file")
6668

6769
if git.is_staging_clean() and not dry_run:
6870
raise NothingToCommitError("No files added to staging!")
6971

72+
if write_message_to_file is not None and write_message_to_file.is_dir():
73+
raise NotAllowed(f"{write_message_to_file} is a directory")
74+
7075
retry: bool = self.arguments.get("retry")
7176

7277
if retry:
@@ -76,6 +81,10 @@ def __call__(self):
7681

7782
out.info(f"\n{m}\n")
7883

84+
if write_message_to_file:
85+
with smart_open(write_message_to_file, "w") as file:
86+
file.write(m)
87+
7988
if dry_run:
8089
raise DryRunExit()
8190

docs/commit.md

+5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ In your terminal run `cz commit` or the shortcut `cz c` to generate a guided git
66

77
A commit can be signed off using `cz commit --signoff` or the shortcut `cz commit -s`.
88

9+
You can run `cz commit --write-message-to-file COMMIT_MSG_FILE` to additionally save the
10+
generated message to a file. This can be combined with the `--dry-run` flag to only
11+
write the message to a file and not modify files and create a commit. A possible use
12+
case for this is to [automatically prepare a commit message](./tutorials/auto_prepare_commit_message.md).
13+
914
!!! note
1015
To maintain platform compatibility, the `commit` command disable ANSI escaping in its output.
1116
In particular pre-commit hooks coloring will be deactivated as discussed in [commitizen-tools/commitizen#417](https://github.com/commitizen-tools/commitizen/issues/417).
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Automatically prepare message before commit
2+
3+
## About
4+
5+
It can be desirable to use commitizen for all types of commits (i.e. regular, merge,
6+
squash) so that the complete git history adheres to the commit message convention
7+
without ever having to call `cz commit`.
8+
9+
To automatically prepare a commit message prior to committing, you can
10+
use a [prepare-commit-msg Git hook](prepare-commit-msg-docs):
11+
12+
> This hook is invoked by git-commit right after preparing the
13+
> default log message, and before the editor is started.
14+
15+
To automatically perform arbitrary cleanup steps after a succesful commit you can use a
16+
[post-commit Git hook][post-commit-docs]:
17+
18+
> This hook is invoked by git-commit. It takes no parameters, and is invoked after a
19+
> commit is made.
20+
21+
A combination of these two hooks allows for enforcing the usage of commitizen so that
22+
whenever a commit is about to be created, commitizen is used for creating the commit
23+
message. Running `git commit` or `git commit -m "..."` for example, would trigger
24+
commitizen and use the generated commit message for the commit.
25+
26+
## Installation
27+
28+
Copy the hooks from [here](https://github.com/commitizen-tools/hooks) into the `.git/hooks` folder and make them
29+
executable by running the following commands from the root of your Git repository:
30+
31+
```bash
32+
wget -o .git/hooks/prepare-commit-msg https://github.com/commitizen-tools/hooks/prepare-commit-msg.py
33+
chmod +x .git/hooks/prepare-commit-msg
34+
wget -o .git/hooks/post-commit https://github.com/commitizen-tools/hooks/post-commit.py
35+
chmod +x .git/hooks/post-commit
36+
```
37+
38+
## Features
39+
40+
- Commits can be created using both `cz commit` and the regular `git commit`
41+
- The hooks automatically create a backup of the commit message that can be reused if
42+
the commit failed
43+
- The commit message backup can also be used via `cz commit --retry`
44+
45+
[post-commit-docs]: https://git-scm.com/docs/githooks#_post_commit
46+
[prepare-commit-msg-docs]: https://git-scm.com/docs/githooks#_prepare_commit_msg

hooks/post-commit.py

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#!/usr/bin/env python
2+
import os
3+
import tempfile
4+
from pathlib import Path
5+
6+
7+
def post_commit():
8+
backup_file = Path(
9+
tempfile.gettempdir(), f"cz.commit{os.environ.get('USER', '')}.backup"
10+
)
11+
12+
# remove backup file if it exists
13+
if backup_file.is_file():
14+
backup_file.unlink()
15+
16+
17+
if __name__ == "__main__":
18+
exit(post_commit())

hooks/prepare-commit-msg.py

+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/usr/bin/env python
2+
import os
3+
import shutil
4+
import subprocess
5+
import sys
6+
import tempfile
7+
from pathlib import Path
8+
from subprocess import CalledProcessError
9+
10+
11+
def prepare_commit_msg(commit_msg_file: Path) -> int:
12+
# check that commitizen is installed
13+
if shutil.which("cz") is None:
14+
print("commitizen is not installed!")
15+
return 0
16+
17+
# check if the commit message needs to be generated using commitizen
18+
if (
19+
subprocess.run(
20+
[
21+
"cz",
22+
"check",
23+
"--commit-msg-file",
24+
commit_msg_file,
25+
],
26+
capture_output=True,
27+
).returncode
28+
!= 0
29+
):
30+
backup_file = Path(
31+
tempfile.gettempdir(), f"cz.commit{os.environ.get('USER', '')}.backup"
32+
)
33+
34+
if backup_file.is_file():
35+
# confirm if commit message from backup file should be reused
36+
answer = input("retry with previous message? [y/N]: ")
37+
if answer.lower() == "y":
38+
shutil.copyfile(backup_file, commit_msg_file)
39+
return 0
40+
41+
# use commitizen to generate the commit message
42+
try:
43+
subprocess.run(
44+
[
45+
"cz",
46+
"commit",
47+
"--dry-run",
48+
"--write-message-to-file",
49+
commit_msg_file,
50+
],
51+
stdin=sys.stdin,
52+
stdout=sys.stdout,
53+
).check_returncode()
54+
except CalledProcessError as error:
55+
return error.returncode
56+
57+
# write message to backup file
58+
shutil.copyfile(commit_msg_file, backup_file)
59+
60+
61+
if __name__ == "__main__":
62+
# make hook interactive by attaching /dev/tty to stdin
63+
with open("/dev/tty") as tty:
64+
sys.stdin = tty
65+
exit(prepare_commit_msg(sys.argv[1]))

mkdocs.yml

+1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ nav:
4343
- Tutorials:
4444
- Writing commits: "tutorials/writing_commits.md"
4545
- Auto check commits: "tutorials/auto_check.md"
46+
- Auto prepare commit message: "tutorials/auto_prepare_commit_message.md"
4647
- GitLab CI: "tutorials/gitlab_ci.md"
4748
- Github Actions: "tutorials/github_actions.md"
4849
- Jenkins pipeline: "tutorials/jenkins_pipeline.md"

tests/commands/test_commit_command.py

+46
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
NoAnswersError,
1313
NoCommitBackupError,
1414
NotAGitProjectError,
15+
NotAllowed,
1516
NothingToCommitError,
1617
)
1718

@@ -109,6 +110,51 @@ def test_commit_command_with_dry_run_option(config, mocker: MockFixture):
109110
commit_cmd()
110111

111112

113+
@pytest.mark.usefixtures("staging_is_clean")
114+
def test_commit_command_with_write_message_to_file_option(
115+
config, tmp_path, mocker: MockFixture
116+
):
117+
tmp_file = tmp_path / "message"
118+
119+
prompt_mock = mocker.patch("questionary.prompt")
120+
prompt_mock.return_value = {
121+
"prefix": "feat",
122+
"subject": "user created",
123+
"scope": "",
124+
"is_breaking_change": False,
125+
"body": "",
126+
"footer": "",
127+
}
128+
129+
commit_mock = mocker.patch("commitizen.git.commit")
130+
commit_mock.return_value = cmd.Command("success", "", b"", b"", 0)
131+
success_mock = mocker.patch("commitizen.out.success")
132+
133+
commands.Commit(config, {"write_message_to_file": tmp_file})()
134+
success_mock.assert_called_once()
135+
assert tmp_file.exists()
136+
assert tmp_file.read_text() == "feat: user created"
137+
138+
139+
@pytest.mark.usefixtures("staging_is_clean")
140+
def test_commit_command_with_invalid_write_message_to_file_option(
141+
config, tmp_path, mocker: MockFixture
142+
):
143+
prompt_mock = mocker.patch("questionary.prompt")
144+
prompt_mock.return_value = {
145+
"prefix": "feat",
146+
"subject": "user created",
147+
"scope": "",
148+
"is_breaking_change": False,
149+
"body": "",
150+
"footer": "",
151+
}
152+
153+
with pytest.raises(NotAllowed):
154+
commit_cmd = commands.Commit(config, {"write_message_to_file": tmp_path})
155+
commit_cmd()
156+
157+
112158
@pytest.mark.usefixtures("staging_is_clean")
113159
def test_commit_command_with_signoff_option(config, mocker: MockFixture):
114160
prompt_mock = mocker.patch("questionary.prompt")

0 commit comments

Comments
 (0)