DEV Community

Emery Li
Emery Li

Posted on

Keep the Dirty Tree Local: A Spill Gate for Uncommitted Agent Work

The laptop fans rose while the payment service tests were still red on Friday afternoon. An unfinished refactor sat across six files, plus a .env.local that still held a sandbox key. Debug logging had printed a handful of customer identifiers into a scratch JSON file meant for deletion. A coding agent then offered to finish the remaining methods remotely because local inference felt painfully slow.

That offer looked reasonable if latency were the only signal that mattered for agent placement. Uncommitted work, however, mixes secrets, unpublished intent, and half-reverted experiments in one working tree. A free remote server can help with clean, committed slices when the laptop cannot keep up. It should not inherit a dirty tree that the developer still planned to rewrite or discard.

Unpublished Intent Is a Placement Bug

Local-first agent setups already weigh latency, secret residency, and offline recovery when work leaves the laptop. Dirty trees add a fourth failure mode that those debates often skip: unpublished developer intent. The working copy contains lines the author has not accepted, including debug prints and aborted spikes. Remote models treat every received byte as authorized context, which is the wrong default for scratch.

Scratch files, local overrides, and abandoned experiments often sit beside the real source in one directory. Offline recovery also suffers because a failed spill can split uncommitted state across two machines. The practical response is a spill gate that inspects git status before any prompt context leaves. The gate fails closed, so unknown paths stay local until a human promotes them into a committed slice.

Decision Table

Dirty path class Examples Local-first action Free server allowed
secret .env.local, id_rsa, *.pem keep bytes on disk; never attach no
unpublished scratch JSON, WIP notes, commented spikes keep on the laptop; optional local model no
generated dist/, coverage HTML, compiled wasm rebuild after the patch; do not upload no
source, uncommitted src/**/*.py with local edits commit or cut a redacted patch first only after commit or explicit promote
source, clean HEAD committed module with tests laptop first; spill if thermal or offline yes, scaffold only

The table above is a policy artifact rather than a benchmark of model quality or server speed. Teams should extend the secret and unpublished patterns until they match the real repository layout.

Workflow: Fail Closed Before Any Spill

The following five steps wrap an agent turn as a proposed control rather than a measured production system. Each step uses ordinary git commands so a developer can reproduce the gate on a throwaway checkout. The control refuses remote context until classification finishes and the working tree matches a clean HEAD.

1. Snapshot the tree

Record branch, HEAD, and every path git considers dirty before the agent receives a prompt.

mkdir -p .agent
{
  echo "branch=$(git rev-parse --abbrev-ref HEAD)"
  echo "head=$(git rev-parse HEAD)"
  echo "user.email=$(git config user.email)"
  git status --porcelain=v1 -uall
} > .agent/tree.snap
git diff --binary > .agent/unstaged.patch
git diff --binary --cached > .agent/staged.patch
Enter fullscreen mode Exit fullscreen mode

Untracked files belong in the snapshot because agents create env overrides and debug dumps without staging them. Nested submodules should be listed separately so a dirty child repo cannot hide under a clean parent. The snapshot file later proves whether HEAD moved while a remote worker was still rewriting a file.

2. Classify each dirty path

Run a small classifier that labels paths before any model call is allowed to see file bytes. The implementation below is proposed example code, and operators should review it before installing a hook.

#!/usr/bin/env python3
"""spill_gate.py — proposed local classifier for agent spill decisions."""
from __future__ import annotations

import re
import subprocess
import sys
from pathlib import Path

SECRET_PATTERNS = [
    re.compile(r"(^|/)\.env"),
    re.compile(r"(^|/)\.env\.[^/]+$"),
    re.compile(r"id_rsa$"),
    re.compile(r"\.pem$"),
    re.compile(r"\.p12$"),
    re.compile(r"credentials\.json$"),
    re.compile(r"secret", re.I),
]
GENERATED_PATTERNS = [
    re.compile(r"(^|/)dist/"),
    re.compile(r"(^|/)build/"),
    re.compile(r"coverage"),
    re.compile(r"\.wasm$"),
]
UNPUBLISHED_PATTERNS = [
    re.compile(r"scratch", re.I),
    re.compile(r"wip", re.I),
    re.compile(r"dump", re.I),
    re.compile(r"\.local$"),
]
SECRET_BYTES = [
    re.compile(rb"API_KEY\s*="),
    re.compile(rb"BEGIN (RSA |OPENSSH )?PRIVATE KEY"),
    re.compile(rb"AKIA[0-9A-Z]{16}"),
]


def porcelain() -> list[tuple[str, str]]:
    out = subprocess.check_output(
        ["git", "status", "--porcelain=v1", "-uall"], text=True
    )
    rows = []
    for line in out.splitlines():
        if not line.strip():
            continue
        status, path = line[:2], line[3:]
        if " -> " in path:
            path = path.split(" -> ", 1)[1]
        rows.append((status, path))
    return rows


def label(path: str) -> str:
    if any(p.search(path) for p in SECRET_PATTERNS):
        return "secret"
    if any(p.search(path) for p in GENERATED_PATTERNS):
        return "generated"
    if any(p.search(path) for p in UNPUBLISHED_PATTERNS):
        return "unpublished"
    try:
        data = Path(path).read_bytes()[:65536]
    except OSError:
        return "source"
    if any(p.search(data) for p in SECRET_BYTES):
        return "secret"
    return "source"


def main() -> int:
    if subprocess.call(
        ["git", "rev-parse", "--is-inside-work-tree"],
        stdout=subprocess.DEVNULL,
    ):
        print("spill_gate: not a git work tree; refuse spill", file=sys.stderr)
        return 2
    blocked = []
    allowed_local = []
    for status, path in porcelain():
        kind = label(path)
        print(f"{status} {kind:12} {path}")
        if kind in {"secret", "unpublished", "generated"}:
            blocked.append(path)
        else:
            allowed_local.append(path)
    if blocked:
        print("FAIL_CLOSED: dirty non-source paths stay on the laptop", file=sys.stderr)
        print("blocked=" + ",".join(blocked), file=sys.stderr)
        return 1
    if allowed_local:
        print("LOCAL_ONLY_UNTIL_COMMIT: source edits exist; do not spill raw tree")
        return 1
    print("CLEAN_TREE: free-server spill may be considered")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

The script exits with status one whenever the tree is dirty, including ordinary uncommitted source edits. That refusal is intentional, because uncommitted source still encodes unpublished intent the author may revert. A later promote step can cut a committed patch and then reopen the spill path for batch work.

3. Keep interactive repair on the laptop

Latency for a single file edit is usually dominated by editor round trips rather than raw model throughput. Secret-shaped strings and unpublished dumps should be rewritten locally, where disk encryption and history still apply. A local model can propose a patch, but the patch lands through git apply --check on the laptop. The free server is not invited to this interactive turn, even when remote tokens would appear cheaper.

python3 spill_gate.py
# non-zero status: stay local
git apply --check .agent/proposed.local.patch
git apply .agent/proposed.local.patch
pytest -q tests/test_spill_gate.py
Enter fullscreen mode Exit fullscreen mode

4. Promote a clean slice only after commit

When the laptop is thermally throttled or the job is a long batch rewrite, a free server can win. The author first commits the accepted source, drops secrets from the archive, and exports a narrow scaffold. The scaffold contains committed tests and modules only, with no working-tree residue and no local overrides.

git add src/payments/ledger.py tests/test_ledger.py
git commit -m "Accept local ledger refactor before any remote spill"
python3 spill_gate.py  # must print CLEAN_TREE
git archive --format=tar HEAD src/payments tests/test_ledger.py > /tmp/scaffold.tar
# attach /tmp/scaffold.tar only; never tar the dirty worktree
Enter fullscreen mode Exit fullscreen mode

A thin wrapper makes the same rule harder to skip during an impatient Friday turn.

#!/usr/bin/env bash
# agent-wrap.sh — refuse remote spill unless spill_gate exits 0
set -euo pipefail
python3 spill_gate.py
mode=${1:-local}
if [[ "$mode" == "remote" ]]; then
  echo "remote spill permitted for clean HEAD $(git rev-parse --short HEAD)"
else
  echo "running local turn"
fi
Enter fullscreen mode Exit fullscreen mode

5. Re-import remote output as a patch, not a tree

Remote output should return as a unified diff against the same HEAD the snapshot recorded earlier. The laptop applies the diff, runs the test suite, and refuses the change if HEAD moved underneath. This preserves offline recovery, because a dead network still leaves the committed local tree as truth. Divergent uncommitted copies on two machines are the failure mode this step exists to prevent.

test "$(git rev-parse HEAD)" = "$(awk -F= '/^head=/{print $2}' .agent/tree.snap)"
git apply --check /tmp/remote.proposed.patch
git apply /tmp/remote.proposed.patch
pytest -q
Enter fullscreen mode Exit fullscreen mode

A Tiny Test Plan

The classifier should be tested against a throwaway repository so policy does not depend on production history. The test below is executable documentation and does not claim live traffic numbers or latency percentiles.

# tests/test_spill_gate.py
import subprocess
from pathlib import Path

import spill_gate


def init_repo(tmp_path: Path) -> None:
    subprocess.check_call(["git", "init", "-q"], cwd=tmp_path)
    subprocess.check_call(["git", "config", "user.email", "dev@example.com"], cwd=tmp_path)
    subprocess.check_call(["git", "config", "user.name", "Dev"], cwd=tmp_path)
    (tmp_path / "src").mkdir()
    (tmp_path / "src" / "app.py").write_text("print('ok')\n")
    subprocess.check_call(["git", "add", "src/app.py"], cwd=tmp_path)
    subprocess.check_call(["git", "commit", "-qm", "init"], cwd=tmp_path)


def test_env_local_blocks(tmp_path, monkeypatch):
    init_repo(tmp_path)
    (tmp_path / ".env.local").write_text("SANDBOX_KEY=example\n")
    monkeypatch.chdir(tmp_path)
    assert spill_gate.main() == 1


def test_scratch_dump_blocks(tmp_path, monkeypatch):
    init_repo(tmp_path)
    (tmp_path / "customers.dump.json").write_text("[]\n")
    monkeypatch.chdir(tmp_path)
    assert spill_gate.main() == 1


def test_uncommitted_source_stays_local(tmp_path, monkeypatch):
    init_repo(tmp_path)
    (tmp_path / "src" / "app.py").write_text("print('wip')\n")
    monkeypatch.chdir(tmp_path)
    assert spill_gate.main() == 1


def test_clean_tree_allows_consideration(tmp_path, monkeypatch):
    init_repo(tmp_path)
    monkeypatch.chdir(tmp_path)
    assert spill_gate.main() == 0
Enter fullscreen mode Exit fullscreen mode

Run those tests with pytest from a checkout that also contains the classifier module beside the test file. A failing test here is a placement bug, not a complaint about model quality or server quota.

Latency, Secrets, Offline, and the Rare Remote Win

Interactive repair still belongs on the laptop because round-trip latency dominates small edits more than throughput. Secret-shaped files never leave disk, which also covers the offline case when a cafe network drops mid-turn. The free server wins only when those three constraints are already satisfied and remaining work is a committed batch. Thermal throttle plus a clean HEAD is the usual pair that justifies moving generation off the laptop.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. Its free model access and free server option can run that clean committed slice while secrets stay on the laptop. Removing that product sentence leaves the git classifier, the decision table, and the test plan intact.

Limitations and Who Should Skip This

The gate assumes a single developer, a git work tree, and a fail-closed policy that can delay remote help. Pairing sessions that must share uncommitted buffers over a remote desktop should not use this control. Generated-code monorepos with intentional dirty artifacts will need a custom allowlist, or every turn blocks. Byte scanners miss encrypted secret stores and binary keychains, so this script does not replace dedicated scanning.

The gate also cannot prove that a committed file is free of customer data or screenshots. It only proves the working tree matches the policy classes listed in the decision table. Teams under regulated data rules should keep customer dumps out of the repository entirely, local or remote. The approach fails when the agent edits ignored files that git status never reports to the classifier.

Those ignored paths need an explicit inventory besides git, or the placement decision is incomplete. Until that inventory exists, the honest placement decision is to keep the agent turn on the laptop. Operators should not treat a green spill_gate as approval to upload build directories or docker volume mounts.

Closing

Uncommitted buffers are not a latency optimization problem; they are a residency problem with a git-shaped interface. Measure dirtiness first, keep secrets and scratch on the laptop, and promote a committed scaffold only afterward. The machine should lose the job only when thermal, battery, or batch length make local completion unrealistic. Teams that already wrap agent turns can drop the classifier in front of the next remote worker.

Top comments (0)