Stop hook treats a stale checkout as unsaved work, and advises committing a revert

Status Open
Reported on v2.1.278
Maintainer reply None cached
Activity 1 comment · opened Sep 22, 2026

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 at origin/main plus one file origin/main had 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.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗