anthropics / anthropics/claude-code

Stop hook reports phantom "unpushed commit(s)" after a squash merge deletes the remote branch

Open Beginner friendly
#94,771 2 comments 0 reactions 0 assignees View on GitHub
area:claude-code-web area:hooks duplicate platform:web
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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.