DEV Community

Dakota Huang
Dakota Huang

Posted on

Don't Merge AI Patches Blind: A Git Worktree Replay Gate

When a free coding model sends you a patch, the optimistic path is to apply it, watch one green test run, and hit merge. The problem is that a single successful run is not evidence that the patch is safe. It can change files outside the intended scope, pass because the test is already broken in a way that masks the change, fail only under a different Python version, or apply cleanly to one commit and not the one CI will actually build.

The fix is not to yell at the model. It is to create a small repeatable replay gate that treats every generated patch as an untrusted change.

I used a free model endpoint and a free server to keep the gate and the model calls in one disposable environment. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and the free server option are relevant here because the gate itself is CPU- and I/O-bound: it applies a diff and runs tests, so it does not need a GPU. The workflow remains model-agnostic and will work with any endpoint that can return a unified diff.

What the gate checks

Instead of merging a model's diff directly into your working tree, the gate copies your repository into a separate Git worktree and asks four questions:

  1. Does the patch apply cleanly to the exact base commit CI will build?
  2. Which files does the patch actually change, and are those files in the allowed scope?
  3. Does the test command pass with the patch applied?
  4. How long did the test take, and what did the tail of the output say?

If the patch fails any of these checks, the gate returns a machine-readable status instead of a merge commit.

The replay gate

Here is a minimal, runnable implementation. Replace the test command with whatever your repository actually uses.

#!/usr/bin/env python3
"""gate_patch.py - replay an AI-generated patch in a disposable Git worktree."""
import argparse
import json
import subprocess
import sys
import tempfile
import time
from pathlib import Path


def run(cmd, cwd=None, timeout=120, input_text=None):
    return subprocess.run(
        cmd,
        cwd=cwd,
        input=input_text,
        capture_output=True,
        text=True,
        timeout=timeout,
    )


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--repo", required=True)
    ap.add_argument("--patch", required=True)
    ap.add_argument("--base-ref", default="HEAD")
    ap.add_argument("--test-cmd", default="pytest -q")
    ap.add_argument("--timeout", type=int, default=180)
    ap.add_argument(
        "--allowed-paths",
        default="",
        help="Comma-separated path prefixes allowed to change. Empty means allow all.",
    )
    args = ap.parse_args()

    patch = Path(args.patch).read_text()
    worktree = Path(tempfile.mkdtemp(prefix="gate_"))
    result = {
        "patch_file": str(Path(args.patch).resolve()),
        "worktree": str(worktree),
    }

    create = run(
        ["git", "worktree", "add", "--detach", str(worktree), args.base_ref],
        cwd=args.repo,
        timeout=60,
    )
    if create.returncode != 0:
        result.update({"stage": "worktree_create", "status": "FAIL", "detail": create.stderr.strip()[:500]})
        print(json.dumps(result, indent=2))
        return 1

    check = run(["git", "apply", "--check"], cwd=str(worktree), input_text=patch, timeout=30)
    if check.returncode != 0:
        result.update({"stage": "apply", "status": "APPLY_FAIL", "detail": check.stderr.strip()[:500]})
        print(json.dumps(result, indent=2))
        return 2

    apply_result = run(["git", "apply"], cwd=str(worktree), input_text=patch, timeout=30)
    if apply_result.returncode != 0:
        result.update({"stage": "apply", "status": "APPLY_FAIL", "detail": apply_result.stderr.strip()[:500]})
        print(json.dumps(result, indent=2))
        return 2

    names = run(
        ["git", "diff", "--name-only", "--diff-filter=ACMR"],
        cwd=str(worktree),
        timeout=30,
    )
    changed_files = [line for line in names.stdout.splitlines() if line.strip()]
    result["changed_files"] = changed_files

    allowed_raw = [p.strip() for p in args.allowed_paths.split(",") if p.strip()]
    unexpected = []
    if allowed_raw:
        for f in changed_files:
            if not any(f == a or f.startswith(a.rstrip("/") + "/") for a in allowed_raw):
                unexpected.append(f)
    if unexpected:
        result.update({"stage": "changed_files", "status": "UNEXPECTED_FILES", "unexpected_files": unexpected})
        print(json.dumps(result, indent=2))
        return 5

    start = time.time()
    try:
        test = run(
            ["bash", "-lc", args.test_cmd],
            cwd=str(worktree),
            timeout=args.timeout,
        )
        result["test_exit_code"] = test.returncode
        result["test_duration_s"] = round(time.time() - start, 2)
        result["test_stdout_tail"] = test.stdout.strip()[-800:]
        result["test_stderr_tail"] = test.stderr.strip()[-800:]
    except subprocess.TimeoutExpired:
        result.update({"stage": "test", "status": "TEST_TIMEOUT", "detail": f"timed out after {args.timeout}s"})
        print(json.dumps(result, indent=2))
        return 3

    if test.returncode == 0:
        result["status"] = "CLEAN"
    else:
        result["status"] = "TEST_FAIL"
    print(json.dumps(result, indent=2))
    return 0 if test.returncode == 0 else 4


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

Run it from outside the repository so the original checkout remains untouched:

python gate_patch.py \
  --repo /path/to/repo \
  --patch model_change.diff \
  --base-ref main \
  --test-cmd "pytest -q tests/unit" \
  --allowed-paths "src/,tests/"
Enter fullscreen mode Exit fullscreen mode

The script leaves the worktree in place after a failure so you can inspect the exact state. Clean it up with:

git worktree remove /tmp/gate_XXXXXXXX --force
Enter fullscreen mode Exit fullscreen mode

Reading the output

The output is JSON, so you can paste it into a comment thread or send it back to the model as context. The important field is status.

Status Meaning Next action
CLEAN The patch applied and the test command passed Still review the diff before merge
APPLY_FAIL The patch does not apply to the base commit Ask the model to regenerate against current HEAD
UNEXPECTED_FILES The patch touches files outside the allowed scope Reject or narrow the allowed paths and inspect
TEST_FAIL The test command failed with the patch applied Send the tail output back with the failing assertion
TEST_TIMEOUT The test command did not finish in time Check for an infinite loop or too-small timeout

This gate catches the most common failure mode I care about with free coding models: a patch that looks plausible but changes a path that has nothing to do with the request. With --allowed-paths, the gate fails before the test step runs, which saves a full test cycle.

Why a free server is enough

The gate does not train a model, run a large inference job, or hold a model in memory. It calls a model endpoint, receives a diff as text, applies that diff, and runs a local command. Those operations are light. That is why a free server option can host this workflow without a GPU, and why the model's availability, not the server's, is usually the limiting factor.

Treat the endpoint itself as a flaky dependency. Record the base commit, the patch file, and the gate result together. If the endpoint times out or returns a malformed diff, the record shows whether the problem was the model response or the wrapper around it.

Limitations

This is a replay gate, not a semantic reviewer. A patch can pass the test suite and still be wrong: it may delete a test, introduce a security problem, or produce the right output for the wrong reason. It also only checks the test command you provide. If your test suite is sparse, the gate is sparse.

A Git worktree is not a sandbox. The applied code and test command run on the host. If you are replaying patches from an untrusted source, run the gate inside a container or virtual machine that has no access to credentials. Also, free model endpoints can change without notice, so do not make the gate the only thing standing between a broken patch and production.

Who should not use this

If your project already has CI that runs every proposed patch in isolation with required reviewers, this gate may be redundant. If you need a full security review or binary analysis, a local worktree is not enough. If your goal is to compare models against each other, this script tests patches, not model quality; use a separate benchmark harness for that.

But if you are generating a lot of small changes from free coding models and need a cheap first filter before human review, this workflow is useful. Try it on one real patch before your next merge. If it stops one bad diff from reaching your main branch, it has already paid for itself.

Top comments (0)