Why my review gate refuses to run on a dirty working tree

Under my last post on dev.to, a reader asked whether merge-check also refuses when the working tree has uncommitted changes. It does, and so do the review and test scripts. This post covers what counts as dirty, where the check runs and why it is there.
The setup in one paragraph
two-engines-gate is the free, MIT licensed version of how I make Claude Code and Codex check each other. It has three scripts. gate.sh runs your tests and writes a pass record named after the commit. review.sh sends the commit to the other engine for a read-only review and saves the verdict under the commit hash. merge-check.sh refuses the merge unless the commit you are on has both. I wrote about the review loop and the commit hash records before. This post is about the one check all three scripts share.
The check itself
Each script defines the same small function:
clean() { # fails on changed or untracked files outside .ai/, and when git status itself fails
local out; out="$(git status --porcelain --untracked-files=all -- . ':(exclude).ai')" && [ -z "$out" ]
}
git status --porcelain prints one line per changed file in a format meant for scripts. --untracked-files=all lists every untracked file, including files inside new folders. The pathspec ':(exclude).ai' leaves out the .ai/ folder, which is where the gate writes its verdicts, pass records and logs. If the command prints anything, the tree is dirty. If git status itself fails, the function fails too, so a broken repository never looks clean by accident. That is why the refusal message says "or git status failed".
I ran each case against the public repo on 23 October to see exactly what the scripts do:
| State of the checkout | Result |
|---|---|
| A tracked file edited and not committed | refused by all three scripts |
A change staged with git add but not committed |
refused |
| A new file that git does not track | refused |
An edited .gitignore that is not committed |
refused |
A new file under .ai/ |
allowed |
A new file inside a folder that .gitignore covers |
allowed |
The refusal looks like this:
review: REFUSED: uncommitted changes or untracked files (or git status failed); commit or remove them first
Watch the .gitignore row. The README asks you to add .ai/ to the project's .gitignore, and that edit is itself an uncommitted change until you commit it, so the first review gets refused.
Why the review needs a clean tree
review.sh builds the reviewer's prompt from the commit log and git diff base...HEAD. Both of those only contain committed work. The verdict is then saved under the hash of HEAD.
The review prompt also tells the reviewer it may read any file in the checkout for context, and Codex in its read-only sandbox can do that. So if an edit sits in the working tree without a commit, the reviewer can open a file whose contents differ from the commit it is judging. Its ACCEPT would then be saved under a hash that does not contain the code it looked at. When the script refuses before the review starts, the files on disk match the commit under review.
Why the test run needs a clean tree
gate.sh runs your test command on the files on disk, and the files on disk are what the test command sees. An untracked helper file or a local fix that never got committed can make the tests pass. The pass record would then carry the hash of a commit that might fail on a fresh checkout. The guide in my kit puts it in one line: the gate refuses a dirty tree because "a check could pass on a file that is not in the commit".
The check runs before and after
Both review.sh and gate.sh run the check twice.
Before anything starts, each script deletes the earlier evidence for this commit. review.sh removes the old verdict file and gate.sh removes the old pass record. Only then does the clean check run. So a refused run cannot leave an old ACCEPT or an old pass sitting there for this commit.
After the reviewer or the tests finish, the script checks two things again: that HEAD has not moved, and that the tree is still clean. If the reviewer changed a file, review.sh prints REVIEW VOID: files changed during the review and saves no verdict. If the test command left a file behind, gate.sh fails the run. I tested that with a test command that creates a stray file:
gate: running 'bash test.sh; touch stray.txt' on 986def16acb8
gate: the run left changed or untracked files (or git status failed)
gate: FAIL (exit 1) on 986def16acb8; no pass record; full log .ai/tests/986def16acb8.log
A test suite that writes snapshots or build output into the repo will hit this. The way out is to make the suite write somewhere git ignores, and to commit that .gitignore change.
merge-check runs it first
merge-check.sh runs the same git status check before it looks at any pass record or verdict. The merge only takes the commit, so uncommitted edits would not be merged anyway. They would still mean the checkout holds work nobody reviewed or tested. With a clean checkout, a yes from merge-check covers exactly what is on disk.
What the check does not cover
Know these limits before you rely on the check.
- Ignored files are invisible to it.
git statusdoes not list files that.gitignorecovers, so a test that reads an ignored config file or a build cache can still behave differently on a fresh checkout. The gate does not catch that. .ai/is excluded on purpose, which means anything you put there is outside the check. The README calls the gate "a local guard against merging the wrong commit, not a security boundary", because anything with write access to the repository can write files under.ai/.- It only checks the local checkout. It does not push, merge or look at what is on the remote.
The same rule in the kit
The paid Two Engines Kit (currently v1.0.2) has the same rule in its own scripts. Its cross-review.sh and gate.sh both refuse a worktree with uncommitted changes or untracked files, and the guide says: "A dirty tree or an untracked file gets no record, so you cannot gate code that is not in the commit and then merge the commit." The Two Engines Kit is built on information gathered since the first Codex and Claude CLIs.
Try it
Clone two-engines-gate and run bash demo.sh to watch one full loop with a stand-in reviewer, so no model or account is used. Then edit a file in a project that uses the gate, leave it uncommitted, and run review.sh to see the refusal. The test suite in tests/run.sh has a dirty tree case and a "files changed during review" case if you want to read the check from the test side.