aboutsummaryrefslogtreecommitdiff
path: root/hooks/git-commit-confirm.py
diff options
context:
space:
mode:
Diffstat (limited to 'hooks/git-commit-confirm.py')
-rwxr-xr-xhooks/git-commit-confirm.py81
1 files changed, 66 insertions, 15 deletions
diff --git a/hooks/git-commit-confirm.py b/hooks/git-commit-confirm.py
index ad2dd66..2441d23 100755
--- a/hooks/git-commit-confirm.py
+++ b/hooks/git-commit-confirm.py
@@ -1,13 +1,23 @@
#!/usr/bin/env python3
-"""PreToolUse hook for Bash: gate `git commit` behind a confirmation modal.
+"""PreToolUse hook for Bash: silent-unless-suspicious gate on `git commit`.
Reads tool-call JSON from stdin. If the Bash command is a `git commit`,
-parse the message (HEREDOC or -m forms), list staged files and diff
-stats, and emit JSON with permissionDecision=ask and a formatted reason
-so the user sees what will actually be committed.
+parse the message, run safety checks, and only emit a confirmation modal
+when one of them fires:
-For non-commit Bash calls, exit 0 with no output — default permission
-rules apply.
+ - AI-attribution patterns in the commit message (Co-Authored-By: Claude,
+ robot emoji, etc.) — the primary leak we want to catch
+ - Message could not be parsed from the command line (no -m / HEREDOC;
+ likely to drop into $EDITOR, which silently blocks Claude)
+ - Zero staged files (the commit will fail; better to ask why)
+ - git author unusable (user.name / user.email not configured)
+
+On a clean, well-formed commit, exit 0 with no output — the commit runs
+without a modal. Non-git-commit Bash calls also exit 0 silent.
+
+Previously this hook asked on every commit; that produced too many benign
+modals for Craig's workflow. The attribution-scan safety is preserved;
+the always-on review is not.
Wire in ~/.claude/settings.json (or per-project .claude/settings.json):
@@ -38,6 +48,11 @@ from _common import read_payload, respond_ask, scan_attribution
MAX_FILES_SHOWN = 25
MAX_MESSAGE_LINES = 30
+UNPARSEABLE_MESSAGE = (
+ "(commit message not parseable from command line; "
+ "will be edited interactively)"
+)
+
def main() -> int:
payload = read_payload()
@@ -53,19 +68,50 @@ def main() -> int:
stats = get_diff_stats()
author = get_author()
- reason = format_confirmation(message, staged, stats, author)
+ issues = collect_issues(message, staged, author)
+ if not issues:
+ return 0 # silent pass-through on clean commits
- hits = scan_attribution(message)
+ reason = format_confirmation(message, staged, stats, author, issues)
+
+ attribution_hits = [
+ i for i in issues if i.startswith("AI-attribution")
+ ]
system_message = (
- f"WARNING — commit message contains AI-attribution patterns: "
- f"{'; '.join(hits)}. Policy forbids AI credit in commits."
- if hits else None
+ f"WARNING — {attribution_hits[0]}. "
+ "Policy forbids AI credit in commits."
+ if attribution_hits else None
)
respond_ask(reason, system_message=system_message)
return 0
+def collect_issues(message: str, staged: list[str], author: str) -> list[str]:
+ """Return a list of human-readable issues worth asking the user about.
+
+ Empty list → silent pass-through. Any hits → modal.
+ """
+ issues: list[str] = []
+
+ hits = scan_attribution(message)
+ if hits:
+ issues.append("AI-attribution pattern in message: " + "; ".join(hits))
+
+ if message == UNPARSEABLE_MESSAGE:
+ issues.append(
+ "commit message not parseable from command — editor will open"
+ )
+
+ if not staged:
+ issues.append("no staged files — the commit will fail")
+
+ if author.startswith("(") and author.endswith(")"):
+ issues.append(f"git author unusable: {author}")
+
+ return issues
+
+
def is_git_commit(cmd: str) -> bool:
"""True if the command invokes `git commit` (possibly with env/cd prefix)."""
# Strip leading assignments and subshells; find a `git commit` word boundary
@@ -94,7 +140,7 @@ def extract_commit_message(cmd: str) -> str:
if long_form:
return "\n\n".join(msg for _, msg in long_form).strip()
- return "(commit message not parseable from command line; will be edited interactively)"
+ return UNPARSEABLE_MESSAGE
def get_staged_files() -> list[str]:
@@ -146,9 +192,14 @@ def get_author() -> str:
def format_confirmation(
- message: str, files: list[str], stats: str, author: str
+ message: str, files: list[str], stats: str, author: str, issues: list[str]
) -> str:
- lines = ["Create commit?", ""]
+ lines = ["Create commit? (flagged for review)", ""]
+
+ lines.append("Issues detected:")
+ for issue in issues:
+ lines.append(f" ! {issue}")
+ lines.append("")
lines.append("Author:")
lines.append(f" {author}")
@@ -172,7 +223,7 @@ def format_confirmation(
lines.append(f"Stats: {stats}")
lines.append("")
- lines.append("Confirm the message, author, and file list before proceeding.")
+ lines.append("Review the issues above before proceeding.")
return "\n".join(lines)