DEV Community

Finley Sun
Finley Sun

Posted on

A Pre-Push Gate for Python Diffs That Fails Loud, Not Often

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()
Enter fullscreen mode Exit fullscreen mode

Reproducible test plan

  1. Create a branch with a deliberate bug: change timeout = 30 to timeout = '30' in a function that passes it to a socket call.
  2. Run git push to a scratch remote. Expect ruff or tests to catch type-adjacent mistakes first when enabled.
  3. Comment out deterministic failures and retry. The model lane should return JSON; if it returns prose, the hook records parse_error instead of guessing.
  4. Add a fake high finding in last.json? Do not. Instead temporarily set LLM_GATE_STRICT=0 and confirm the push proceeds while the artifact remains.
  5. 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)