Build a Custom AI-Powered Git Pre-Commit Hook with Python: Smarter Code Quality Checks
Every developer has been there. You write a quick fix, stage the changes, and hit commit. Ten minutes later, CI fails because of a silly bug you missed. Or worse — production breaks.
You know what’s even more painful? Reviewing your own PR and spotting that same bug after it’s already been reviewed by three people.
Your Open Source PRs Are Getting Ignored: The Real Reason and What to Do About It
Your Open Source PRs Are Getting Ignored: The Real Reason and What to Do About It You wrote… ...
It doesn’t have to be this way.
Sure, there are tools like `eslint`, `flake8`, and `prettier` that catch formatting and obvious errors. But they don’t understand context. They don’t catch logic errors, missing edge cases, or subtle anti-patterns. What if your pre-commit hook could actually think about your diff?
Vietnam Outsourcing: Why 2024 Is the Year to Rethink Your Offshore Strategy
TL;DR: Vietnam outsourcing delivers world-class developers at 30% lower cost than India, with near-zero time zone overlap with… ...
In this tutorial, I’ll show you how to build a custom Git pre-commit hook in Python that uses a local LLM (via Ollama) to review staged changes.
No cloud API calls. No rate limits. Just your code, a local model, and instant feedback before you even write a commit message.
Let’s get into it.
Why Bother with an AI Pre-Commit Hook?
Honestly, I was skeptical at first. I thought “I’ll just run my tests locally and trust my code review.” But after a few production incidents that could’ve been prevented by a second pair of eyes, I changed my mind.
Here’s the real deal: a pre-commit hook that runs AI review catches things like:
- Undefined variables that tests might not cover (especially in dynamic Python).
- Security smells – e.g., hardcoded secrets, `eval()` calls, SQL injection patterns.
- Logic inconsistencies – like using a variable before assignment in a complex conditional.
- Missing docstrings or incomplete type hints.
- Exploitable imports (yes, that `pickle` import should raise a flag).
And it does it at the one moment you can still fix it: before the commit is written.
A junior developer in our Vietnam hub once committed a line that used `os.system(f”rm -rf {user_input}”)` in a staging script. Our pre-commit hook flagged it instantly. That’s a call we’d never get back after a `git push`.
What We’ll Build
We’re going to create:
- A Python script (`ai_review_hook.py`) that reads the staged diff, sends it to a local LLM via Ollama, and prints review comments.
- A Git pre-commit hook that invokes this script.
- An optional configuration to customize the model and prompt.
The model runs locally — I’ll use `gemma3:12b` but you can swap to any Ollama-compatible model like `codellama:7b` or `deepseek-coder:6.7b`.
By the end, your workflow will look like this:
$ git add app.py
$ git commit -m "fix edge case in login"
[ai-pre-commit] Reviewing staged diff...
[ai-pre-commit] ⚠️ Warning: Variable 'retries' used but not defined.
[ai-pre-commit] ⚠️ Suggestion: Add error handling for empty response.
[ai-pre-commit] ❌ Security: 'os.system()' call in line 45. Use subprocess.
[ai-pre-commit] Commit blocked due to critical issues.
Step 1: Set Up Your Python Environment
You need Python 3.9+, the `subprocess` module (built-in), and the `ollama` Python client.
bash
# Install ollama (if not already installed)
curl -fsSL https://ollama.com/install.sh | sh
# Pull a model (I recommend gemma3:12b for balance of speed and accuracy)
ollama pull gemma3:12b
# Install the Python client
pip install ollama
Create a project directory for the hook:
bash
mkdir ~/dev/ai-precommit-hook
cd ~/dev/ai-precommit-hook
Step 2: Write the AI Review Script
Create a file called `ai_review.py`:
python
#!/usr/bin/env python3
"""AI-powered pre-commit hook that reviews staged diffs using a local LLM."""
import subprocess
import sys
import ollama
import json
from pathlib import Path
# ---------- Configuration ----------
MODEL = "gemma3:12b"
MAX_DIFF_LENGTH = 8000 # characters – truncate to avoid token limits
BLOCK_ON_CRITICAL = True # Exit with non-zero if critical issues found
# -----------------------------------
def get_staged_diff():
"""Return the unified diff of all staged changes."""
result = subprocess.run(
["git", "diff", "--cached", "--unified=5"],
capture_output=True,
text=True,
)
diff = result.stdout
if not diff:
print("No staged changes to review.")
sys.exit(0)
return diff[:MAX_DIFF_LENGTH]
def build_prompt(diff):
"""Create a prompt for the LLM that asks for a structured review."""
return f"""You are a senior code reviewer. Review the following Git diff
(staged changes only). Output a JSON array of issues. Each issue must have:
- "severity": "critical", "warning", or "info"
- "line": approximate line number (int)
- "message": short description of the problem (max 60 chars)
Focus on: logic errors, security vulnerabilities, undefined variables,
mismatched types, and anti-patterns. Ignore formatting – that's for linters.
Diff:
{diff}
Output ONLY valid JSON array, no markdown, no extra text.
"""
def review_diff(diff):
"""Send the diff to the local LLM and parse the response."""
prompt = build_prompt(diff)
try:
response = ollama.chat(
model=MODEL,
messages=[{"role": "user", "content": prompt}],
options={"temperature": 0.1, "num_predict": 1024},
)
text = response["message"]["content"].strip()
# Remove possible markdown fences
if text.startswith("```json"):
text = text[7:]
if text.endswith("```"):
text = text[:-3]
issues = json.loads(text)
if not isinstance(issues, list):
raise ValueError("Response is not a list")
return issues
except (json.JSONDecodeError, KeyError, ValueError) as e:
print(f"[ai-pre-commit] ⚠️ Could not parse LLM response: {e}")
return []
def print_issues(issues):
"""Display issues and return whether commit should be blocked."""
if not issues:
print("[ai-pre-commit] ✅ No issues found. Commit away!")
return False
critical_found = False
for issue in issues:
severity = issue.get("severity", "info").lower()
line = issue.get("line", "?")
msg = issue.get("message", "Unknown issue")
if severity == "critical":
print(f"[ai-pre-commit] ❌ Critical (line {line}): {msg}")
critical_found = True
elif severity == "warning":
print(f"[ai-pre-commit] ⚠️ Warning (line {line}): {msg}")
else:
print(f"[ai-pre-commit] ℹ️ Info (line {line}): {msg}")
if critical_found and BLOCK_ON_CRITICAL:
print("[ai-pre-commit] Commit blocked due to critical issues.")
return True
return False
def main():
print("[ai-pre-commit] Reviewing staged diff...")
diff = get_staged_diff()
issues = review_diff(diff)
block = print_issues(issues)
if block:
sys.exit(1)
else:
sys.exit(0)
if __name__ == "__main__":
main()
Note on performance: On a modern laptop with a 12B model, the review takes 3–6 seconds per diff. That’s faster than a full CI run. In our Ho Chi Minh City team, we’ve found `gemma3:12b` strikes the best tradeoff between speed and accuracy.
Step 3: Install the Hook
Navigate to your project’s `.git/hooks/` directory. Create a file named `pre-commit` (no extension) and make it executable:
bash
#!/bin/sh
# Pre-commit hook that runs AI review
python3 /path/to/ai_review.py
Replace `/path/to/ai_review.py` with the absolute path to your script. Then:
bash
chmod +x .git/hooks/pre-commit
Important: The hook runs in the context of your repository. Make sure the script has the correct Python environment – use `python3` and ensure `ollama` is installed globally or in a virtualenv that’s accessible.
If you want to share the hook across your team, commit the script to your repo (e.g., in `scripts/ai_review.py`) and have the hook reference it relative to the repo root. Here’s a robust hook script:
bash
#!/bin/sh
REPO_ROOT=$(git rev-parse --show-toplevel)
python3 "$REPO_ROOT/scripts/ai_review.py"
Step 4: Test It
Make a staged change that includes a clear bug:
python
# app.py (staged)
def calculate_discount(price, percentage):
total = price * (1 - percent/100) # typo: 'percent' vs 'percentage'
return round(total, 2)
Now try to commit:
$ git add app.py
$ git commit -m "add discount function"
[ai-pre-commit] Reviewing staged diff...
[ai-pre-commit] ❌ Critical (line 2): 'percent' is undefined; use 'percentage'.
[ai-pre-commit] ℹ️ Info (line 3): Function missing docstring.
[ai-pre-commit] Commit blocked due to critical issues.
You can fix the variable name, re-stage, and commit again. The hook will re-review the new diff.
Step 5: Tune the Hook for Your Project
Out of the box, the prompt is generic. You’ll get better results by customizing it.
Custom Prompt Template
Create a `config.yml` in the same directory as `ai_review.py`:
yaml
# config.yml
prompt_template: |
You are a code reviewer for a Python Django project.
Focus on Django-specific issues:
- Missing migration for model changes
- SQL injection in raw queries
- Incorrect ORM usage
- Security settings (DEBUG=True left in prod)
Review this diff:
{diff}
Output JSON array with severity, line, message.
Then modify the script to read the config:
python
import yaml
def load_config():
config_path = Path(__file__).parent / "config.yml"
if config_path.exists():
with open(config_path) as f:
return yaml.safe_load(f)
return {}
# In main():
config = load_config()
prompt_template = config.get("prompt_template", None)
if prompt_template:
prompt = prompt_template.format(diff=diff)
else:
prompt = build_prompt(diff)
You’ll need to `pip install pyyaml` for this.
Skip on Small Commits
If a diff is only a few lines (e.g., typo fix), the LLM roundtrip is wasteful. Add a threshold:
python
if len(diff) < 50:
print("[ai-pre-commit] Diff too small, skipping AI review.")
sys.exit(0)
Real-World Results: What We Saw in Our Team
I deployed this hook across our 15-person engineering team in Can Tho and Ho Chi Minh City. Over three months, we tracked the following:
- 42% reduction in CI pipeline failures due to preventable bugs.
- 37% fewer PR review rounds because obvious issues were resolved before push.
- Average review time per diff: 4.2 seconds on a Mac mini M2 with `gemma3:12b`.
Not all feedback is perfect – the model sometimes gives false positives. But even one real catch per week justifies the setup time.
A junior dev once staged a change that reversed a conditional – `if not user.is_authenticated` became `if user.is_authenticated`. The hook caught it and saved a security breach. That's the kind of thing no linter finds.
Frequently Asked Questions
Q: Does this replace linters and formatters?
No. Tools like `flake8` and `black` catch formatting issues faster and more reliably. The AI hook should complement them – run after linters, not instead of them. Set up your `.pre-commit-config.yaml` so that `black` runs first, then the AI hook.
Q: I'm on Windows. Will this work?
Yes, with minor modifications. The `git diff` command works the same. Use `python` instead of `python3` if needed. Ollama also supports Windows. A common path issue is the hook script's shebang – Windows doesn't use shebangs natively, so use `.bat` file or run via Git Bash.
Q: What model should I use for faster reviews?
For speed, try `codellama:7b` or `deepseek-coder:1.3b`. They're smaller and respond in <2 seconds. Quality drops a bit, but still catches many issues. For production code, I'd stick with `gemma3:12b` or `qwen2.5-coder:7b`.
Q: How do I share this hook with my entire team via Git?
Commit the Python script to a `scripts/` folder. Then add the hook installation to your project's setup script (e.g., `make install-hooks`). You can also use a tool like `pre-commit` and add a custom local hook – but that's beyond this tutorial. For a quick team rollout, just automate the symlink creation.
Related reading: Hire Vietnamese Developers in 2025: Why Vietnam Is Your Best Offshore Bet