From ee02437e3dfef0adda19430a2890398acddebcce Mon Sep 17 00:00:00 2001 From: Corwin Brust Date: Mon, 5 Oct 2026 19:45:42 -0500 Subject: [PATCH] Add a commit-msg hook for this project's attribution form card-games is published, so its commit messages are permanent public record. The new hook refuses a session-link trailer and a Co-Authored-By trailer naming Claude or anthropic.com, and checks that any Assisted-by trailer reads NAME (for PERSON; MODEL). A message with no Assisted-by line passes: attribution stays the author's call. * hooks/commit-msg: New hook. * hooks/applypatch-msg: New hook; runs commit-msg for git am. * test/commit-msg-tests.el: New ERT tests that drive the sh hook. * Makefile (GITHOOKS): New variable. (hooks): Install all three hooks. (test-hooks): New target. (check): Run it. Assisted-by: Mentat (for Corwin; claude-opus-5-5) --- Makefile | 22 ++++++++---- hooks/applypatch-msg | 11 ++++++ hooks/commit-msg | 59 +++++++++++++++++++++++++++++++ test/commit-msg-tests.el | 76 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 7 deletions(-) create mode 100755 hooks/applypatch-msg create mode 100755 hooks/commit-msg create mode 100644 test/commit-msg-tests.el diff --git a/Makefile b/Makefile index f322d9b..4320f84 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ README = README.md EXTRA = README.org $(README) $(PKGDESC) $(MANUAL) $(VERTEXI) $(IMAGES) .PHONY: all compile test clean distclean checkdoc lint check package tarball elpa release help \ - info info-emacs html pdf docclean version readme hooks + info info-emacs html pdf docclean version readme hooks test-hooks help: @echo "card-games $(VERSION) -- make targets:" @@ -32,7 +32,8 @@ help: @echo " check compile + checkdoc + lint + test (the full gate)" @echo " version regenerate $(VERTEXI) from VERSION" @echo " readme export README.org -> README.md (Emacs batch)" - @echo " hooks install the git pre-commit hook" + @echo " hooks install the git hooks (pre-commit, commit-msg, applypatch-msg)" + @echo " test-hooks test the commit-msg hook" @echo " info build the Info manual ($(INFO)) with makeinfo" @echo " info-emacs build the Info manual with Emacs alone (fallback)" @echo " html build the one-file HTML manual" @@ -51,9 +52,13 @@ compile: test: $(BATCH) -L test -l test/$(PKG)-tests.el -f ert-run-tests-batch-and-exit +# The commit-msg hook is sh; its tests are ERT, so each checks the other. +test-hooks: + $(EMACS) -Q --batch -l test/commit-msg-tests.el -f ert-run-tests-batch-and-exit + # One gate a contributor (or CI) can run: the same quality tools MELPA # asks for -- byte-compile, checkdoc, package-lint -- plus the ERT suite. -check: compile checkdoc lint test +check: compile checkdoc lint test test-hooks @echo "card-games $(VERSION): compile + checkdoc + lint + tests all green" checkdoc: @@ -111,12 +116,15 @@ readme: $(README) $(README): README.org build.el $(BATCH) -l build.el -# Install the pre-commit hook into this checkout's .git/hooks. +# Install the git hooks into this checkout's .git/hooks. +GITHOOKS = pre-commit commit-msg applypatch-msg hooks: @if [ -d .git ]; then \ - cp hooks/pre-commit .git/hooks/pre-commit && \ - chmod +x .git/hooks/pre-commit && \ - echo "Installed .git/hooks/pre-commit"; \ + for h in $(GITHOOKS); do \ + cp hooks/$$h .git/hooks/$$h && \ + chmod +x .git/hooks/$$h && \ + echo "Installed .git/hooks/$$h" || exit 1; \ + done; \ else \ echo "No .git directory here; skipping hook install."; \ fi diff --git a/hooks/applypatch-msg b/hooks/applypatch-msg new file mode 100755 index 0000000..a8f9d72 --- /dev/null +++ b/hooks/applypatch-msg @@ -0,0 +1,11 @@ +#!/bin/sh +# hooks/applypatch-msg -- give patches applied with `git am' the same +# trailer check as an ordinary commit, by running hooks/commit-msg. +# +# Install: make hooks + +hook=$(git rev-parse --git-path hooks/commit-msg) +if [ -x "$hook" ]; then + exec "$hook" "$@" +fi +exit 0 diff --git a/hooks/commit-msg b/hooks/commit-msg new file mode 100755 index 0000000..7e85662 --- /dev/null +++ b/hooks/commit-msg @@ -0,0 +1,59 @@ +#!/bin/sh +# hooks/commit-msg -- keep commit trailers in this project's attribution form. +# +# card-games is published (MELPA, and bound for GNU ELPA), so every commit +# message is a permanent public record. This hook refuses two trailers the +# project has not agreed to and checks the one form it has: +# +# refused a session-link trailer (Claude-Session and the like) +# refused Co-Authored-By naming Claude or anthropic.com +# checked Assisted-by: NAME (for PERSON; MODEL) +# e.g. Assisted-by: Mentat (for Corwin; claude-opus-5-5) +# A comma in place of the semicolon is accepted too. +# +# Attribution is the author's call: a message with no Assisted-by line +# passes. Comment lines, and everything after the scissors line that +# `git commit -v' adds, are not checked. +# +# Install: make hooks (also installs applypatch-msg, so `git am' is checked) +# Bypass: git commit --no-verify -- a hook is a check, not a lock. +# Tests: make test-hooks +# +# Usage: commit-msg MESSAGE-FILE Exit 0 accepts, 1 refuses. + +set -u + +msg=${1:?usage: commit-msg MESSAGE-FILE} +cr=$(printf '\r') +status=0 + +refuse () { + printf 'commit-msg: %s\n %s\n' "$1" "$2" >&2 + status=1 +} + +while IFS= read -r line || [ -n "$line" ]; do + line=${line%"$cr"} + case $line in + '# ------------------------ >8 ------------------------'*) break ;; + '#'*) continue ;; + esac + lower=$(printf '%s' "$line" | tr '[:upper:]' '[:lower:]') + case $lower in + claude-session:*) + refuse 'session links are not used in this project:' "$line" ;; + co-authored-by:*claude*|co-authored-by:*anthropic.com*) + refuse 'credit AI help as Assisted-by: NAME (for PERSON; MODEL), not:' "$line" ;; + assisted-by:*) + if ! printf '%s\n' "$line" | + grep -Eq '^[Aa]ssisted-[Bb]y: [^ (][^(]* \(for [^;,()]+[;,] [^ ;,()]+\)$' + then + refuse 'expected Assisted-by: NAME (for PERSON; MODEL), got:' "$line" + fi ;; + esac +done < "$msg" + +if [ "$status" -ne 0 ]; then + echo 'commit-msg: refused; fix the message and try again.' >&2 +fi +exit "$status" diff --git a/test/commit-msg-tests.el b/test/commit-msg-tests.el new file mode 100644 index 0000000..9d10808 --- /dev/null +++ b/test/commit-msg-tests.el @@ -0,0 +1,76 @@ +;;; commit-msg-tests.el --- ERT tests for hooks/commit-msg -*- lexical-binding: t; -*- + +;; Run with: make test-hooks (or) +;; emacs -Q --batch -l test/commit-msg-tests.el -f ert-run-tests-batch-and-exit +;; +;; The hook is POSIX sh; these tests drive it from Emacs Lisp, so the +;; check and its tests are written in different languages. Each test +;; feeds one message file to the hook and reads only its exit status. +;; The table holds messages that must pass and messages that must be +;; refused, so a hook that accepts everything, or refuses everything, +;; fails here. + +;;; Code: + +(require 'ert) + +(defconst cmt--hook + (expand-file-name "../hooks/commit-msg" + (file-name-directory (or load-file-name buffer-file-name))) + "The hook under test.") + +(defun cmt--status (message) + "Return the exit status of the hook run on MESSAGE." + (let ((file (make-temp-file "commit-msg-test-"))) + (unwind-protect + (progn + (let ((coding-system-for-write 'no-conversion)) + (with-temp-file file (insert message))) + (call-process "sh" nil nil nil cmt--hook file)) + (delete-file file)))) + +(defconst cmt--subject "Fix a thing\n\nBody text.\n\n") + +(defconst cmt--accept + `(("no trailer at all" . "Fix a thing\n\nBody text.\n") + ("persona form, semicolon" . ,(concat cmt--subject "Assisted-by: Mentat (for Corwin; claude-opus-5-5)\n")) + ("persona form, comma" . ,(concat cmt--subject "Assisted-by: Mentat (for Corwin, claude-opus-5-5)\n")) + ("key case varies" . ,(concat cmt--subject "Assisted-By: Mentat (for Corwin; claude-opus-5-5)\n")) + ("CRLF line endings" . "Fix a thing\r\n\r\nAssisted-by: Mentat (for Corwin; claude-opus-5-5)\r\n") + ("no final newline" . ,(concat cmt--subject "Assisted-by: Mentat (for Corwin; claude-opus-5-5)")) + ("comment line ignored" . ,(concat cmt--subject "# Claude-Session: https://example.invalid/x\n")) + ("after the scissors line" . ,(concat cmt--subject + "# ------------------------ >8 ------------------------\n" + "+Claude-Session: https://example.invalid/x\n" + "+Co-Authored-By: Claude \n"))) + "Messages the hook must accept, by name.") + +(defconst cmt--refuse + `(("session link" . ,(concat cmt--subject "Claude-Session: https://claude.ai/code/session_x\n")) + ("session link, lower case" . ,(concat cmt--subject "claude-session: https://claude.ai/code/session_x\n")) + ("co-author, Claude" . ,(concat cmt--subject "Co-Authored-By: Claude Opus 5.5 \n")) + ("co-author, lower case" . ,(concat cmt--subject "co-authored-by: Someone \n")) + ("older Assisted-by form" . ,(concat cmt--subject "Assisted-by: Claude:claude-opus-4-8\n")) + ("persona form, no model" . ,(concat cmt--subject "Assisted-by: Mentat (for Corwin)\n")) + ("persona form, no person" . ,(concat cmt--subject "Assisted-by: Mentat (claude-opus-5-5)\n")) + ("good line then a bad one" . ,(concat cmt--subject + "Assisted-by: Mentat (for Corwin; claude-opus-5-5)\n" + "Claude-Session: https://claude.ai/code/session_x\n"))) + "Messages the hook must refuse, by name.") + +(ert-deftest cmt-accepts () + "Every message in `cmt--accept' passes the hook." + (skip-unless (executable-find "sh")) + (dolist (case cmt--accept) + (should (equal (cons (car case) 0) + (cons (car case) (cmt--status (cdr case))))))) + +(ert-deftest cmt-refuses () + "Every message in `cmt--refuse' is refused by the hook." + (skip-unless (executable-find "sh")) + (dolist (case cmt--refuse) + (should (equal (cons (car case) 1) + (cons (car case) (cmt--status (cdr case))))))) + +(provide 'commit-msg-tests) +;;; commit-msg-tests.el ends here