Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 40 additions & 13 deletions git-guards/scripts/commit-trailer-guard.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
#!/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:<model>` 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 <noreply@anthropic.com> (email form)
Wrong: Assisted-by: Claude (bare, no model)
Stripped: πŸ€– Generated with [Claude Code](...) (not part of spec)
"""

import json
import re
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.).
Expand Down Expand Up @@ -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},
Expand Down
77 changes: 77 additions & 0 deletions git-guards/scripts/test_commit_trailer_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@

MODEL = "claude-opus-4-7"
OLD_TRAILER = "Assisted-by: Claude <noreply@anthropic.com>"
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:
Expand Down Expand Up @@ -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 <claudette@example.com>"'
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)
Comment thread
JacobPEvans-personal marked this conversation as resolved.
Loading