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)
This commit is contained in:
parent
74af63a089
commit
ee02437e3d
4 changed files with 161 additions and 7 deletions
22
Makefile
22
Makefile
|
|
@ -21,7 +21,7 @@ README = README.md
|
||||||
EXTRA = README.org $(README) $(PKGDESC) $(MANUAL) $(VERTEXI) $(IMAGES)
|
EXTRA = README.org $(README) $(PKGDESC) $(MANUAL) $(VERTEXI) $(IMAGES)
|
||||||
|
|
||||||
.PHONY: all compile test clean distclean checkdoc lint check package tarball elpa release help \
|
.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:
|
help:
|
||||||
@echo "card-games $(VERSION) -- make targets:"
|
@echo "card-games $(VERSION) -- make targets:"
|
||||||
|
|
@ -32,7 +32,8 @@ help:
|
||||||
@echo " check compile + checkdoc + lint + test (the full gate)"
|
@echo " check compile + checkdoc + lint + test (the full gate)"
|
||||||
@echo " version regenerate $(VERTEXI) from VERSION"
|
@echo " version regenerate $(VERTEXI) from VERSION"
|
||||||
@echo " readme export README.org -> README.md (Emacs batch)"
|
@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 build the Info manual ($(INFO)) with makeinfo"
|
||||||
@echo " info-emacs build the Info manual with Emacs alone (fallback)"
|
@echo " info-emacs build the Info manual with Emacs alone (fallback)"
|
||||||
@echo " html build the one-file HTML manual"
|
@echo " html build the one-file HTML manual"
|
||||||
|
|
@ -51,9 +52,13 @@ compile:
|
||||||
test:
|
test:
|
||||||
$(BATCH) -L test -l test/$(PKG)-tests.el -f ert-run-tests-batch-and-exit
|
$(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
|
# One gate a contributor (or CI) can run: the same quality tools MELPA
|
||||||
# asks for -- byte-compile, checkdoc, package-lint -- plus the ERT suite.
|
# 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"
|
@echo "card-games $(VERSION): compile + checkdoc + lint + tests all green"
|
||||||
|
|
||||||
checkdoc:
|
checkdoc:
|
||||||
|
|
@ -111,12 +116,15 @@ readme: $(README)
|
||||||
$(README): README.org build.el
|
$(README): README.org build.el
|
||||||
$(BATCH) -l 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:
|
hooks:
|
||||||
@if [ -d .git ]; then \
|
@if [ -d .git ]; then \
|
||||||
cp hooks/pre-commit .git/hooks/pre-commit && \
|
for h in $(GITHOOKS); do \
|
||||||
chmod +x .git/hooks/pre-commit && \
|
cp hooks/$$h .git/hooks/$$h && \
|
||||||
echo "Installed .git/hooks/pre-commit"; \
|
chmod +x .git/hooks/$$h && \
|
||||||
|
echo "Installed .git/hooks/$$h" || exit 1; \
|
||||||
|
done; \
|
||||||
else \
|
else \
|
||||||
echo "No .git directory here; skipping hook install."; \
|
echo "No .git directory here; skipping hook install."; \
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
11
hooks/applypatch-msg
Executable file
11
hooks/applypatch-msg
Executable file
|
|
@ -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
|
||||||
59
hooks/commit-msg
Executable file
59
hooks/commit-msg
Executable file
|
|
@ -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"
|
||||||
76
test/commit-msg-tests.el
Normal file
76
test/commit-msg-tests.el
Normal file
|
|
@ -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 <noreply@anthropic.com>\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 <noreply@anthropic.com>\n"))
|
||||||
|
("co-author, lower case" . ,(concat cmt--subject "co-authored-by: Someone <bot@anthropic.com>\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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue