Stop hook reports phantom "unpushed commit(s)" after a squash merge deletes the remote branch
What happens
On Claude Code on the web (remote container), the Stop hook registered in ~/.claude/launcher-settings.json → ~/.claude/stop-hook-git-check.sh blocks with:
There are 1 unpushed commit(s) on branch 'claude/<name>'. Please push these changes to the remote repository.
…while there is nothing unpushed:
$ git log --oneline origin/develop..HEAD | wc -l
0
$ git status --short
$ # (clean)
The advice it gives cannot be followed. The branch it wants pushed no longer exists on the remote, so the push either recreates a deleted branch or, with the lease the agent is told elsewhere to use, fails outright:
$ git push --force-with-lease=<branch>:<old-sha> origin HEAD:<branch>
! [rejected] HEAD -> <branch> (stale info)
error: failed to push some refs
$ git fetch origin <branch>
fatal: couldn't find remote ref <branch>
The agent then burns a turn diagnosing this, and the hook fires again on the next Stop.
Reproduction
Any repo with squash merge + "automatically delete head branch" — a very common GitHub configuration.
- Agent commits on
claude/<name>and pushes it. - PR is squash-merged into the default branch. GitHub deletes
claude/<name>on the remote. - Agent follows the standard "branch was merged, restart from the default branch" flow:
````
git fetch origin develop
git checkout -B claude/<name> origin/develop
- Stop hook fires → phantom "1 unpushed commit(s)".
Minimal synthetic repro on an existing clone (this is how I verified the fix):
$ git update-ref refs/remotes/origin/<branch> <pre-squash-sha>
$ echo '{"stop_hook_active":false}' | bash ~/.claude/stop-hook-git-check.sh; echo "exit=$?"
There are 1 unpushed commit(s) on branch '<branch>'. Please push these changes to the remote repository.
exit=2
Root cause
stop-hook-git-check.sh:39-45 picks the upstream with git rev-parse:
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"
fi
git rev-parse only consults the local remote-tracking ref. That ref survives deletion of the remote branch until someone runs git remote prune — so it still resolves, pointing at the pre-squash commit.
stop-hook-git-check.sh:116 then counts against it:
unpushed=$(git rev-list "$upstream..HEAD" --count 2>/dev/null) || unpushed=0
Because a squash merge creates a new commit, HEAD (now the default branch's tip) is not a descendant of the stale ref, so the range is non-empty. Every commit it counts is already published — it is on the default branch.
A pruning gotcha makes this sticky: git fetch --prune origin <branch> does not remove the stale ref, because the refspec scopes the prune. Only git remote prune origin (or git fetch --prune with no refspec) clears it. So the false alarm repeats across turns until someone happens to run the right prune.
Suggested fix
Suppress the count when every commit in HEAD is already reachable from some remote ref:
unpushed=$(git rev-list "$upstream..HEAD" --count 2>/dev/null) || unpushed=0
# $upstream is resolved with `git rev-parse`, which only consults the local
# remote-tracking ref — it still resolves after the remote branch is gone
# (squash merge + delete-branch-on-merge). `HEAD --not --remotes` answers the
# question this gate actually cares about: is any work reachable only from
# the local clone? Applied as a suppressor so every other shape keeps its
# current behaviour and message.
if [[ "$unpushed" -gt 0 ]] &&
[[ "$(git rev-list HEAD --not --remotes --count 2>/dev/null)" == "0" ]]; then
unpushed=0
fi
if [[ "$unpushed" -gt 0 ]]; then
This is the same primitive the signing block above already uses (stop-hook-git-check.sh:79, local_count="$(git rev-list HEAD --not --remotes --count)"), so the fix makes the two checks consistent rather than introducing a new idiom. It needs no network call, so it also works offline.
The one behavioural change is commits that live on a remote ref other than $upstream. Those are not at risk of being lost when the ephemeral container goes away, which is what this gate exists to prevent.
Verified
Patched the script in a live container and exercised four cases:
| Case | Before | After |
| --- | --- | --- |
| Stale tracking ref after squash merge (the bug) | exit 2 + false message | exit 0 |
| Genuine unpushed commit | exit 2 | exit 2 |
| Uncommitted changes | exit 2 | exit 2 |
| Clean, ref already pruned | exit 0 | exit 0 |
Alternative considered and rejected
Verifying the remote branch still exists with git ls-remote --exit-code --heads origin "$current_branch" before trusting $upstream. Rejected: it adds a network round-trip to every Stop, and fails closed when the container is offline or the proxy is unavailable.
Environment
- Claude Code on the web, remote execution container
- Stop hook provisioned by
/opt/env-runner/environment-managerand re-written at session start (so a local patch does not survive) - Repo configured with squash-merge-only for feature branches and delete-branch-on-merge
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗