Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- id: check-commit-message
name: Check commit message
description: Check commit title length, description wrapping, and attribution trailers
entry: check-commit-messages/check.py --message-file
language: unsupported_script
stages: [commit-msg]
26 changes: 25 additions & 1 deletion check-commit-messages/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Checks every non-merge commit in a pull request.
Long URLs, recognized trailers, quoted text, fenced code blocks, and long
unwrappable tokens are exempt from description wrapping.

## Usage
## GitHub Actions usage

The action requires a checkout with complete history:

Expand All @@ -32,3 +32,27 @@ check-commit-messages:
```

Consumers should pin the action to a complete commit SHA.

## Local commit hook

The same checker is available as a
[`pre-commit`](https://pre-commit.com/) `commit-msg` hook. Add this
configuration to the consuming repository:

```yaml
minimum_pre_commit_version: 4.4.0
repos:
- repo: https://github.com/airlift/github-actions
rev: <commit-sha>
hooks:
- id: check-commit-message
```

Install `pre-commit`, then install the hook in each checkout:

```bash
pre-commit install --hook-type commit-msg --install-hooks
```

The hook checks the proposed message before Git creates the commit. Consumers
should pin the hook to a complete commit SHA.
133 changes: 116 additions & 17 deletions check-commit-messages/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path

RECOMMENDED_SUBJECT_LENGTH = 50
MAX_SUBJECT_LENGTH = 60
Expand Down Expand Up @@ -36,6 +37,8 @@
"gpt",
"windsurf",
)
COMMENT_PREFIX_MARKER = "commit-message-check"
SCISSORS_LINE_SUFFIX = " ------------------------ >8 ------------------------"


@dataclass(frozen=True)
Expand All @@ -62,11 +65,12 @@ class CommitAttributionViolation:
line: str


def run_git(arguments: list[str]) -> str:
def run_git(arguments: list[str], input_text: str | None = None) -> str:
try:
result = subprocess.run(
["git", *arguments],
check=True,
input=input_text,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
Expand Down Expand Up @@ -221,6 +225,21 @@ def get_attribution_violations(
return violations


def check_commit_message(
commit: str,
message: str,
) -> tuple[
list[CommitSubjectViolation],
list[CommitDescriptionViolation],
list[CommitAttributionViolation],
]:
return (
get_subject_violations(commit, message),
get_description_violations(commit, message),
get_attribution_violations(commit, message),
)


def check_commit_messages(
revision_range: str,
) -> tuple[
Expand All @@ -236,9 +255,14 @@ def check_commit_messages(

for commit in commits:
message = get_commit_message(commit)
subject_violations.extend(get_subject_violations(commit, message))
description_violations.extend(get_description_violations(commit, message))
attribution_violations.extend(get_attribution_violations(commit, message))
(
commit_subject_violations,
commit_description_violations,
commit_attribution_violations,
) = check_commit_message(commit, message)
subject_violations.extend(commit_subject_violations)
description_violations.extend(commit_description_violations)
attribution_violations.extend(commit_attribution_violations)

return (
commits,
Expand All @@ -248,6 +272,61 @@ def check_commit_messages(
)


def check_commit_message_file(
message_file: Path,
) -> tuple[
list[CommitSubjectViolation],
list[CommitDescriptionViolation],
list[CommitAttributionViolation],
]:
try:
message = clean_commit_message_file(message_file.read_text())
except OSError as exception:
print(
f"Unable to read commit message file {message_file}: {exception}",
file=sys.stderr,
)
raise SystemExit(1) from exception

return check_commit_message("commit message", message)


def clean_commit_message_file(message: str) -> str:
return strip_commit_comments(
truncate_commit_scissors(message, get_comment_prefix())
)


def get_comment_prefix() -> str:
commented_marker = run_git(
["stripspace", "--comment-lines"],
input_text=f"{COMMENT_PREFIX_MARKER}\n",
)
marker_suffix = f" {COMMENT_PREFIX_MARKER}\n"
if not commented_marker.endswith(marker_suffix):
print(
"Unable to determine Git's configured comment prefix.",
file=sys.stderr,
)
raise SystemExit(1)

return commented_marker[: -len(marker_suffix)]


def truncate_commit_scissors(message: str, comment_prefix: str) -> str:
scissors_line = f"{comment_prefix}{SCISSORS_LINE_SUFFIX}"
lines = message.splitlines(keepends=True)
for line_number, line in enumerate(lines):
if line.rstrip("\r\n") == scissors_line:
return "".join(lines[:line_number])

return message


def strip_commit_comments(message: str) -> str:
return run_git(["stripspace", "--strip-comments"], input_text=message)


def print_subject_violations(
violations: list[CommitSubjectViolation],
) -> None:
Expand Down Expand Up @@ -332,22 +411,36 @@ def parse_args() -> argparse.Namespace:
"and attribution trailers."
)
)
parser.add_argument(
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"revision_range",
nargs="?",
help="Git revision range to check, such as origin/main..HEAD.",
)
group.add_argument(
"--message-file",
type=Path,
help="Commit message file to check, as passed to a commit-msg hook.",
)
return parser.parse_args()


def main() -> int:
args = parse_args()
(
commits,
subject_violations,
description_violations,
attribution_violations,
) = check_commit_messages(args.revision_range)
commit_count = len(commits)
if args.message_file is not None:
(
subject_violations,
description_violations,
attribution_violations,
) = check_commit_message_file(args.message_file)
else:
(
commits,
subject_violations,
description_violations,
attribution_violations,
) = check_commit_messages(args.revision_range)
commit_count = len(commits)

if subject_violations:
print_subject_violations(subject_violations)
Expand All @@ -358,11 +451,17 @@ def main() -> int:
if subject_violations or description_violations or attribution_violations:
return 1

noun = "message" if commit_count == 1 else "messages"
print(
f"Checked {commit_count} commit {noun}; subjects and descriptions "
"meet length limits and no prohibited attributions were found."
)
if args.message_file is not None:
print(
"Checked commit message; subject and description meet length limits "
"and no prohibited attributions were found."
)
else:
noun = "message" if commit_count == 1 else "messages"
print(
f"Checked {commit_count} commit {noun}; subjects and descriptions "
"meet length limits and no prohibited attributions were found."
)
return 0


Expand Down
78 changes: 78 additions & 0 deletions check-commit-messages/test_check.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
#!/usr/bin/env python3

import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

from check import (
PROHIBITED_ATTRIBUTION_MARKERS,
SCISSORS_LINE_SUFFIX,
check_commit_message_file,
get_attribution_violations,
get_comment_prefix,
get_description_violations,
get_subject_violations,
truncate_commit_scissors,
)


Expand Down Expand Up @@ -131,6 +139,76 @@ def test_accepts_examples_that_are_not_attribution_trailers(self) -> None:
with self.subTest(message=message):
self.assertEqual(attribution_violating_lines(message), [])

def test_checks_commit_message_file(self) -> None:
with patch("check.get_comment_prefix", return_value="#"):
with tempfile.NamedTemporaryFile(
mode="w+",
encoding="utf-8",
) as message_file:
message_file.write(
f"{'x' * 61}\n\n"
"This line is exactly eighty characters long and it should "
"fail as written here.!\n"
"Co-authored-by: Codex <bot@example.com>\n"
"# This comment is intentionally long enough to fail if "
"commit template comments are validated.\n"
"# ------------------------ >8 ------------------------\n"
"This verbose diff line is intentionally long enough to "
"fail if content after the scissors line is validated.\n"
)
message_file.flush()

(
subject_violations,
description_violations,
attribution_violations,
) = check_commit_message_file(Path(message_file.name))

self.assertEqual(
[violation.length for violation in subject_violations],
[61],
)
self.assertEqual(
[violation.line_number for violation in description_violations],
[3],
)
self.assertEqual(
[violation.line_number for violation in attribution_violations],
[4],
)

def test_keeps_scissors_line_with_different_comment_prefix(self) -> None:
message = (
"Add useful check\n\n"
f"x{SCISSORS_LINE_SUFFIX}\n"
"This content remains part of the commit message.\n"
)

self.assertEqual(truncate_commit_scissors(message, "#"), message)

def test_reads_configured_comment_prefix(self) -> None:
with patch.dict(
os.environ,
{
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": "core.commentString",
"GIT_CONFIG_VALUE_0": "//",
},
):
self.assertEqual(get_comment_prefix(), "//")

def test_truncates_scissors_line_with_configured_comment_prefix(self) -> None:
message = (
"Add useful check\n\n"
f"//{SCISSORS_LINE_SUFFIX}\n"
"This content is excluded from the commit message.\n"
)

self.assertEqual(
truncate_commit_scissors(message, "//"),
"Add useful check\n\n",
)


def commit_message(body: str) -> str:
return f"Add useful check\n\n{body}"
Expand Down