The annoying part of AI-assisted review is not getting an answer. It is getting ten vague comments after CI already passed, or a confident red flag with no file, no line, and no way to reproduce it. For small Python services I wanted the opposite: a local gate that runs before git push, keeps deterministic checks first, asks an LLM only about the current diff, and fails only when the finding is specific enough to act on.
The artifact below is a single Python pre-push hook. It does three things in order: run fast local checks, send only the push diff to an OpenAI-compatible endpoint, and write a provenance record under .git/llm-gate/ so a teammate can see what was sent and why the hook blocked. The provider is hidden behind environment variables, so the same hook works with a local server or a hosted endpoint. For a no-cost try while keeping the provider swappable, I used MonkeyCode's free model access and free server option as the endpoint behind those variables; treat availability as something to confirm in your own account before relying on it. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
What the hook optimizes for
- Deterministic checks win. Formatting, lint, and a tiny test slice are cheaper and more stable than any model.
- The model sees the diff, not the repo. That keeps cost, prompt size, and leakage surface down.
- Blocking requires structure. A comment must include severity, path, line, rationale, and a suggested patch or check.
- Every decision leaves a local artifact. If it blocks at 2 a.m., you can inspect exactly what happened.
Install
Save this as .git/hooks/pre-push and chmod +x it. Do not put keys in the script. Use LLM_BASE_URL, LLM_API_KEY if your endpoint needs one, LLM_MODEL, and optional LLM_GATE_STRICT=1.
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
import urllib.request
from pathlib import Path
ROOT = Path(subprocess.run(['git', 'rev-parse', '--show-toplevel'], capture_output=True, text=True, check=True).stdout.strip())
LOGDIR = ROOT / '.git' / 'llm-gate'
LOGDIR.mkdir(parents=True, exist_ok=True)
def sh(cmd, check=False):
return subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, check=check)
def run_deterministic():
findings = []
if (ROOT / 'pyproject.toml').exists() and sh(['python', '-m', 'ruff', '--version']).returncode == 0:
r = sh(['python', '-m', 'ruff', 'check', '.'])
if r.returncode != 0:
findings.append({'tool': 'ruff', 'detail': r.stdout + r.stderr})
if os.environ.get('GATE_FAST_TESTS') == '1':
r = sh(['python', '-m', 'pytest', '-q', '-x'])
if r.returncode != 0:
findings.append({'tool': 'pytest', 'detail': r.stdout[-4000:] + r.stderr[-1000:]})
return findings
def push_diff(stdin_text):
# pre-push receives: local_ref local_sha remote_ref remote_sha per line
diffs = []
for line in stdin_text.splitlines():
parts = line.split()
if len(parts) != 4:
continue
local_ref, local_sha, remote_ref, remote_sha = parts
if local_sha.startswith('0000000'):
continue
if remote_sha.startswith('0000000'):
base = sh(['git', 'merge-base', local_sha, remote_ref]).stdout.strip() or local_sha + '^'
else:
base = remote_sha
d = sh(['git', 'diff', '--unified=20', '--no-ext-diff', base, local_sha, '--', '*.py'])
if d.stdout:
diffs.append(d.stdout)
return '\n'.join(diffs)[:60000]
def ask_model(diff):
base = os.environ.get('LLM_BASE_URL', '').rstrip('/')
model = os.environ.get('LLM_MODEL', '')
if not base or not model or not diff.strip():
return {'skipped': True, 'reason': 'missing endpoint, model, or diff'}
prompt = '''Review this Python git diff. Return only JSON with key findings.
Each finding must be: severity high|medium|low, path, line, rationale, suggested_check_or_patch.
Block-worthy means high severity only: correctness bug, data loss, authz/authn bypass, secret leak, unsafe deserialization, or migration risk.
If none, return findings as an empty list. Diff:\n''' + diff
payload = {'model': model, 'messages': [{'role': 'user', 'content': prompt}], 'temperature': 0}
req = urllib.request.Request(base + '/v1/chat/completions', data=json.dumps(payload).encode(), headers={'Content-Type': 'application/json'})
key = os.environ.get('LLM_API_KEY')
if key:
req.add_header('Authorization', 'Bearer ' + key)
with urllib.request.urlopen(req, timeout=45) as resp:
data = json.loads(resp.read().decode())
text = data['choices'][0]['message']['content']
try:
return json.loads(text)
except Exception:
return {'parse_error': True, 'raw': text[:4000]}
def main():
stdin_text = sys.stdin.read()
det = run_deterministic()
diff = push_diff(stdin_text)
model_out = ask_model(diff)
record = {'deterministic': det, 'model': model_out, 'diff_chars': len(diff)}
(LOGDIR / 'last.json').write_text(json.dumps(record, indent=2))
if det:
print('pre-push blocked by deterministic checks; see .git/llm-gate/last.json', file=sys.stderr)
sys.exit(1)
highs = [f for f in model_out.get('findings', []) if f.get('severity') == 'high']
if highs and os.environ.get('LLM_GATE_STRICT', '1') == '1':
for f in highs:
print('HIGH:', f.get('path'), f.get('line'), f.get('rationale'), file=sys.stderr)
print('Fix, override with LLM_GATE_STRICT=0 for this push only, or inspect .git/llm-gate/last.json', file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()
Reproducible test plan
- Create a branch with a deliberate bug: change
timeout = 30totimeout = '30'in a function that passes it to a socket call. - Run
git pushto a scratch remote. Expectruffor tests to catch type-adjacent mistakes first when enabled. - Comment out deterministic failures and retry. The model lane should return JSON; if it returns prose, the hook records
parse_errorinstead of guessing. - Add a fake high finding in
last.json? Do not. Instead temporarily setLLM_GATE_STRICT=0and confirm the push proceeds while the artifact remains. - Restore strict mode and verify a clean diff produces
findings: []and no network call when there is no Python diff.
Decision table
| Situation | Use this hook? | Why |
|---|---|---|
| Solo Python repo, pushes daily | Yes | Fast feedback before CI and review |
| Monorepo with huge generated diffs | Only with path filters | Diff truncation can hide context |
| Regulated code with export limits | Maybe not hosted lane | Keep deterministic lane; disable network lane |
| Team with no prompt/log policy | No | Agree on data retention and override rules first |
| You need final security signoff | No | This is a gate, not an audit |
Limitations
An LLM can miss a real bug or invent a plausible one, so the hook treats it as an advisory layer unless a finding is structured and high severity. Diff-only review lacks architecture context; a safe-looking change can still break an invariant elsewhere. Free tiers and free servers can change, rate limits can appear, and endpoints can be unavailable, so the script must degrade to deterministic checks rather than become a new flaky dependency. Do not send proprietary code to any endpoint until retention, region, and access controls are acceptable.
If you try it, start non-strict for a week, tune the rubric from the false positives you actually see, and only then let high-severity structured findings block pushes.
Top comments (0)