🔧 Enable checking commit messages from files (#12951)

Adds a new flag --message-file to check-commit

AI-assisted-by: deepseek-flash
This commit is contained in:
Miguel de Benito Delgado 2026-11-12 13:28:35 +01:00 committed by GitHub
parent d829e47a04
commit ef725c0cc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 131 additions and 9 deletions

View File

@ -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),

View File

@ -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)