[BUG] Stop hook reports phantom "unpushed commit(s)" after a squash-merged PR whose branch was auto-deleted
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
~/.claude/stop-hook-git-check.sh selects its comparison ref with:
git rev-parse "origin/$current_branch"
That answers "does this local ref exist", not "does this branch still exist on
the remote". The two diverge the moment GitHub auto-deletes a merged head
branch: the local origin/<branch> ref survives until something prunes it, so it
still resolves, and origin/<stale>..HEAD counts the squash commit as unpushed.
The result is a Stop-hook block that fires on every stop, indefinitely, with
advice the agent cannot satisfy -- it is told to push to a branch that no longer
exists. Pushing either fails or recreates a deleted branch.
In one session it fired three times across two branches. The same stale ref also
made git push --force-with-lease fail with "stale info", since the lease was
checked against a ref the remote no longer had.
What Should Happen?
When every commit reachable from HEAD is already contained in the default
branch, the work is merged and there is nothing to push. The hook should exit 0
silently, regardless of what any stale remote-tracking ref claims.
Error Messages/Logs
There are 1 unpushed commit(s) on branch 'claude/<branch>'. Please push these changes to the remote repository.
(hook exit code 2, repeated on every stop)
Steps to Reproduce
Note: git push origin --delete <branch> does NOT reproduce this -- it removes
the local tracking ref too. GitHub's server-side auto-delete does not, so the
repro deletes the ref inside the bare remote to match.
- Set up a repo with a remote:
git init -q --bare remote.git && git init -q work && cd work
git config commit.gpgsign false && git remote add origin ../remote.git
echo a > f && git add f && git commit -qm base && git branch -M main
git push -q origin main && git remote set-head origin main
- Branch, push, then squash-merge it into main:
git checkout -qb feature && echo b > g && git add g && git commit -qm work
git push -q origin feature
git checkout -q main && echo b > g && git add g && git commit -qm "work (#1)"
git push -q origin main
- Simulate GitHub's auto-delete of the merged head branch:
git --git-dir=../remote.git update-ref -d refs/heads/feature
- Do the usual post-merge move -- reset the local branch onto main:
git checkout -qB feature origin/main
- Run the hook:
echo '{"stop_hook_active":false}' | bash ~/.claude/stop-hook-git-check.sh; echo "exit=$?"
Actual: "There are 1 unpushed commit(s) on branch 'feature'. ..." exit=2
Expected: silent, exit=0 -- the work is merged
Claude Model
Opus
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.261 (Claude Code)
Platform
Anthropic API
Operating System
Other Linux
Terminal/Shell
Other
Additional Information
ENVIRONMENT
Claude Code on the web (remote session). The hook is registered as the Stop hook
in ~/.claude/launcher-settings.json. That directory is managed and restored on
re-provision -- a local patch was overwritten ~6 minutes after being applied --
so this cannot be fixed by users.
THE OBVIOUS FIX IS WRONG
Substituting the default branch for the upstream (upstream="origin/main")
silences this case but reports every commit on a FULLY PUSHED feature branch as
unpushed, since such a branch is legitimately ahead of the default branch. That
is far more common than the case being fixed.
SUGGESTED FIX -- compare against the default branch as a containment check
rather than substituting it. Insert immediately before the unpushed= line:
default_ref=""
for candidate in \
"$(git symbolic-ref -q --short refs/remotes/origin/HEAD 2>/dev/null)" \
origin/main origin/master; do
if [[ -n "$candidate" ]] && git rev-parse -q --verify "$candidate" >/dev/null 2>&1; then
default_ref="$candidate"
break
fi
done
if [[ -n "$default_ref" ]] && git merge-base --is-ancestor HEAD "$default_ref" 2>/dev/null; then
exit 0
fi
origin/HEAD first, falling back to origin/main then origin/master, because
origin/HEAD is unset in some clone shapes -- including this CCR container.
No placement change is needed relative to the signing block above it: that block
already scopes to HEAD --not --remotes, which is empty once HEAD is contained
in a remote ref, so it no-ops on its own here.
TEST MATRIX -- five scenarios, each run against the shipped hook, the naive
substitution, and the suggested fix:
| scenario | shipped | naive | suggested |
|-----------------------------------------------|---------|-------|-----------|
| squash-merged, stale ref, nothing to push | FAIL | pass | pass |
| genuinely unpushed commit on a tracked branch | pass | pass | pass |
| fully pushed feature branch, 2 ahead of main | pass | FAIL | pass |
| never-pushed branch with work | pass | pass | pass |
| on the default branch, in sync | pass | pass | pass |
WORKAROUNDgit fetch --prune after a merge clears the stale ref. Note that fetch.prune=true
does NOT help a scoped fetch -- prune applies only to the refspec being fetched,
so the common git fetch origin main prunes nothing. Measured:
git fetch origin main fetch.prune=on -> stale ref survives, false alarm
git fetch origin fetch.prune=on -> pruned, quiet
either fetch.prune=off -> survives, false alarm
SEPARATE OBSERVATION (not part of this report; happy to file separately)
Where origin/HEAD is unset, a branch with no remote counterpart takes the
upstream="origin/HEAD" path, git rev-list errors, and || unpushed=0 swallows
it -- so genuinely unpushed work on a new local branch is reported as clean.
Present in the shipped hook and unaffected by the fix above.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗