Stop hook treats a stale checkout as unsaved work, and advises committing a revert
Version: Claude Code 2.1.278, CLAUDE_CODE_REMOTE=true (cloud session, Linux 6.18.44)
File: ~/.claude/stop-hook-git-check.sh
The code
# Check for uncommitted changes (both staged and unstaged)
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "There are uncommitted changes in the repository. Please commit and push these changes to the remote branch." >&2
exit 2
fi
A working tree can differ from HEAD without carrying any unsaved work — when the checkout is parked behind or sideways of its own HEAD. Every difference git reports is then content that is already committed, and "commit and push" publishes a revert.
The hook fires at the end of every turn and its output reads as an instruction from the environment. An agent has no standing reason to distrust it.
How it happens without anyone doing anything unusual
git worktree add followed by git checkout -B <branch> relocates the branch ref that the main clone is sitting on. The clone keeps its old tree; its HEAD moves. That is a normal way to do isolated work in a repo you are also sitting in.
What it actually cost
This fired three times in one session. Each time the hook was right that the tree was dirty and wrong about what to do:
| Repo | State | What "commit and push" would have done |
|---|---|---|
| A | tree stale behind its own HEAD after a worktree branch move | reverted a large document rewrite that had already been pushed |
| B | clone left at its session-start commit while the branch ref advanced | 36 files changed, 177 insertions, 1700 deletions — reverted 36 commits of main and deleted a package directory |
| C | git checkout <ref> -- . used to read files | resurrected a CI workflow deleted two hours earlier specifically to cut Actions minutes |
Each was avoided only by ignoring the hook, hashing the tree, and finding it already in history. An agent that follows the instruction as written pushes the revert.
Case C is worth separating out: git checkout <ref> -- . updates and adds files but never deletes them, so anything the ref removed lingers and stages as an addition. That is an easy way for an agent to "read the current version of some files" and silently arm a revert.
Suggested fix
Hash what is on disk and check whether that tree already exists in history. If it does, nothing is at risk and the correct advice is to realign, not to commit.
# Build the on-disk tree in a TEMPORARY index so the real one is untouched.
# Covers staged, unstaged and untracked together. `git add -A` honours
# .gitignore. If the index is unmerged, write-tree fails and we fall through
# to the ordinary advice — a conflicted tree IS unsaved work.
disk_tree=""
_tmp_index="$(mktemp -u "${TMPDIR:-/tmp}/stop-hook-index.XXXXXX")"
if GIT_INDEX_FILE="$_tmp_index" git read-tree HEAD 2>/dev/null &&
GIT_INDEX_FILE="$_tmp_index" git add -A 2>/dev/null; then
disk_tree="$(GIT_INDEX_FILE="$_tmp_index" git write-tree 2>/dev/null)"
fi
rm -f "$_tmp_index"
# A tree equal to HEAD's is simply CLEAN and matches history trivially.
# Without this guard the check fires on every clean repo.
head_tree="$(git rev-parse -q --verify 'HEAD^{tree}' 2>/dev/null)"
[[ -n "$disk_tree" && "$disk_tree" == "$head_tree" ]] && disk_tree=""
if [[ -n "$disk_tree" ]]; then
known_commit="$(git log --all --max-count=2000 --format='%H %T' 2>/dev/null |
awk -v t="$disk_tree" '$2 == t { print $1; exit }')"
if [[ -n "$known_commit" ]]; then
head_short="$(git rev-parse --short HEAD 2>/dev/null)"
echo "This working tree is NOT carrying unsaved work: it is byte-identical to commit ${known_commit:0:7}, which is already in history. HEAD is $head_short, so the checkout is parked behind or sideways of its own HEAD." >&2
echo "Do NOT commit — that would publish a revert. Realign instead: 'git reset --hard HEAD', or 'git checkout <branch>' / 'git reset --hard <ref>'. Nothing is lost; the content on disk is already committed as ${known_commit:0:7}." >&2
exit 2
fi
fi
Place it before the existing staged/unstaged and untracked checks, since it subsumes both.
Test results
Patched locally and exercised against six cases, checking real exit codes:
| Case | Expected | Result |
|---|---|---|
| clean at HEAD | exit 0, silent | ✅ |
| genuine unstaged edit | exit 2, "uncommitted changes" | ✅ |
| genuine untracked file | exit 2, "untracked files" | ✅ |
| stale tree == an earlier commit | exit 2, realign advice | ✅ |
| conflicted merge | exit 2, falls through | ✅ |
| no remote | exit 0 | ✅ |
Note the clean-repo case. My first version omitted the head_tree guard and fired on every clean repository, because a clean tree trivially matches history — it is HEAD. Worth a regression test on that side specifically, since a check that only passes on the failure it was written for looks correct.
Known limits
The match is exact — whole tree, not per-file — which is why it has no false positives and why coverage is partial:
- the stale-checkout shapes (A and B): caught
git checkout <ref> -- .(case C): caught only when the result happens to equal a historical tree exactly. In the real case — a tree atorigin/mainplus one fileorigin/mainhad deleted — the result is a tree that has never existed, so this does not fire and the old advice is still wrong there.
I considered widening it to "is every changed path a revert of some ancestor?" and rejected it: this gates every session, and mislabelling genuine work as stale is worse than missing a case. Exact and partial beats broad and wrong.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗