anthropics / anthropics/claude-code
Stop hook reports phantom "unpushed commit(s)" after a squash merge deletes the remote branch
- Dominant language
- Python
- Stars
- 145k
- Forks
- 23.1k
- PR merge metrics
- PR metrics pending
Description
### 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/'. Please push these changes to the remote repository.
```
…while there is nothing unpushed:
```console
$ 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:
```console
$ git push --force-with-lease=: origin HEAD:
! [rejected] HEAD -> (stale info)
error: failed to push some refs
$ git fetch origin
fatal: couldn't find remote ref
```
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.
1. Agent commits on `claude/` and pushes it.
2. PR is squash-merged into the default branch. GitHub deletes `claude/` on the remote.
3. Agent follows the standard "branch was merged, restart from the default branch" flow:
```
git fetch origin develop
git checkout -B claude/ origin/develop
```
4. Stop hook fires → phantom "1 unpushed commit(s)".
Minimal synthetic repro on an existing clone (this is how I verified the fix):
```console
$ git update-ref refs/remotes/origin/
$ echo '{"stop_hook_active":false}' | bash ~/.claude/stop-hook-git-check.sh; echo "exit=$?"
There are 1 unpushed commit(s) on 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`:
```bash
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:
```bash
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 ` 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:
```bash
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-manager` and 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
Contributor guide
No contributing guide indexed for this repository
Research direction
Start in stop-hook-git-check.sh, especially the upstream selection around lines 39-45, the signing logic near line 79, and the unpushed count around line 116. Reproduce the stale remote-tracking-ref case, then run the four verified scenarios in the issue; done means the squash-merge case exits 0 while genuine commits and uncommitted changes still exit 2.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- git, shell
- Domain
- cli, devtools
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 88/100