diff options
| author | Craig Jennings <c@cjennings.net> | 2026-04-19 17:06:10 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-04-19 17:06:10 -0500 |
| commit | 4957c60c9ee985628ad59344e593d20a18ca8fdb (patch) | |
| tree | e8d6659dd2d7dd24126782fa83ccccffc6c6f836 /hooks/git-commit-confirm.py | |
| parent | ab4a07b3c081609a81ee049ec9bbe6ccded09b54 (diff) | |
| download | rulesets-4957c60c9ee985628ad59344e593d20a18ca8fdb.tar.gz rulesets-4957c60c9ee985628ad59344e593d20a18ca8fdb.zip | |
feat(hooks): add global hooks — PreCompact priorities + git/gh confirm modals
Three new machine-wide hooks installed via `make install-hooks`:
- `precompact-priorities.sh` (PreCompact) — injects a priority block into
the compaction prompt so the generated summary retains information most
expensive to reconstruct: unanswered questions, root causes with
file:line, subagent findings as primary evidence, exact numbers/IDs,
A-vs-B decisions, open TODOs, classified-data handling.
- `git-commit-confirm.py` (PreToolUse/Bash) — gates `git commit` behind a
confirmation modal showing parsed message, staged files, diff stats,
author. Parses both HEREDOC and `-m`/`--message` forms.
- `gh-pr-create-confirm.py` (PreToolUse/Bash) — gates `gh pr create`
behind a modal showing title, base ← head, reviewers, labels,
assignees, milestone, draft flag, body (HEREDOC or quoted).
Makefile: adds `install-hooks` / `uninstall-hooks` targets and extends
`list` with a Hooks section. Install prints the settings.json snippet
(in `hooks/settings-snippet.json`) to merge into `~/.claude/settings.json`.
Also: `languages/elisp/claude/hooks/validate-el.sh` now emits JSON with
`hookSpecificOutput.additionalContext` on failure (via new `fail_json()`
helper) so Claude sees a structured error in context, in addition to
the existing stderr output and exit 2.
Patterns synthesized clean-room from fcakyon/claude-codex-settings
(Apache-2.0). Each hook is original content.
Diffstat (limited to 'hooks/git-commit-confirm.py')
| -rwxr-xr-x | hooks/git-commit-confirm.py | 183 |
1 files changed, 183 insertions, 0 deletions
diff --git a/hooks/git-commit-confirm.py b/hooks/git-commit-confirm.py new file mode 100755 index 0000000..bea6410 --- /dev/null +++ b/hooks/git-commit-confirm.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""PreToolUse hook for Bash: gate `git commit` behind a confirmation modal. + +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. + +For non-commit Bash calls, exit 0 with no output — default permission +rules apply. + +Wire in ~/.claude/settings.json (or per-project .claude/settings.json): + + { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "~/.claude/hooks/git-commit-confirm.py" + } + ] + } + ] + } + } +""" + +import json +import re +import subprocess +import sys + + +MAX_FILES_SHOWN = 25 +MAX_MESSAGE_LINES = 30 + + +def main() -> int: + try: + payload = json.loads(sys.stdin.read()) + except (json.JSONDecodeError, ValueError): + return 0 # malformed; don't block + + if payload.get("tool_name") != "Bash": + return 0 + + cmd = payload.get("tool_input", {}).get("command", "") + if not is_git_commit(cmd): + return 0 + + message = extract_commit_message(cmd) + staged = get_staged_files() + stats = get_diff_stats() + author = get_author() + + reason = format_confirmation(message, staged, stats, author) + + output = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "ask", + "permissionDecisionReason": reason, + } + } + print(json.dumps(output)) + return 0 + + +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 + return bool(re.search(r"(?:^|[\s;&|()])git\s+(?:-[^\s]+\s+)*commit\b", cmd)) + + +def extract_commit_message(cmd: str) -> str: + """Parse the commit message from either HEREDOC or -m forms.""" + # HEREDOC form: -m "$(cat <<'EOF' ... EOF)" or -m "$(cat <<EOF ... EOF)" + heredoc = re.search( + r"<<-?\s*['\"]?(\w+)['\"]?\s*\n(.*?)\n\s*\1\b", + cmd, + re.DOTALL, + ) + if heredoc: + return heredoc.group(2).strip() + + # One or more -m flags (simple single/double quotes) + flags = re.findall(r"-m\s+([\"'])(.*?)\1", cmd, re.DOTALL) + if flags: + # Multiple -m flags join with blank line (git's own behavior) + return "\n\n".join(msg for _, msg in flags).strip() + + # --message=... form + long_form = re.findall(r"--message[=\s]([\"'])(.*?)\1", cmd, re.DOTALL) + 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)" + + +def get_staged_files() -> list[str]: + try: + out = subprocess.run( + ["git", "diff", "--cached", "--name-only"], + capture_output=True, + text=True, + timeout=5, + ) + return [line for line in out.stdout.splitlines() if line.strip()] + except (subprocess.SubprocessError, OSError, FileNotFoundError): + return [] + + +def get_diff_stats() -> str: + try: + out = subprocess.run( + ["git", "diff", "--cached", "--shortstat"], + capture_output=True, + text=True, + timeout=5, + ) + return out.stdout.strip() or "(no staged changes — commit may fail)" + except (subprocess.SubprocessError, OSError, FileNotFoundError): + return "(could not read diff stats)" + + +def get_author() -> str: + """Report the git author identity that will own the commit.""" + try: + name = subprocess.run( + ["git", "config", "user.name"], + capture_output=True, + text=True, + timeout=3, + ).stdout.strip() + email = subprocess.run( + ["git", "config", "user.email"], + capture_output=True, + text=True, + timeout=3, + ).stdout.strip() + if name and email: + return f"{name} <{email}>" + return "(git user.name / user.email not configured)" + except (subprocess.SubprocessError, OSError, FileNotFoundError): + return "(could not read git config)" + + +def format_confirmation( + message: str, files: list[str], stats: str, author: str +) -> str: + lines = ["Create commit?", ""] + + lines.append("Author:") + lines.append(f" {author}") + lines.append("") + + lines.append("Message:") + msg_lines = message.splitlines() or ["(empty)"] + for line in msg_lines[:MAX_MESSAGE_LINES]: + lines.append(f" {line}") + if len(msg_lines) > MAX_MESSAGE_LINES: + lines.append(f" ... ({len(msg_lines) - MAX_MESSAGE_LINES} more lines)") + lines.append("") + + lines.append(f"Staged files ({len(files)}):") + for f in files[:MAX_FILES_SHOWN]: + lines.append(f" - {f}") + if len(files) > MAX_FILES_SHOWN: + lines.append(f" ... and {len(files) - MAX_FILES_SHOWN} more") + lines.append("") + + lines.append(f"Stats: {stats}") + + lines.append("") + lines.append("Confirm the message, author, and file list before proceeding.") + return "\n".join(lines) + + +if __name__ == "__main__": + sys.exit(main()) |
