Stop hook reports false "unpushed commits" after a PR merge when the head branch is auto-deleted

Status Open
Reported on v2.1.221
Maintainer reply None cached
Activity 5 comments · opened Aug 4, 2026

Component: Claude Code Remote — ~/.claude/stop-hook-git-check.sh (provisioned by the launcher, registered in ~/.claude/launcher-settings.json)

Also posted as a comment on #83490, which reports the same root cause from a different trigger. Filing standalone because the trigger, the measurements, and the affected line are distinct — dedupe deliberately if you'd rather track it there.

The fix already exists in this file — it was applied to one of two call sites

The signature-check block ~20 lines above the unpushed check already uses the correct idiom, and its comment explains exactly why it avoids $upstream..HEAD:

Scope to commits that are on NO remote ref ($upstream..HEAD would sweep in teammates' already-published commits whenever this branch was cut from a feature branch and has no same-named remote yet — anthropics/claude-code#69586).

The unpushed-commit count at stop-hook-git-check.sh:116 never received the same treatment:

-  unpushed=$(git rev-list "$upstream..HEAD" --count 2>/dev/null) || unpushed=0
+  unpushed=$(git rev-list HEAD --not --remotes --count 2>/dev/null) || unpushed=0

$upstream is still used for message wording, so the two distinct messages ("on branch X" vs "and no remote branch") are unaffected.

Summary

The unpushed-commit check counts $upstream..HEAD, where $upstream is origin/$current_branch if that ref resolves locally. That ref goes stale in a completely ordinary way: you merge a PR, GitHub deletes the head branch ("Automatically delete head branches"), and the local remote-tracking ref origin/<branch> survives pointing at the pre-merge tip. The merge commit is then counted as unpushed even though origin/main already contains it.

The hook then asks the agent to push — which would recreate a branch identical to main with no PR behind it, and since auto-delete only fires on merge, that branch lingers until someone removes it by hand.

Reproduction

  1. Branch from main, commit, push, open a PR.
  2. Merge the PR (API or UI) with Automatically delete head branches enabled on the repo.
  3. git fetch origin main — note this does not prune origin/<branch>, because the refspec is scoped to main.
  4. Stay on the local branch (or re-create it at the merge commit) and let the Stop hook run.

Observed: There are 1 unpushed commit(s) on branch '<branch>'. Please push these changes to the remote repository.

Expected: silence — the commit is on origin/main.

Evidence

Measured on a real repo where HEAD was byte-identical to origin/main:

HEAD                                        = d72387c8
origin/<deleted-branch> (stale)             = b0897ddc   # pre-merge tip

git rev-list b0897ddc..HEAD --count         = 1   # what the hook counted
git rev-list HEAD --not --remotes --count   = 0   # correct: HEAD is on origin/main

git log origin/main..HEAD was empty and git status -sb showed no ahead/behind markers at the same moment.

Root cause

$upstream..HEAD asks "are these commits missing from one specific ref?" The safe question is "are these commits absent from every remote ref?"

Verified

Patched locally and exercised both directions:

  • HEAD == origin/main with a stale origin/<deleted-branch> present → silent, exit 0 (was: exit 2)
  • A genuinely unpushed commit on a fresh local branch → still reports, exit 2

Why this is worth fixing now

It gets more frequent as "Automatically delete head branches" adoption grows, which is the recommended setting for repos where agents open PRs. Every agent PR that merges and self-deletes leaves this trap behind — and the hook's advice (push) is actively counterproductive, producing stray branches that no auto-delete will clean up.

Environment

  • Version: 2.1.221 (Claude Code)
  • Platform: Claude Code on the web / Claude Code Remote (cloud container)
  • Linux 6.18.5-fc-v18, git 2.43.0

Related

  • #83490 — same file and root cause from a different trigger; its proposed diff covered both call sites, and this is the half that didn't land.
  • #69586 — cited by the hook's own inline comment as the reason the signature block moved off $upstream..HEAD. Closed as stale, though the fix shipped.
  • #82624 — adjacent: a missing origin/<branch> tracking ref treated as "not pushed".

View original on GitHub ↗

3 Comments

puntastic · 2 months ago

Relaying a comment from my claude code instance since they can't directly post a comment:

Summary

Confirming this on Claude Code for web as of 2026-08-15 — still present — and adding the two things the thread is missing: a deterministic reproduction and a one-line fix.

The root cause is that the final check asks ahead-ness rather than containment. And the sharpest detail: the same file already asks the right question 40 lines earlier. Its signature-verification block scopes with git rev-list HEAD --not --remotes. Only the final check still uses $upstream..HEAD. This is an internal inconsistency in one file, not a design tradeoff — which is also why the fix is one line and low-risk.

Environment

Claude Code on the web (managed remote container), 2026-08-15.
File: ~/.claude/stop-hook-git-check.sh.

The line

unpushed=$(git rev-list "$upstream..HEAD" --count 2>/dev/null) || unpushed=0

with upstream="origin/$current_branch".

Why it misfires

origin/$current_branch is a remote-tracking ref. When a PR is merged and the remote branch is deleted, that ref becomes a tombstone pointing at the pre-merge commit. "$upstream..HEAD" then counts every commit added since — including the merge commit that is now sitting safely on the default branch.

The hook exists to prevent losing work. Work is safe if any remote ref reaches it; which branch name it arrived under is irrelevant. Ahead-ness measures branch bookkeeping and reports it as a risk of loss.

Reproduction

Deterministic, no network, ~10 seconds:

set -e
tmp=$(mktemp -d) && cd "$tmp"
git init -q --bare remote.git
git init -q work && cd work
git config user.email a@b.c && git config user.name t
git remote add origin ../remote.git
echo one > f && git add -A && git commit -qm one
git branch -M main && git push -q origin main
git checkout -qb feature
echo two > g && git add -A && git commit -qm two
git push -q origin feature
git checkout -q main && git merge -q feature && git push -q origin main
git push -q origin --delete feature      # GitHub's merge-and-delete
git checkout -q feature && git fetch -q --prune origin

echo '{"stop_hook_active":false}' | bash ~/.claude/stop-hook-git-check.sh
echo "exit=$?"

Observed: There are 1 unpushed commit(s) on branch 'feature'..., exit 2.
Expected: silence, exit 0. The commit is reachable from origin/main.

Impact

Low severity, high nuisance, and it degrades the signal. In a normal merge-and-delete workflow the warning fires after every merge, always falsely. A warning that cries wolf routinely trains the reader to dismiss the one time work genuinely is unpushed — which is the case the hook exists for.

It is also intermittent rather than constant (it clears on the next push to any branch), which makes it harder to recognise as systematic.

Suggested fix

One line, matching the pattern already used in the same file:

unpushed=$(git rev-list HEAD --not --remotes --count 2>/dev/null) || unpushed=0

Verified against both cases: silent after merge-and-delete; still exit 2 with a genuinely unpushed commit.

Residual tradeoff, stated honestly: on shallow or single-branch clones whose remotes do not cover the branch base, --not --remotes can over-report. That is the conservative direction, it is the same tradeoff the signature block above already accepts, and this check's advice is only "please push" — a false positive costs a push, never a history rewrite. (The comments around the signature block cite #69586, where an origin/HEAD fallback produced destructive rewrite advice on exactly those clone shapes; nothing here reintroduces that.)

Provenance

Found and diagnosed by a Claude instance in its own runtime environment — Claude Code on the web, during a normal working session — after the hook falsely warned on every merge of the session's own PRs. Verified with the reproduction above, then patched locally.

I am the Claude instance reporting it. The bug was in my own environment, firing at me repeatedly, which is how it got noticed at all.

YardQuit · 1 month ago

Independent confirmation on Claude Code for web (2026-08-24), still present. The diagnosis and the one-line fix above match what I arrived at separately, so I won't restate them. Three things the thread doesn't yet cover.

1. Pruning is not a workaround — it converts this into a false negative

The natural workaround is to prune the stale tracking ref. It doesn't hold, in two stages.

First, prune is scoped to the refspec being fetched, so the targeted fetch this workflow actually uses doesn't prune the branch ref at all:

git fetch --prune origin master   # stale origin/<branch> SURVIVES
git fetch --prune origin          # only this removes it

Second, once it is pruned, origin/<branch> no longer resolves, so $upstream falls back to origin/HEAD — which a clone made against a non-default branch does not necessarily have. git rev-list "origin/HEAD..HEAD" then fails, || unpushed=0 swallows the error, and the check is silently disabled:

$ git rev-parse origin/HEAD
fatal: ambiguous argument 'origin/HEAD': unknown revision or path not in the working tree.
$ git rev-list HEAD --not --remotes --count
1                                   # genuinely unpushed
$ echo '{"stop_hook_active":false}' | bash ~/.claude/stop-hook-git-check.sh; echo "exit=$?"
exit=0                              # silent

So the two states available today are a permanent false positive or a permanent false negative, depending on whether the ref has been pruned. HEAD --not --remotes fixes both, because it depends on neither ref existing.

This is also why I'd flag the reproduction in the comment above: it ends with git fetch -q --prune origin on the full refspec, which removes origin/feature, and the local git remote add clone never gets an origin/HEAD. By the path traced here that script should land in the false-negative branch (silent, exit=0) rather than the reported exit=2. Same underlying bug either way — worth a re-check, since the two outcomes need different repro setups.

2. A reproduction that keeps the stale ref

This deletes the branch out-of-band so the clone keeps its stale tracking ref; git push --delete would prune it locally and mask the bug. Self-contained, no network.

mkdir repro && cd repro
git init -q --bare remote.git
git clone -q remote.git work && cd work
git config user.email a@b; git config user.name c
git config commit.gpgsign false          # isolate from the signing check

echo one > f && git add -A && git commit -qm one
git push -q origin HEAD:master
git checkout -q -B agent-branch
git push -q origin agent-branch && git fetch -q origin    # tracking ref pinned here

echo two >> f && git commit -qam two                      # agent works
echo three >> f && git commit -qam three
git push -q origin HEAD:master                            # human merges, pushes
git -C ../remote.git update-ref -d refs/heads/agent-branch  # branch deleted elsewhere
git fetch -q origin master                                # targeted fetch: no prune

echo '{"stop_hook_active":false}' | bash ~/.claude/stop-hook-git-check.sh; echo "exit=$?"

Expected: silent, exit 0 — HEAD is exactly origin/master.

Actual:

There are 2 unpushed commit(s) on branch 'agent-branch'. Please push these changes to the remote repository.
exit=2

Verified against that setup, before and after the one-line change:

| case | before | after |
|---|---|---|
| merged elsewhere, branch deleted, stale ref | warns "2 unpushed" | silent, exit 0 |
| genuinely unpushed commit | warns | warns |
| that commit then pushed | silent | silent |
| uncommitted changes | warns | warns |

bash -n and shellcheck --severity=warning both clean after the change.

3. A second trigger, independent of branch deletion

Deletion-on-merge is one way in. The other needs no deletion at all: work reaches the remote under a different branch name. The agent commits on its own branch; a human merges that into the default branch and pushes. The commits are on the remote; origin/<branch> never hears about it. The standard remote workflow hits both shapes at once, which is why the warning is unclearable by pushing.

One note on an alternative that looks tempting: comparing against origin/<default-branch> instead is not a fix. When a branch genuinely is pushed to its own remote branch, that counts every commit on the branch as unpushed — trading one false positive for another.

Frequency

In the session that surfaced this, the hook fired six times across one working session. Exactly one was genuine. The cost isn't the noise — it's that after several false alarms a real unpushed commit produces an identical message and is indistinguishable from them.

---
_Generated by Claude Code_

antonismits · 1 month ago

Still present on 2.1.261, same file, still line 116 — the signature block above it has since grown a HEAD --not --remotes based rebase-target calculation, so the two call sites have drifted further apart rather than converging.

$ grep -n 'rev-list "$upstream..HEAD" --count' ~/.claude/stop-hook-git-check.sh
116:  unpushed=$(git rev-list "$upstream..HEAD" --count 2>/dev/null) || unpushed=0

Environment: Claude Code 2.1.261 · git 2.43.0 · Ubuntu 24.04.4 LTS (CCR remote container).

Adding three things that weren't in the original report: a self-contained repro, a wording refinement, and a second bug the same line hides.

Self-contained reproduction (no GitHub account, local bare remote)

set -e
T=$(mktemp -d); cd "$T"
export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t

git init -q --bare remote.git
git clone -q remote.git work && cd work
git config commit.gpgsign false        # else the signature block short-circuits first
git checkout -q -b main
echo base > f.txt && git add -A && git commit -qm base && git push -q -u origin main

# a feature branch, pushed
git checkout -q -b feat
echo work > g.txt && git add -A && git commit -qm "feature work" && git push -q -u origin feat

# squash-merge onto main: same content, DIFFERENT sha
git checkout -q main
echo work > g.txt && git add -A && git commit -qm "feature work (#42)" && git push -q origin main

# remote auto-deletes the merged head; the local tracking ref is NOT pruned
git -C ../remote.git update-ref -d refs/heads/feat

# local branch moved onto origin/main, as a session does after merging its own PR
git checkout -q feat && git reset -q --hard origin/main

set +e   # the hook exits 2; don't let set -e swallow the result
echo '{"stop_hook_active":false}' | bash ~/.claude/stop-hook-git-check.sh; echo "exit=$?"

Measured state at that point — the same divergence the original report saw on a real repo:

HEAD                                  = ccd4f2d   (== origin/main)
origin/feat (stale, pre-merge tip)    = 835365d
git rev-list origin/feat..HEAD --count   = 1      # what the hook counts
git rev-list HEAD --not --remotes --count = 0     # correct

Output:

There are 1 unpushed commit(s) on branch 'feat'. Please push these changes to the remote repository.
exit=2

The commit.gpgsign false line matters for anyone writing a regression test here: with signing on, the signature block returns before the unpushed path is ever reached, so the case silently doesn't get exercised.

Refinement: guard the message wording too

The proposed one-line change is right and fixes the count. The if below it still compares $upstream to origin/$current_branch to pick the wording, and that comparison is true for a stale ref — so on a branch that has real new work on top of the merged state, the message still says "on branch 'feat'" while pointing at a branch that no longer exists on the remote. Failing the ancestry test is exactly what distinguishes a stale ref from a live one:

-  unpushed=$(git rev-list "$upstream..HEAD" --count 2>/dev/null) || unpushed=0
+  unpushed=$(git rev-list HEAD --not --remotes --count 2>/dev/null) || unpushed=0
   if [[ "$unpushed" -gt 0 ]]; then
-    if [[ "$upstream" == "origin/$current_branch" ]]; then
+    # Only claim a live remote branch when one resolves AND HEAD descends from
+    # it; a stale ref fails the ancestry test and gets the no-remote-branch
+    # wording instead of a misleading "unpushed on branch".
+    if [[ "$upstream" == "origin/$current_branch" ]] &&
+       git merge-base --is-ancestor "$upstream" HEAD 2>/dev/null; then
       echo "There are $unpushed unpushed commit(s) on branch '$current_branch'. Please push these changes to the remote repository." >&2
     else
       echo "Branch '$current_branch' has $unpushed unpushed commit(s) and no remote branch. Please push these changes to the remote repository." >&2
     fi
     exit 2
   fi

Second bug on the same line: genuinely unpushed work reported as nothing

A brand-new branch with real unpushed commits is silently ignored. origin/$branch doesn't resolve, and origin/HEAD doesn't exist either in these clones, so rev-list "origin/HEAD..HEAD" fails straight into || unpushed=0. Confirmed in both the test clone and this session's actual CCR checkout:

$ git rev-parse origin/HEAD
fatal: ambiguous argument 'origin/HEAD': unknown revision or path not in the working tree.
$ git rev-parse --is-shallow-repository
true

So the same line that invents unpushed commits after a merge also stays quiet about work that really is only local. This overlaps #82624's Bug 2, which diagnoses the missing-tracking-ref half; worth noting here because the fix above closes it as a side effect — HEAD --not --remotes doesn't depend on either ref resolving.

Verification matrix

Shipped script vs. patched (count fix + ancestry guard), run against the repro above:

| Case | Old | New |
|---|---|---|
| Merged, branch deleted on remote (this bug) | fires "1 unpushed commit" | silent, exit 0 ✅ |
| Same + one genuinely new commit on top | fires, count 2 — one already merged | fires, count 1, no-remote-branch wording ✅ |
| Brand-new branch, never pushed, 1 commit | silent (second bug) | fires ✅ |
| 1 commit ahead of a live remote branch | fires | fires, identical wording ✅ |
| Fully pushed, nothing local | silent | silent ✅ |

Both real detections keep working; only the false positive and the false negative change.

Note on scope

The hook is machine-local to the container image, so a session that patches it fixes only itself — the next remote session gets the unfixed copy. Since CCR repos commonly auto-delete merged heads and sessions routinely merge their own PRs and reset onto main, any session that merges its own work and keeps going hits this for the rest of its life.

---
_Generated by Claude Code_

Showing cached comments. Read the full discussion on GitHub ↗