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

Status Open
Maintainer reply None cached
Activity 2 comments · opened Sep 16, 2026

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.

  1. Agent commits on claude/<name> and pushes it.
  2. PR is squash-merged into the default branch. GitHub deletes claude/<name> 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/<name> origin/develop
``

  1. 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-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

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗