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:

LocationWhat to runBest when
Agent Done / stop hookscripts/git-status-gate.sh (porcelain parse)Required before ending a turn
Editor / agent command hookSame script, path-scopedRight after a write batch
pre-commitBlock tracked dirty · unmergedWhen a human stages
pre-pushFinal check before uploadStop “local-only green” pushes
CI (optional)After checkout: build must not dirty the treeCatch generator/format sprawl

Practical order:

  1. Add one repo entrypoint, e.g. scripts/git-status-gate.sh.
  2. In Skill/Rule Done: no done/PR/next task until that script exits 0.
  3. Call the same script from local pre-commit or pre-push.
  4. Parse --porcelain (optionally -z), not human git 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)MeaningDefault gate
M / M / MM …Tracked modify (index/worktree)Stop
A / D / R …Add · delete · renameStop (unless task allowlist)
U* / *U / AA / DDUnmergedStop immediately
??UntrackedPolicy 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:

ApproachWhenCaution
Add to .gitignoreRecurring build/cache/tool outputDo not misclassify secrets/artifacts
Gate allowlistOne-off report/log paths for this taskList paths in the ticket/Done
Commit or deleteKeep or clean the artifactNo git add -A bypass
Align -u modeMatch how untracked are shownSame 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.sh from Done/hooks/pre-* — tables above
  • Adjacent axes: agent-test-gate (command exit), agent-patch-commits (commit units), agent-rebase-conflict (conflict handoff)