Every team has that one PR that slips through review with a hardcoded API key, an unchecked null pointer, or a SQL injection waiting to happen. Code review catches most of these, but reviewers are human — they get tired, they skim, they miss things on Friday at 4pm. Git hooks that run AI analysis on your staged changes can catch what humans miss, before the code even leaves your machine.
This tutorial walks through building two hooks: a pre-commit hook that reviews staged files for bugs and security issues, and a pre-push hook that generates a full diff analysis against your target branch. Both use Claude via EzAI's API, and both run fast enough that you won't rage-quit and --no-verify every commit.
Why Git Hooks Instead of CI?
CI-based code review tools run after you push. That means the bad code is already in your remote, already visible in the PR, and you're doing a fix-up commit with a sheepish message like "oops, remove debug log." Git hooks catch problems locally, before anyone sees them. The feedback loop drops from minutes to seconds.
The tradeoff is hooks run on the developer's machine — they need to be fast, non-blocking on network failures, and easy to bypass when needed. We'll handle all three.
Project Setup
You need an EzAI API key and Python 3.8+. The hook scripts are self-contained — no frameworks, no package managers, just stdlib plus one HTTP call.
# Set your EzAI key (one-time)
export EZAI_API_KEY="sk-your-key-here"
# Create the hooks directory in your repo
mkdir -p .githooks
git config core.hooksPath .githooks
Using .githooks/ instead of .git/hooks/ means you can commit the hooks to your repo. Everyone on the team gets them automatically.
The Pre-Commit Hook: Staged File Review
This hook grabs every staged file, sends the diff to Claude, and blocks the commit if it finds critical issues. It skips binary files, caps the diff at 30KB to keep latency under 3 seconds, and falls through gracefully if the API is unreachable.
#!/usr/bin/env python3
# .githooks/pre-commit
import subprocess, json, urllib.request, os, sys
EZAI_KEY = os.environ.get("EZAI_API_KEY", "")
EZAI_URL = "https://ezaiapi.com/v1/messages"
MODEL = "claude-sonnet-4-5"
MAX_DIFF = 30_000 # chars — keeps latency <3s
def get_staged_diff():
result = subprocess.run(
["git", "diff", "--cached", "--diff-filter=ACMR",
"--no-binary", "--", "."],
capture_output=True, text=True
)
return result.stdout[:MAX_DIFF]
def review_with_ai(diff):
payload = json.dumps({
"model": MODEL,
"max_tokens": 1024,
"messages": [{
"role": "user",
"content": f"""Review this git diff for critical issues ONLY.
Flag: security vulns, hardcoded secrets, null derefs, SQL injection,
race conditions, resource leaks. Ignore style, naming, formatting.
Reply with JSON: {{"block": true/false, "issues": ["..."]}}
If no critical issues, reply: {{"block": false, "issues": []}}
```diff
{diff}
```"""
}]
})
req = urllib.request.Request(EZAI_URL,
data=payload.encode(),
headers={
"x-api-key": EZAI_KEY,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
})
with urllib.request.urlopen(req, timeout=10) as resp:
body = json.loads(resp.read())
text = body["content"][0]["text"]
# Extract JSON from possible markdown fences
if "```" in text:
text = text.split("```")[1].lstrip("json\n")
return json.loads(text)
if __name__ == "__main__":
if not EZAI_KEY:
sys.exit(0) # no key = skip hook
diff = get_staged_diff()
if not diff.strip():
sys.exit(0)
try:
result = review_with_ai(diff)
if result.get("block"):
print("\n🚨 AI found critical issues:")
for issue in result["issues"]:
print(f" ⚠ {issue}")
print("\nFix these or commit with: git commit --no-verify")
sys.exit(1)
print("✅ AI review passed")
except Exception:
print("⏩ AI review skipped (network/timeout)")
sys.exit(0) # never block on infra failure
Make it executable and test it:
chmod +x .githooks/pre-commit
# Stage a file with a hardcoded password to test
echo 'DB_PASSWORD = "admin123"' >> config.py
git add config.py
git commit -m "test"
# Expected: 🚨 AI found critical issues
The Pre-Push Hook: Full Branch Analysis
Two-layer review: pre-commit catches per-file issues, pre-push analyzes the full branch diff
The pre-push hook runs a broader analysis. Instead of file-by-file, it diffs your entire branch against the remote target and asks Claude to review the overall changeset — logic flow, missing error handling, API contract changes, and test coverage gaps.
#!/usr/bin/env python3
# .githooks/pre-push
import subprocess, json, urllib.request, os, sys
EZAI_KEY = os.environ.get("EZAI_API_KEY", "")
EZAI_URL = "https://ezaiapi.com/v1/messages"
MODEL = "claude-sonnet-4-5"
def get_branch_diff():
# Diff against the remote tracking branch
branch = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True
).stdout.strip()
remote = f"origin/{branch}"
result = subprocess.run(
["git", "diff", remote, "--stat"],
capture_output=True, text=True
)
stat = result.stdout
diff = subprocess.run(
["git", "diff", remote, "--no-binary"],
capture_output=True, text=True
).stdout[:50_000]
return branch, stat, diff
def analyze_branch(branch, stat, diff):
payload = json.dumps({
"model": MODEL,
"max_tokens": 2048,
"messages": [{
"role": "user",
"content": f"""You are a senior engineer reviewing a branch before push.
Branch: {branch}
Stat: {stat}
Analyze for: logic bugs, missing error handling, broken API contracts,
unhandled edge cases, missing test updates.
Reply as markdown with sections: Summary, Issues (if any), Suggestions.
Keep it under 500 words. Be specific — cite file names and line numbers.
```diff
{diff}
```"""
}]
})
req = urllib.request.Request(EZAI_URL,
data=payload.encode(),
headers={
"x-api-key": EZAI_KEY,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
})
with urllib.request.urlopen(req, timeout=30) as resp:
body = json.loads(resp.read())
return body["content"][0]["text"]
if __name__ == "__main__":
if not EZAI_KEY:
sys.exit(0)
try:
branch, stat, diff = get_branch_diff()
if not diff.strip():
sys.exit(0)
print(f"\n🔍 AI reviewing branch '{branch}'...\n")
review = analyze_branch(branch, stat, diff)
print(review)
print("\n✅ Review complete. Push continues.\n")
except Exception:
print("⏩ AI review skipped (network/timeout)")
sys.exit(0) # pre-push is advisory — never block
Notice the difference: pre-commit blocks on critical issues, but pre-push is advisory only. Blocking a push is too disruptive — developers will just disable the hook. Showing the review and letting them decide is the right balance.
Performance: Keeping It Under 3 Seconds
The number-one reason developers disable git hooks is speed. An AI-powered hook that takes 15 seconds per commit will last about a day before everyone adds --no-verify to their aliases. Here's how to keep it fast:
- Cap diff size — 30KB for pre-commit, 50KB for pre-push. Larger diffs get truncated with a note to the model.
- Use Sonnet, not Opus — Claude Sonnet processes code diffs 3-4x faster than Opus with comparable accuracy for bug detection. Through EzAI, Sonnet costs a fraction of direct pricing.
- Set aggressive timeouts — 10s for pre-commit, 30s for pre-push. If the API is slow, skip gracefully.
- Skip unchanged file types — Filter out
.md,.txt,.jsonconfigs from the review. Focus on code files.
On a typical 500-line diff, the pre-commit hook completes in 1.5-2.5 seconds. That's roughly the time it takes for git status to run on a large repo.
Sharing Hooks Across Your Team
Individual hooks are useful. Team-wide hooks are transformative. Here's a setup script that configures hooks for any new clone:
#!/bin/bash
# scripts/setup-hooks.sh — run once after cloning
git config core.hooksPath .githooks
chmod +x .githooks/*
# Check for API key
if [ -z "$EZAI_API_KEY" ]; then
echo "⚠ Set EZAI_API_KEY for AI-powered hooks"
echo " Get a key at https://ezaiapi.com/dashboard"
echo " Hooks still work without it (AI review disabled)"
fi
echo "✅ Git hooks configured"
Add "postinstall": "bash scripts/setup-hooks.sh" to your package.json (or equivalent for your stack), and hooks configure themselves on npm install. For Python projects, call it from your Makefile or setup.py.
Customizing the Review Prompt
The system prompt is where you encode your team's standards. Store it in a committed file so everyone gets the same rules:
// .githooks/review-rules.json
{
"block_on": [
"hardcoded secrets or API keys",
"SQL injection (string concatenation in queries)",
"unchecked null/undefined access",
"open file handles without close/finally"
],
"warn_on": [
"TODO/FIXME without issue tracker link",
"console.log or print statements in prod code",
"functions longer than 50 lines"
],
"ignore": [
"test files",
"generated code",
"migrations"
]
}
Load this in your hook script and inject it into the prompt. Different teams, different rules — but the hook infrastructure stays the same.
Cost Breakdown
A typical developer commits 8-15 times per day. Each pre-commit review uses roughly 1,500 input tokens (diff) and 200 output tokens (JSON response). Through EzAI pricing, that's about $0.003 per review with Claude Sonnet — around $0.04/day per developer. A 10-person team spends less than $10/month for automated code review on every single commit.
Compare that to one production incident caused by a missed null pointer: hours of debugging, an incident postmortem, and a very unhappy on-call engineer at 3am.
What's Next?
Two hooks give you a solid foundation. From here, you can extend into commit message generation (have AI write your commit messages from the diff), changelog automation, and PR description drafting — all using the same EzAI endpoint and the same hook infrastructure.
- Read Build an AI PR Reviewer with GitHub Actions for CI-level review
- Check Prompt Engineering for Production to tune your review prompts
- See EzAI pricing to estimate costs for your team size