From be58d98d2bab42addf14126e4daae5efb44427bf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 18:48:15 +0000 Subject: [PATCH] chore: gate remote submissions on spotless formatting for AI sessions Add a PreToolUse hook (.claude/hooks/pre-push-spotless.sh, wired in .claude/settings.json) that runs spotlessApply before a git-push subcommand or the create_pull_request MCP tool. If spotless reformats tracked Kotlin, or reports a non-autofixable lint, the call is blocked so the fix is committed first -- turning CI's spotlessCheck failure into an in-session block. Gradle infra/network failures warn and allow, leaving CI as the backstop. Boundary detection tokenizes the command so the words appearing in a commit message do not trip the gate. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015nWg1Wwq5xCE7kPFzWY95o --- .claude/hooks/pre-push-spotless.sh | 96 ++++++++++++++++++++++++++++++ .claude/settings.json | 12 ++++ 2 files changed, 108 insertions(+) create mode 100755 .claude/hooks/pre-push-spotless.sh diff --git a/.claude/hooks/pre-push-spotless.sh b/.claude/hooks/pre-push-spotless.sh new file mode 100755 index 0000000000..ff989f68a6 --- /dev/null +++ b/.claude/hooks/pre-push-spotless.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# PreToolUse gate: make sure Kotlin is spotless-clean BEFORE it leaves the box. +# +# Fires on `git push` (Bash tool) and on the create_pull_request MCP tool. Runs +# `spotlessApply`; if that reformats any tracked .kt/.kts file, the push/PR is +# blocked (exit 2) so the agent commits the formatting fix first. This turns +# CI's `spotlessCheck` failure into an in-session block — no red PR, no round +# trip. `spotlessApply` runs the same formatters CI's `spotlessCheck` verifies, +# so a clean apply means a green check. +set -uo pipefail + +cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0 + +# --- Parse the tool call off stdin; decide whether this call is a boundary. --- +payload="$(cat)" +should_gate="$( + printf '%s' "$payload" | python3 -c ' +import json, shlex, sys +try: + data = json.load(sys.stdin) +except Exception: + print("no"); sys.exit(0) +tool = data.get("tool_name", "") +if tool.endswith("create_pull_request"): + print("yes"); sys.exit(0) +if tool != "Bash": + print("no"); sys.exit(0) +cmd = (data.get("tool_input") or {}).get("command", "") +# Tokenize like a shell so `push` inside a quoted commit message or heredoc +# stays one token and is NOT mistaken for the push subcommand. +try: + tokens = shlex.split(cmd, comments=True) +except ValueError: + tokens = cmd.split() +GLOBAL_WITH_ARG = {"-c", "-C", "--namespace", "--git-dir", "--work-tree", "--exec-path"} +for i, t in enumerate(tokens): + if t != "git" and not t.endswith("/git"): + continue + j = i + 1 + while j < len(tokens): # skip git global options to reach the subcommand + tok = tokens[j] + if tok in GLOBAL_WITH_ARG: + j += 2; continue + if tok.startswith("-"): + j += 1; continue + break + if j < len(tokens) and tokens[j] == "push": + print("yes"); sys.exit(0) +print("no") +' 2>/dev/null +)" + +[ "$should_gate" = "yes" ] || exit 0 + +# Nothing to format if no Kotlin is tracked/changed at all — cheap early out. +if ! git ls-files --error-unmatch '*.kt' '*.kts' >/dev/null 2>&1; then + exit 0 +fi + +# Snapshot Kotlin state (vs HEAD, so staged + unstaged both count) before/after +# formatting; any delta means the committed tree wasn't spotless. +before="$(git diff HEAD -- '*.kt' '*.kts' 2>/dev/null | sha1sum)" + +log="$(mktemp /tmp/spotless-gate.XXXXXX.log)" +if ! ./gradlew spotlessApply >"$log" 2>&1; then + # Distinguish a formatting failure (block) from Gradle being unable to RUN — + # e.g. deps can't resolve in a restricted sandbox. An infra failure must not + # strand the agent; warn and let CI's spotlessCheck be the backstop. + if grep -qiE "could not resolve|could not (get|download)|handshake|connect timed out|no address|unable to (find|resolve) host|read timed out" "$log"; then + echo "WARN: could not run spotlessApply (Gradle infra/network failure), skipping the formatting gate." >&2 + echo " CI's spotlessCheck still enforces formatting on the PR." >&2 + rm -f "$log" + exit 0 + fi + echo "BLOCKED: spotlessApply failed — fix the build/formatting error before pushing." >&2 + echo "----- gradle output (tail) -----" >&2 + tail -n 40 "$log" >&2 + rm -f "$log" + exit 2 +fi +rm -f "$log" + +after="$(git diff HEAD -- '*.kt' '*.kts' 2>/dev/null | sha1sum)" + +if [ "$before" != "$after" ]; then + echo "BLOCKED: spotlessApply reformatted Kotlin files that were about to be pushed." >&2 + echo "The changes below are now in your working tree. Commit them, then retry:" >&2 + echo >&2 + git diff --name-only HEAD -- '*.kt' '*.kts' >&2 + echo >&2 + echo " git add -A && git commit -m 'style: apply spotless' && " >&2 + echo "(CI runs 'spotlessCheck'; pushing now would fail the lint job.)" >&2 + exit 2 +fi + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index 15b897a70f..53540bdb4d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,17 @@ { "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|mcp__github__create_pull_request", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-push-spotless.sh", + "timeout": 180 + } + ] + } + ], "SessionStart": [ { "hooks": [