From 6c10627192316c51ce87ce797e164be14b7ca379 Mon Sep 17 00:00:00 2001 From: David Phillips Date: Thu, 30 Jul 2026 17:52:54 -0700 Subject: [PATCH] Add local commit message hook Projects need immediate feedback from the same commit-message policy enforced in pull requests. Expose the checker through pre-commit so local hooks and CI share one implementation. --- .pre-commit-hooks.yaml | 6 ++ check-commit-messages/README.md | 26 +++++- check-commit-messages/check.py | 133 ++++++++++++++++++++++++---- check-commit-messages/test_check.py | 78 ++++++++++++++++ 4 files changed, 225 insertions(+), 18 deletions(-) create mode 100644 .pre-commit-hooks.yaml diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000..9902043 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -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] diff --git a/check-commit-messages/README.md b/check-commit-messages/README.md index d262656..bcc281c 100644 --- a/check-commit-messages/README.md +++ b/check-commit-messages/README.md @@ -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: @@ -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: + 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. diff --git a/check-commit-messages/check.py b/check-commit-messages/check.py index ef845da..dcec416 100755 --- a/check-commit-messages/check.py +++ b/check-commit-messages/check.py @@ -5,6 +5,7 @@ import subprocess import sys from dataclasses import dataclass +from pathlib import Path RECOMMENDED_SUBJECT_LENGTH = 50 MAX_SUBJECT_LENGTH = 60 @@ -36,6 +37,8 @@ "gpt", "windsurf", ) +COMMENT_PREFIX_MARKER = "commit-message-check" +SCISSORS_LINE_SUFFIX = " ------------------------ >8 ------------------------" @dataclass(frozen=True) @@ -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, @@ -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[ @@ -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, @@ -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: @@ -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) @@ -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 diff --git a/check-commit-messages/test_check.py b/check-commit-messages/test_check.py index ddbbf5d..9910f49 100755 --- a/check-commit-messages/test_check.py +++ b/check-commit-messages/test_check.py @@ -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, ) @@ -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 \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}"