Skip to content

Commit

Permalink
Merge pull request #731 from crai0/write-message-to-file
Browse files Browse the repository at this point in the history
feat(commit): add --write-message-to-file option
  • Loading branch information
woile authored May 1, 2023
2 parents 6656cb4 + f04a719 commit 0354a9d
Show file tree
Hide file tree
Showing 8 changed files with 198 additions and 1 deletion.
9 changes: 8 additions & 1 deletion commitizen/cli.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
},
],
},
Expand Down
9 changes: 9 additions & 0 deletions commitizen/commands/commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
NoAnswersError,
NoCommitBackupError,
NotAGitProjectError,
NotAllowed,
NothingToCommitError,
)
from commitizen.git import smart_open
Expand Down Expand Up @@ -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:
Expand All @@ -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()

Expand Down
5 changes: 5 additions & 0 deletions docs/commit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
46 changes: 46 additions & 0 deletions docs/tutorials/auto_prepare_commit_message.md
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions hooks/post-commit.py
Original file line number Diff line number Diff line change
@@ -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())
65 changes: 65 additions & 0 deletions hooks/prepare-commit-msg.py
Original file line number Diff line number Diff line change
@@ -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]))
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
46 changes: 46 additions & 0 deletions tests/commands/test_commit_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
NoAnswersError,
NoCommitBackupError,
NotAGitProjectError,
NotAllowed,
NothingToCommitError,
)

Expand Down Expand Up @@ -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")
Expand Down

0 comments on commit 0354a9d

Please sign in to comment.