Skip to content

Instantly share code, notes, and snippets.

@freQuensy23-coder
Last active April 18, 2026 14:42
Show Gist options
  • Select an option

  • Save freQuensy23-coder/c5b19e82c2068206118dd55c84bec5c8 to your computer and use it in GitHub Desktop.

Select an option

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
#!/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