[BUG] Stop hook fabricates ahead count when origin/HEAD is unset after squash-merge branch deletion
Summary
The Stop hook shipped in cloud containers (~/.claude/stop-hook-git-check.sh) reports a fabricated "N unpushed commit(s)" count when it cannot resolve a base ref. It selects origin/HEAD as a fallback without verifying that ref exists, passes it to git rev-list --count, discards the resulting error, and uses the empty stdout as a count.
In a session where the working tree was clean and fully merged, this produced "There are 3 unpushed commit(s)" and later "There are 33 unpushed commit(s)" while git rev-list --count origin/main..HEAD was 0 both times.
This is not cosmetic: the hook exits non-zero, so the agent is told at every stop that it has unpushed work. Acting on that message means force-pushing or re-committing already-merged history.
Environment
- Claude Code on the web / cloud container (remote session),
entrypoint: remote - Affected file:
~/.claude/stop-hook-git-check.sh - Registered as the
Stophook in~/.claude/launcher-settings.json(not~/.claude/settings.json, which contains no hook entries) - Both files are recreated by the container bootstrap — observed being rewritten mid-session, so local edits to the hook do not persist
Root cause
current_branch=$(git branch --show-current)
if [[ -n "$current_branch" ]]; then
if git rev-parse "origin/$current_branch" >/dev/null 2>&1; then
upstream="origin/$current_branch"
else
upstream="origin/HEAD" # <-- never verified to exist
fi
origin/HEAD is a local convenience ref. It is not created by git clone --branch, by shallow/single-branch clones, or by a later git fetch origin main. It is absent in exactly the state cloud sessions reach constantly: a branch that was squash-merged and deleted on the remote.
At that point:
origin/$current_branchno longer exists (deleted on merge)origin/HEADwas never set$upstreamnames a ref that resolves to nothinggit rev-list --count "$upstream..HEAD"fails; its stderr is suppressed and its empty stdout becomes the count
Reproduction
Any repository whose merge strategy deletes the branch (GitHub "squash and merge" + auto-delete):
git clone --branch main --single-branch <repo> # no origin/HEAD is created
cd <repo>
git checkout -b feature-branch
# ...commit, push, open a PR, squash-merge it; the remote branch is deleted...
git fetch --prune origin # drops origin/feature-branch
git checkout -B feature-branch origin/main # local branch, no same-name remote
git rev-parse origin/HEAD # fatal: ambiguous argument
git rev-list --count origin/main..HEAD # 0 -- nothing unpushed
git status --porcelain # empty -- clean tree
The Stop hook then reports N unpushed commits.
Proof the count was fabricated
$ git rev-list --count origin/main..HEAD
0
$ git status --porcelain # (no output)
$ git symbolic-ref -q refs/remotes/origin/HEAD
# (unset)
$ git rev-list --count origin/HEAD..HEAD
# (empty -- this empty string is the "count")
Setting the ref (git remote set-head origin -a) makes the hook correct immediately, confirming the diagnosis. That is a workaround, not a fix: the bootstrap recreates the hook, and a container that never sets origin/HEAD re-enters the same state.
Reference fix
Resolution order, each candidate verified to resolve to a commit before use:
resolve_base_ref() {
local branch="$1" ref
# 1. The branch's own configured upstream.
ref=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null)
if [[ -n "$ref" ]] && git rev-parse --verify -q "$ref^{commit}" >/dev/null; then
printf '%s\n' "$ref"; return 0
fi
# 2. A remote branch of the same name.
if [[ -n "$branch" ]] && git rev-parse --verify -q "origin/$branch^{commit}" >/dev/null; then
printf '%s\n' "origin/$branch"; return 0
fi
# 3. origin/HEAD -- verified, not assumed. This is the check that is missing.
if git rev-parse --verify -q 'origin/HEAD^{commit}' >/dev/null; then
printf '%s\n' 'origin/HEAD'; return 0
fi
# 4. Conventional default branches.
for ref in origin/main origin/master; do
if git rev-parse --verify -q "$ref^{commit}" >/dev/null; then
printf '%s\n' "$ref"; return 0
fi
done
# 5. Nothing resolved. Say so; do not guess and do not count.
return 1
}
Call site:
base_ref=$(resolve_base_ref "$current_branch") || {
echo "Base branch unresolved: no upstream, no origin/$current_branch, no origin/HEAD, no origin/main or origin/master. Cannot determine whether commits are unpushed. Fix: git remote set-head origin -a (or push the branch to create its remote ref)." >&2
exit 2
}
ahead=$(git rev-list --count "$base_ref..HEAD")
if [[ "$ahead" -gt 0 ]]; then
echo "There are $ahead unpushed commit(s) on branch '$current_branch' (vs $base_ref). Please push these changes to the remote repository." >&2
exit 2
fi
Two properties worth keeping structural rather than incidental:
git rev-listis unreachable from an unverified ref.resolve_base_refreturns only refs it has already verified, so no future edit can reintroduce a count derived from an invalid base.- The message names the base it compared against —
(vs origin/main)— so a report is auditable instead of a bare number. Without this, the two wrong counts above were indistinguishable from real ones.
Expected behaviour when nothing resolves
Fail closed and say why. The current failure mode is the worst available: it emits a confident, specific, wrong number. A hook that cannot determine the base must report that it cannot, not guess.
Suggested tests
| case | setup | expected base |
|---|---|---|
| upstream set | git branch --set-upstream-to=origin/main | the upstream ref |
| same-name remote | upstream unset, origin/<branch> exists | origin/<branch> |
| origin/HEAD | neither of the above, origin/HEAD set | origin/HEAD |
| default branch | none of the above, origin/main exists | origin/main |
| nothing resolves | none of the four exist | exit 2, "Base branch unresolved", no count emitted |
I verified cases 1–4 and the stop condition against a real repository by unsetting the upstream, deleting refs/remotes/origin/HEAD, then deleting refs/remotes/origin/<branch> and refs/remotes/origin/main in turn. Each step selected the next candidate in order, and the final state returned the unresolved error rather than a count.
Note on /bug
I filed this by hand rather than through /bug, because that command can transmit conversation history including source, and this session was working in a private repository. Everything above is synthetic or generic.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗