diff --git a/git-guards/scripts/commit-trailer-guard.py b/git-guards/scripts/commit-trailer-guard.py index 9b97108..5a1a14d 100755 --- a/git-guards/scripts/commit-trailer-guard.py +++ b/git-guards/scripts/commit-trailer-guard.py @@ -1,9 +1,12 @@ #!/usr/bin/env python3 """ -commit-trailer-guard.py - PreToolUse hook to rewrite Assisted-by trailer to kernel spec. +commit-trailer-guard.py - PreToolUse hook to enforce kernel coding-assistants spec. -Detects `Assisted-by: Claude <...>` in git commit commands and rewrites to -`Assisted-by: Claude:` per https://docs.kernel.org/process/coding-assistants.html. +Per https://docs.kernel.org/process/coding-assistants.html: + Correct: Assisted-by: Claude:claude-opus-4-7 + Wrong: Assisted-by: Claude (email form) + Wrong: Assisted-by: Claude (bare, no model) + Stripped: šŸ¤– Generated with [Claude Code](...) (not part of spec) """ import json @@ -11,9 +14,18 @@ import sys from pathlib import Path -TRAILER_PATTERN = re.compile(r"Assisted-by:\s*Claude\s*<[^>]*>") +# Matches the email form and the bare form, but NOT the already-correct Agent:model form. +# \b after Claude prevents false matches on names like "Claudette", "Claudine", etc. +TRAILER_PATTERN = re.compile(r"Assisted-by:\s*Claude\b(?:\s*<[^>]*>|(?!\s*:\S))") TRAILER_REPL = "Assisted-by: Claude:{model}" +_ROBOT_URL_PATTERN = r"šŸ¤– Generated with \[Claude Code\]\([^)]*\)" +# When the robot line is preceded by a blank line, replace the pair with a single newline +# so the text before it still ends cleanly (e.g. heredoc EOF stays on its own line). +_ROBOT_DOUBLE_NL = re.compile(r"\n\n" + _ROBOT_URL_PATTERN + r"\n?") +# When the robot line has no preceding blank, remove it entirely. +_ROBOT_SINGLE = re.compile(r"(?:\n|^)" + _ROBOT_URL_PATTERN + r"\n?") + # Matches git global flags that take a value (-C/-c) to strip before subcommand detection. _GIT_GLOBAL_VALUE = re.compile(r'^-[Cc]\s+(?:"[^"]*"|\'[^\']*\'|\S+)\s*') # Matches valueless git global flags (-p/-P/--paginate/etc.). @@ -79,28 +91,43 @@ def main() -> None: tool_input = data.get("tool_input", {}) command = tool_input.get("command", "") - if not TRAILER_PATTERN.search(command): - sys.exit(0) + is_commit = command.startswith("git ") and _is_git_commit(command) + needs_trailer_fix = is_commit and bool(TRAILER_PATTERN.search(command)) + needs_robot_strip = bool(_ROBOT_DOUBLE_NL.search(command) or _ROBOT_SINGLE.search(command)) - if not command.startswith("git ") or not _is_git_commit(command): + if not needs_trailer_fix and not needs_robot_strip: sys.exit(0) - model = _get_model_from_transcript(data.get("transcript_path", "")) - if not model: - sys.exit(0) + model = "" + if needs_trailer_fix: + model = _get_model_from_transcript(data.get("transcript_path", "")) + if not model: + needs_trailer_fix = False + + new_command = command + if needs_robot_strip: + new_command = _ROBOT_DOUBLE_NL.sub("\n", new_command) + new_command = _ROBOT_SINGLE.sub("", new_command) + if needs_trailer_fix: + new_command = TRAILER_PATTERN.sub(TRAILER_REPL.format(model=model), new_command) - new_command = TRAILER_PATTERN.sub(TRAILER_REPL.format(model=model), command) if new_command == command: sys.exit(0) + reason_parts = [] + if needs_trailer_fix: + reason_parts.append(f"trailer rewritten (model={model})") + if needs_robot_strip: + reason_parts.append("robot-signature line stripped") + print(json.dumps({ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow", "permissionDecisionReason": ( - f"Trailer rewritten to kernel coding-assistants spec " + "kernel coding-assistants spec enforced " f"(https://docs.kernel.org/process/coding-assistants.html): " - f"model={model}" + + ", ".join(reason_parts) ), }, "updatedInput": {**tool_input, "command": new_command}, diff --git a/git-guards/scripts/test_commit_trailer_guard.py b/git-guards/scripts/test_commit_trailer_guard.py index 7484855..fad4609 100755 --- a/git-guards/scripts/test_commit_trailer_guard.py +++ b/git-guards/scripts/test_commit_trailer_guard.py @@ -16,7 +16,9 @@ MODEL = "claude-opus-4-7" OLD_TRAILER = "Assisted-by: Claude " +BARE_TRAILER = "Assisted-by: Claude" NEW_TRAILER = f"Assisted-by: Claude:{MODEL}" +ROBOT_LINE = "šŸ¤– Generated with [Claude Code](https://claude.com/claude-code)" def _make_transcript(model: str = MODEL) -> str: @@ -133,6 +135,81 @@ def check(label: str, command: str, expected_decision: str, transcript_path=transcript, ) +# 8. Bare trailer (no email, no model) → rewritten to Agent:model +cmd8 = f'git commit -m "fix: x\n\n{BARE_TRAILER}"' +all_pass &= check( + "bare trailer rewrite", + cmd8, + "allow", + transcript_path=transcript, + expected_new_command=cmd8.replace(BARE_TRAILER, NEW_TRAILER), +) + +# 9. Robot line preceded by blank in commit (already-correct trailer) → robot stripped. +# The blank-line replacement (\n\nšŸ¤–... → \n) leaves a trailing \n inside the message. +cmd9 = f'git commit -m "fix: x\n\n{NEW_TRAILER}\n\n{ROBOT_LINE}"' +expected9 = f'git commit -m "fix: x\n\n{NEW_TRAILER}\n"' +all_pass &= check( + "robot line stripped from commit", + cmd9, + "allow", + transcript_path=transcript, + expected_new_command=expected9, +) + +# 10. Robot line in gh pr create body → stripped (not a git command). +# \n\nšŸ¤–...\n → \n keeps the heredoc EOF on its own line. +pr_cmd = ( + f'gh pr create --title "feat: x" --body "$(cat <<\'EOF\'\n' + f'## Summary\n- did a thing\n\n' + f'{ROBOT_LINE}\n' + f'EOF\n)"' +) +expected10 = ( + f'gh pr create --title "feat: x" --body "$(cat <<\'EOF\'\n' + f'## Summary\n- did a thing\n' + f'EOF\n)"' +) +all_pass &= check( + "robot line stripped from gh pr create", + pr_cmd, + "allow", + transcript_path=transcript, + expected_new_command=expected10, +) + +# 11. Bare trailer + robot line together → both fixed in one pass. +# Robot strip runs first, leaving trailing \n; then trailer fix rewrites BARE → NEW. +cmd11 = f'git commit -m "fix\n\n{BARE_TRAILER}\n\n{ROBOT_LINE}"' +expected11 = f'git commit -m "fix\n\n{NEW_TRAILER}\n"' +all_pass &= check( + "bare trailer + robot line fixed together", + cmd11, + "allow", + transcript_path=transcript, + expected_new_command=expected11, +) + +# 12. Robot line + missing transcript → robot still stripped (robot strip is transcript-independent). +cmd12 = f'git commit -m "fix: x\n\n{ROBOT_LINE}"' +expected12 = 'git commit -m "fix: x\n"' +all_pass &= check( + "robot line stripped even without transcript", + cmd12, + "allow", + transcript_path="", + expected_new_command=expected12, +) + +# 13. Name that starts with "Claude" (Claudette) must NOT be rewritten — word-boundary guard. +cmd13 = 'git commit -m "fix: x\n\nAssisted-by: Claudette "' +all_pass &= check( + "name starting with Claude is not rewritten", + cmd13, + "silent_allow", + transcript_path=transcript, +) + print() print("ALL TESTS PASSED" if all_pass else "SOME TESTS FAILED") sys.exit(0 if all_pass else 1)