Enforce a Git Status Gate After Agent Work
After an agent leaves a patch, eyeballing the working tree lets noise leak into the next turn, PR, or push. An agent git status check is not “looks clean”—it is a gate that mechanically reads git status --porcelain and passes or stops.
This post covers only where to check? · stop on dirty? · intentional untracked?. agent-test-gate owns test/lint exit codes, agent-patch-commits owns commit slicing, agent-rebase-conflict owns conflict delegation. Here we only cover the working-tree status gate. No pricing, plans, affiliates, or C++ samples.
Grounding: git-status --porcelain / -s, plus the practice of wiring the same script into agent hooks, Done commands, and pre- hooks*.
Where do you put the check?
One-line answer: Put the check first at agent turn end (Done/stop hook), and second before a human commit/push (pre-commit / pre-push). “Paste status in chat” alone is not a gate.
Placement candidates:
| Location | What to run | Best when |
|---|---|---|
| Agent Done / stop hook | scripts/git-status-gate.sh (porcelain parse) | Required before ending a turn |
| Editor / agent command hook | Same script, path-scoped | Right after a write batch |
| pre-commit | Block tracked dirty · unmerged | When a human stages |
| pre-push | Final check before upload | Stop “local-only green” pushes |
| CI (optional) | After checkout: build must not dirty the tree | Catch generator/format sprawl |
Practical order:
- Add one repo entrypoint, e.g.
scripts/git-status-gate.sh. - In Skill/Rule Done: no done/PR/next task until that script exits 0.
- Call the same script from local
pre-commitorpre-push. - Parse
--porcelain(optionally-z), not humangit status. Avoid locale/color drift.
Copy-paste Done gate:
Gate (must exit 0 before claiming done):
./scripts/git-status-gate.sh
Do not open PR / start next task / push while the gate is red.
Report: full porcelain output on failure.
Minimal script skeleton (branch per team policy):
#!/usr/bin/env bash
# scripts/git-status-gate.sh — fail on unexpected dirty tree
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
# Machine-readable; stable across locales (git-status --porcelain)
mapfile -t lines < <(git status --porcelain=v1)
# Unmerged / conflict leftovers always fail
if git status --porcelain=v1 | grep -qE '^(UU|AA|DD|.U|U.)'; then
echo "git-status-gate: unmerged paths present" >&2
printf '%s\n' "${lines[@]}" >&2
exit 1
fi
# Default: any tracked change (XY not ??) fails
dirty=0
while IFS= read -r line; do
[[ -z "$line" ]] && continue
# porcelain v1: first two cols are XY; ?? = untracked
xy="${line:0:2}"
if [[ "$xy" != "??" ]]; then
dirty=1
break
fi
done <<< "$(printf '%s\n' "${lines[@]}")"
if [[ "$dirty" -eq 1 ]]; then
echo "git-status-gate: dirty tracked tree" >&2
printf '%s\n' "${lines[@]}" >&2
exit 1
fi
# Untracked policy: see next sections / ALLOWLIST file
exit 0
Boundary: agent-test-gate is test command exit codes; this section is working-tree porcelain. Both can sit on Done, but keep separate scripts.
Stop on a dirty tree?
One-line answer: Stop on unexpected tracked changes or any unmerged paths. Do not “commit for now,” “stash later,” or edit unrelated files to move on. Retries use the same gate script only.
Signals porcelain usually blocks (git-status short/porcelain XY):
| XY pattern (gist) | Meaning | Default gate |
|---|---|---|
M / M / MM … | Tracked modify (index/worktree) | Stop |
A / D / R … | Add · delete · rename | Stop (unless task allowlist) |
U* / *U / AA / DD | Unmerged | Stop immediately |
?? | Untracked | Policy branch (next section) |
!! | Ignored (when shown) | Usually out of scope |
Stop rules for the agent:
On git-status-gate failure:
1. Stop. Do not start unrelated edits or open a PR.
2. Print full `git status --porcelain=v1` (and optional `-b`).
3. Either: restore unexpected paths OR commit only allowlisted intentional changes
with an explicit human/ticket note — then re-run the same gate.
4. Never `git add -A` to silence the gate.
5. Never disable the hook/script without ticket ID + owner approval.
Stop ≠ “tree must always be empty”: If the task intentionally leaves commit-pending changes, put those paths on an allowlist (“allowed dirty”) or require commit-then-clean. Pin the policy in one documented line.
Policy A (strict clean): porcelain must be empty before done.
Policy B (allowlisted dirty): only paths in GATE_ALLOWLIST may be non-??;
everything else fails. Unmerged always fails.
Intentional untracked?
One-line answer: Default ?? fails; open exceptions only via allowlist, .gitignore, or an explicit keep path. Do not silently ignore “agent temp files.”
Options:
| Approach | When | Caution |
|---|---|---|
Add to .gitignore | Recurring build/cache/tool output | Do not misclassify secrets/artifacts |
| Gate allowlist | One-off report/log paths for this task | List paths in the ticket/Done |
| Commit or delete | Keep or clean the artifact | No git add -A bypass |
Align -u mode | Match how untracked are shown | Same flags in gate and local status |
Allowlist example (gate filters ?? lines):
# GATE_UNTRACKED_ALLOWLIST — one pathspec/prefix per line
artifacts/agent-report.json
tmp/scratch/
# inside gate: fail on ?? not matching allowlist
while IFS= read -r line; do
[[ "${line:0:2}" == "??" ]] || continue
path="${line:3}"
ok=0
while IFS= read -r allow; do
[[ -z "$allow" || "$allow" =~ ^# ]] && continue
if [[ "$path" == "$allow"* || "$path" == "$allow" ]]; then
ok=1; break
fi
done < GATE_UNTRACKED_ALLOWLIST
if [[ "$ok" -eq 0 ]]; then
echo "unexpected untracked: $path" >&2
exit 1
fi
done <<< "$(git status --porcelain=v1)"
Do not: run only git status -uno in the gate to hide untracked forever. That is a human convenience setting and masks agent leftover files. To hide them, move the rule into ignore.
One-liner: Git status gate = porcelain script on Done/hooks · stop on tracked/unmerged dirty · ?? exceptions only via allowlist/gitignore. Different axis from the test gate and commit slicing.
FAQ
How is this different from agent-test-gate?
That post is test/lint/type command exit codes. This post blocks on working tree/index via git status --porcelain. You can put both on Done; keep scripts and failure messages separate.
Why porcelain instead of human git status?
Long format shifts with translation and layout. --porcelain (and -z) is the documented script-stable form, fit for hooks, CI, and agent commands.
Is stashing enough to pass?
Stashing only to mark done defers the mess. If you allow it, require a ticket ID and add a stash-list check at the next turn start. Default: stop and clean.
Submodules and ignored files?
--ignore-submodules defaults and ignored-file options change output. Pin the flags in the gate script and repeat them in the Skill.
Sources
- git-status —
--porcelain, short-format XY, untracked/ignored options - Team practice: same
scripts/git-status-gate.shfrom Done/hooks/pre-*— tables above - Adjacent axes: agent-test-gate (command exit), agent-patch-commits (commit units), agent-rebase-conflict (conflict handoff)