Last active
April 18, 2026 14:42
-
-
Save freQuensy23-coder/c5b19e82c2068206118dd55c84bec5c8 to your computer and use it in GitHub Desktop.
pre-merge-commit hook: LLM code review with mandatory codebase exploration via Explore subagent
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| # --- only run on merges INTO main --- | |
| CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) | |
| if [ "$CURRENT_BRANCH" != "main" ]; then | |
| exit 0 | |
| fi | |
| # --- bypass via env var --- | |
| # NB: pre-merge-commit runs BEFORE git materialises the -m message, | |
| # so the bypass flag cannot come from the commit message. | |
| # Usage: BYPASS_MERGE_REVIEW=1 git merge <branch> | |
| if [ "${BYPASS_MERGE_REVIEW:-}" = "1" ]; then | |
| echo "⚠️ BYPASS_MERGE_REVIEW=1 — skipping automated review" | |
| exit 0 | |
| fi | |
| # --- find the incoming commit SHA + branch name --- | |
| # Modern git (ort strategy) does NOT write .git/MERGE_HEAD for clean auto-merges. | |
| # It exposes the incoming side as an env var: GITHEAD_<sha>=<branch-name>. | |
| MERGE_HEAD_SHA="" | |
| MERGE_BRANCH="" | |
| while IFS= read -r line; do | |
| case "$line" in | |
| GITHEAD_*) | |
| MERGE_HEAD_SHA="${line#GITHEAD_}" | |
| MERGE_HEAD_SHA="${MERGE_HEAD_SHA%%=*}" | |
| MERGE_BRANCH="${line#*=}" | |
| break | |
| ;; | |
| esac | |
| done < <(env) | |
| # Fallback: if env var isn't present (older git, octopus merge, etc.), try MERGE_HEAD ref. | |
| if [ -z "$MERGE_HEAD_SHA" ]; then | |
| MERGE_HEAD_SHA=$(git rev-parse --verify MERGE_HEAD 2>/dev/null || true) | |
| MERGE_BRANCH=$(git name-rev --name-only MERGE_HEAD 2>/dev/null || echo "unknown") | |
| fi | |
| # Final fallback: diff the staged index against HEAD. | |
| # This still captures what's being merged in, just without branch/commit metadata. | |
| if [ -n "$MERGE_HEAD_SHA" ]; then | |
| DIFF=$(git diff HEAD.."$MERGE_HEAD_SHA") | |
| FILES=$(git diff --name-only HEAD.."$MERGE_HEAD_SHA") | |
| COMMITS=$(git log HEAD.."$MERGE_HEAD_SHA" --pretty=format:"%h %s") | |
| else | |
| MERGE_BRANCH="(unknown — pre-merge-commit did not expose merge metadata)" | |
| DIFF=$(git diff --cached HEAD) | |
| FILES=$(git diff --cached --name-only HEAD) | |
| COMMITS="" | |
| fi | |
| if [ -z "$DIFF" ]; then | |
| echo "empty diff, nothing to review" | |
| exit 0 | |
| fi | |
| # --- diff size: if huge, warn and skip --- | |
| DIFF_LINES=$(echo "$DIFF" | wc -l | tr -d ' ') | |
| if [ "$DIFF_LINES" -gt 5000 ]; then | |
| echo "⚠️ diff too large ($DIFF_LINES lines), skipping automated review" | |
| echo " review manually or split the PR" | |
| exit 0 | |
| fi | |
| echo "🔍 reviewing merge of '$MERGE_BRANCH' into main ($DIFF_LINES diff lines)..." | |
| # --- prompt --- | |
| # NB: must use `read -r -d ''` rather than $(cat <<EOF ... EOF), | |
| # because the prompt contains unbalanced ')' characters which | |
| # confuse bash's command-substitution parser even inside a quoted heredoc. | |
| IFS='' read -r -d '' PROMPT <<'PROMPT_EOF' || true | |
| You are reviewing a git diff that is about to be merged into the main branch. | |
| Be strict but reasonable. Your goal is to catch actual problems, not nitpick. | |
| ## 0. MANDATORY: explore the codebase first | |
| Before producing your verdict you MUST spawn the `Explore` subagent (Agent tool, `subagent_type: "Explore"`) to investigate how the changed code fits into the broader codebase. | |
| Do NOT reach a verdict based solely on the diff text — a diff in isolation hides callers, existing conventions, related tests, and adjacent modules. | |
| At minimum, use Explore to answer: | |
| - Where is each changed symbol used elsewhere? Are callers updated consistently? | |
| - Do existing tests already cover this area? What patterns do they use? | |
| - Are there sibling files/modules that establish conventions this diff should follow? | |
| - For new features: is there a natural place where an E2E test would live? | |
| This run is headless (`claude -p` with no interactive user), so any tool that requires write permission (Write, Edit, NotebookEdit, filesystem-mutating Bash, etc.) will be automatically denied by the harness. You have Read, Grep, Glob, Task/Agent (for Explore), and other read-only tools available. Running Explore is non-negotiable: if you skip it, your verdict will be rejected. | |
| Run the following checks: | |
| ## 1. Product changes vs tests | |
| - List the product-level changes in this diff (new features, new endpoints, new logic branches, changed behaviour). | |
| - For each product change, verify there is a corresponding test. | |
| - If a product change has no test — that's a blocker. | |
| ## 2. Test quality | |
| Tests must actually test something. Flag as bullshit if: | |
| - A test mocks everything and then asserts something trivial (e.g. `assert 1 == 1`, `assert mock.called`) | |
| - A test asserts on implementation details of its own mocks instead of real behaviour | |
| - A test checks static facts about config (e.g. `assert len(CONFIG["sites"]) == 5`) — pointless | |
| - A test has no assertions at all, or only `assert True` | |
| Tests MAY legitimately use: | |
| - In-memory SQLite for DB logic | |
| - Mocks for specific external dependencies (APIs, slow services) | |
| - Fixtures for isolated component testing | |
| The rule: mocking something is fine, mocking EVERYTHING and testing nothing is not. | |
| ## 3. E2E tests for significant features | |
| If the diff introduces a substantial new capability — for example, a new LLM call for classification, a new external integration, a new pipeline stage — there should be an E2E test in a dedicated e2e test directory that exercises the real path end-to-end. | |
| Small changes (bugfixes, refactors, internal helpers) do not need E2E tests. | |
| ## 4. Unit test speed | |
| Unit tests must be fast. Flag unit tests that: | |
| - Make real network calls | |
| - Hit real databases (non-in-memory) | |
| - Sleep for non-trivial durations | |
| - Load large files / real ML models | |
| Slow operations belong in E2E tests, not unit tests. | |
| ## 5. Code style & basic hygiene | |
| - All imports should be at the top of the file (except justified lazy imports) | |
| - No commented-out code blocks left behind | |
| - No debug prints / console.logs left in | |
| - No obvious style inconsistencies with the rest of the file | |
| ## 6. Do NOT review | |
| - Business logic correctness (you don't have enough context) | |
| - Architecture decisions | |
| - Naming preferences beyond outright nonsense | |
| --- | |
| Output ONLY valid JSON, no prose, no markdown fences. Use EXACTLY this structure: | |
| { | |
| "product_changes": ["change 1", "change 2"], | |
| "issues": [ | |
| {"severity": "blocker" | "warning", "category": "tests|e2e|style|speed|hygiene", "detail": "..."} | |
| ], | |
| "summary": "one sentence", | |
| "verdict": "approve" | "reject" | |
| } | |
| Set verdict to "reject" if there is at least one "blocker" issue. Warnings alone do not reject. | |
| PROMPT_EOF | |
| # --- run Claude Code --- | |
| INPUT=$(printf '%s\n\n## Branch: %s\n## Commits:\n%s\n\n## Files changed:\n%s\n\n## Diff:\n%s\n' \ | |
| "$PROMPT" "$MERGE_BRANCH" "$COMMITS" "$FILES" "$DIFF") | |
| STREAM_FILE=$(mktemp -t premergereview.XXXXXX) | |
| trap 'rm -f "$STREAM_FILE"' EXIT | |
| # jq formatter: turns each stream-json event into one human-readable line. | |
| FORMATTER=' | |
| try (fromjson) catch empty | . as $e | | |
| if $e.type == "system" and $e.subtype == "init" then | |
| "🚀 session | cwd=\($e.cwd // "?") | model=\($e.model // "?")" | |
| elif $e.type == "assistant" then | |
| ($e.message.content[]? | | |
| if .type == "thinking" then | |
| (.thinking // "") as $t | | |
| if $t == "" then empty else "💭 " + ($t | gsub("\n"; " ") | if length > 400 then .[0:400] + "…" else . end) end | |
| elif .type == "tool_use" then | |
| if (.name == "Agent" or .name == "Task") then | |
| "🔧 \(.name)[\(.input.subagent_type // "?")] ← " + ((.input.description // "") | gsub("\n"; " ")) | |
| else | |
| "🔧 \(.name) ← " + (.input | tostring | gsub("\n"; " ") | if length > 280 then .[0:280] + "…" else . end) | |
| end | |
| elif .type == "text" then | |
| (.text // "") as $t | | |
| if $t == "" then empty else "💬 " + ($t | gsub("\n"; " ") | if length > 400 then .[0:400] + "…" else . end) end | |
| else empty end) | |
| elif $e.type == "user" then | |
| ($e.message.content[]? | | |
| select(.type == "tool_result") | | |
| (if (.content|type)=="string" then .content | |
| else [.content[]?.text] | join(" ") end) as $r | | |
| " ↳ " + ($r | gsub("\n"; " ") | if length > 240 then .[0:240] + "…" else . end)) | |
| elif $e.type == "result" then | |
| (if $e.subtype == "success" then "✅" else "❌" end) + | |
| " completed in \(($e.duration_ms / 1000) | floor)s | turns=\($e.num_turns) | cost=$\($e.total_cost_usd // 0)" | |
| else empty end | |
| ' | |
| echo "" | |
| echo "── Live review progress ──" | |
| if ! echo "$INPUT" | claude -p \ | |
| --output-format stream-json \ | |
| --verbose \ | |
| --model claude-opus-4-7 \ | |
| 2>/dev/null \ | |
| | tee "$STREAM_FILE" \ | |
| | jq -rR --unbuffered "$FORMATTER"; then | |
| echo "❌ claude call failed" | |
| echo " to merge anyway: BYPASS_MERGE_REVIEW=1 git merge ..." >&2 | |
| exit 1 | |
| fi | |
| echo "── End live progress ──" | |
| # Pull the final text out of the result event in the captured stream | |
| RESULT_TEXT=$(jq -r 'select(.type=="result" and .subtype=="success") | .result' "$STREAM_FILE") | |
| if [ -z "$RESULT_TEXT" ]; then | |
| echo "❌ no result event found in stream" | |
| exit 1 | |
| fi | |
| # Claude sometimes wraps output in ```json — strip it | |
| RESULT_JSON=$(echo "$RESULT_TEXT" | sed 's/^```json//; s/^```//; s/```$//' | jq -c '.' 2>/dev/null) || { | |
| echo "❌ could not parse Claude's response as JSON:" | |
| echo "$RESULT_TEXT" | |
| exit 1 | |
| } | |
| VERDICT=$(echo "$RESULT_JSON" | jq -r '.verdict') | |
| SUMMARY=$(echo "$RESULT_JSON" | jq -r '.summary') | |
| echo "" | |
| echo "── Product changes ──" | |
| echo "$RESULT_JSON" | jq -r '.product_changes[]? // empty' | sed 's/^/ • /' | |
| echo "" | |
| echo "── Issues ──" | |
| ISSUES=$(echo "$RESULT_JSON" | jq -r '.issues | length') | |
| if [ "$ISSUES" = "0" ]; then | |
| echo " (none)" | |
| else | |
| echo "$RESULT_JSON" | jq -r '.issues[] | " [\(.severity | ascii_upcase)] \(.category): \(.detail)"' | |
| fi | |
| echo "" | |
| echo "── Summary ── $SUMMARY" | |
| echo "" | |
| if [ "$VERDICT" = "reject" ]; then | |
| echo "❌ merge rejected" | |
| echo " fix the blockers above, or if Claude is wrong (and the user directly approves):" | |
| echo "" | |
| echo " BYPASS_MERGE_REVIEW=1 git merge <branch> -m 'your message'" | |
| exit 1 | |
| fi | |
| echo "✅ merge approved" | |
| exit 0 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment