From ef725c0cc2a285f9c30ae44f5ab53c3c773c4a33 Mon Sep 17 00:00:00 2001 From: Miguel de Benito Delgado Date: Thu, 12 Nov 2026 13:28:35 +0100 Subject: [PATCH] :wrench: Enable checking commit messages from files (#12951) Adds a new flag --message-file to check-commit AI-assisted-by: deepseek-flash --- scripts/check-commit | 65 +++++++++++++++++++++++++++---- scripts/test_check_commit.py | 75 +++++++++++++++++++++++++++++++++++- 2 files changed, 131 insertions(+), 9 deletions(-) diff --git a/scripts/check-commit b/scripts/check-commit index df4672ea59..bc2fa0b6fc 100755 --- a/scripts/check-commit +++ b/scripts/check-commit @@ -13,6 +13,7 @@ Usage: ./scripts/check-commit ./scripts/check-commit --commit HEAD~1 ./scripts/check-commit -c abc1234 + ./scripts/check-commit --message-file .git/COMMIT_EDITMSG """ import argparse @@ -60,15 +61,23 @@ FORBIDDEN_TRAILER_PATTERN = re.compile(r"^\s*Co-authored-by\s*:", re.IGNORECASE) URL_PATTERN = re.compile(r"https?://\S+") +# Git appends the staged diff after this marker for `git commit -v` and +# discards everything after it during cleanup. +SCISSORS_PATTERN = re.compile( + r"^.\s------------------------ >8 ------------------------$", + re.MULTILINE, +) + # ═══════════════════════════════════════════════════════════════════════════════ # Helpers # ═══════════════════════════════════════════════════════════════════════════════ -def run_git(args): +def run_git(args, input_text=None): """Run a git command and return (returncode, stdout, stderr).""" try: result = subprocess.run( ["git"] + args, + input=input_text, capture_output=True, text=True, check=False, @@ -91,6 +100,39 @@ def get_commit_message(commit_ref): return out.rstrip("\n") +def get_message_from_file(path): + """Return the commit message stored in *path*. + + The commit-msg hook receives the message before git removes comment + lines, so strip them the same way git does before validating. + """ + try: + with open(path, encoding="utf-8") as fh: + raw = fh.read() + except (OSError, UnicodeDecodeError) as exc: + print(f"ERROR: could not read message file {path}: {exc}", file=sys.stderr) + sys.exit(1) + + scissors = SCISSORS_PATTERN.search(raw) + if scissors: + raw = raw[:scissors.start()] + + rc, out, err = run_git(["stripspace", "--strip-comments"], input_text=raw) + if rc != 0: + print( + f"ERROR: could not clean message file {path}: {err.strip()}", + file=sys.stderr, + ) + sys.exit(1) + + message = out.rstrip("\n") + if not message.strip(): + print(f"ERROR: message file {path} has no commit message", file=sys.stderr) + sys.exit(1) + + return message + + # ═══════════════════════════════════════════════════════════════════════════════ # Validators # ═══════════════════════════════════════════════════════════════════════════════ @@ -243,17 +285,26 @@ def main(): parser = argparse.ArgumentParser( description="Check a commit message against Penpot commit guidelines." ) - parser.add_argument( + source = parser.add_mutually_exclusive_group() + source.add_argument( "-c", "--commit", - default="HEAD", + metavar="REF", help="Commit to check (default: HEAD)", ) + source.add_argument( + "-m", "--message-file", + metavar="FILE", + help="Read the commit message from FILE instead of a commit", + ) args = parser.parse_args() - commit_ref = args.commit - message = get_commit_message(commit_ref) - - print(f"Checking commit {commit_ref} ...\n") + if args.message_file is not None: + message = get_message_from_file(args.message_file) + print(f"Checking commit message in {args.message_file} ...\n") + else: + commit_ref = args.commit if args.commit is not None else "HEAD" + message = get_commit_message(commit_ref) + print(f"Checking commit {commit_ref} ...\n") validators = [ ("Regex pattern", check_regex), diff --git a/scripts/test_check_commit.py b/scripts/test_check_commit.py index 037bc8fd56..fca40ccb93 100644 --- a/scripts/test_check_commit.py +++ b/scripts/test_check_commit.py @@ -5,15 +5,20 @@ Run with: python3 scripts/test_check_commit.py -Covers the body line-wrapping validator added to enforce the commit body -wrap rule documented in .serena/memories/workflow/creating-commits.md. +Covers the commit message rules enforced by scripts/check-commit and documented +in .serena/memories/workflow/creating-commits.md. """ +import contextlib import importlib.machinery import importlib.util +import io +import os import pathlib import sys +import tempfile import unittest +import unittest.mock # Loading scripts/check-commit would otherwise emit scripts/__pycache__/. sys.dont_write_bytecode = True @@ -180,5 +185,71 @@ class SubjectRulesRegressionTests(unittest.TestCase): self.assertTrue(ok, error) +class MessageFileTests(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(self.tmpdir.cleanup) + env = unittest.mock.patch.dict( + os.environ, + { + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "core.commentChar", + "GIT_CONFIG_VALUE_0": "#", + }, + ) + env.start() + self.addCleanup(env.stop) + + def write_message(self, text): + path = pathlib.Path(self.tmpdir.name) / "COMMIT_EDITMSG" + path.write_text(text, encoding="utf-8") + return str(path) + + def test_reads_message_from_file(self): + path = self.write_message(":bug: Fix crash\n\nBody.\n") + self.assertEqual( + check_commit.get_message_from_file(path), + ":bug: Fix crash\n\nBody.", + ) + + def test_ignores_comment_lines(self): + path = self.write_message( + ":bug: Fix crash\n\nBody.\n\n" + "# Please enter the commit message for your changes.\n" + "# " + "long comment " * 20 + "\n" + ) + message = check_commit.get_message_from_file(path) + self.assertEqual(message, ":bug: Fix crash\n\nBody.") + ok, error = check_commit.check_body_line_length(message) + self.assertTrue(ok, error) + + def test_comment_only_file_exits(self): + path = self.write_message("# Nothing but a comment\n") + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit): + check_commit.get_message_from_file(path) + + def test_ignores_diff_after_scissors_line(self): + path = self.write_message( + ":bug: Fix crash\n\nBody.\n\n" + "# ------------------------ >8 ------------------------\n" + "diff --git a/f b/f\n" + "+" + "long diff line " * 10 + "\n" + ) + message = check_commit.get_message_from_file(path) + self.assertEqual(message, ":bug: Fix crash\n\nBody.") + + +class CommandLineTests(unittest.TestCase): + def test_empty_message_file_path_is_rejected(self): + argv = ["check-commit", "--message-file", ""] + with unittest.mock.patch.object(sys, "argv", argv): + with contextlib.redirect_stdout(io.StringIO()): + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as raised: + check_commit.main() + self.assertEqual(raised.exception.code, 1) + + if __name__ == "__main__": unittest.main(verbosity=2)