Stop hook reports fully-pushed branches as unpushed when the clone's fetch refspec is narrowed
Summary
~/.claude/stop-hook-git-check.sh decides whether a branch exists on the remote by testing for refs/remotes/origin/<branch>. When a clone's fetch refspec is narrowed — as cloud containers do, with
remote.origin.fetch=+refs/heads/main:refs/remotes/origin/main
— no remote-tracking ref is ever created for any other branch. git rev-parse origin/<branch> therefore fails for a branch that is pushed and up to date, the hook falls back to origin/HEAD, and it blocks the stop with:
Branch 'feature-branch' has N unpushed commit(s) and no remote branch.
Please push these changes to the remote repository.
The check cannot return a positive for any branch in such a clone. Pushing again does not clear it, because pushing does not create the missing ref.
Minimal repro
Self-contained; no Claude Code required, just the hook script.
#!/bin/bash
set -e
HOOK="${1:-$HOME/.claude/stop-hook-git-check.sh}"
R=$(mktemp -d); cd "$R"
git init -q --bare remote.git
git init -q work; cd work
git config user.email t@x.y; git config user.name t
git config commit.gpgsign false # keep the unrelated signing block out of the way
git remote add origin "$R/remote.git"
echo base > f; git add f; git commit -qm base
git push -q origin HEAD:refs/heads/main
git checkout -qb feature-branch
echo work > g; git add g; git commit -qm work
git push -q origin feature-branch # branch IS on the remote, fully pushed
# Reproduce a narrowed-refspec clone:
git config --unset-all remote.origin.fetch
git config remote.origin.fetch '+refs/heads/main:refs/remotes/origin/main'
git update-ref -d refs/remotes/origin/feature-branch 2>/dev/null || true
git fetch -q origin
git remote set-head origin main
echo '{"stop_hook_active":false}' | bash "$HOOK"; echo "exit=$?"
Actual: Branch 'feature-branch' has 1 unpushed commit(s) and no remote branch. with exit=2, while git ls-remote --heads origin feature-branch returns the same SHA as HEAD.
Expected: silence, exit 0.
Second defect in the same path: the count is wrong
Because the comparison falls back to origin/HEAD, a branch one commit ahead of its pushed tip is reported as every commit since main. In the repro, adding one genuine commit yields "2 unpushed commit(s)".
Proposed fix
When the local ref is missing, ask the remote rather than inferring absence from it. ls-remote is reached only on that fallback path, so the common case costs no network call.
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
+ # A missing refs/remotes/origin/<branch> does NOT mean the branch is absent
+ # from the remote: a narrowed fetch refspec never creates one. Ask the remote
+ # instead of inferring. Fail closed -- only a definite answer clears the gate;
+ # if ls-remote errors (offline, auth, genuinely absent) the previous
+ # behaviour is what happens.
+ branch_on_remote=""
+ if git rev-parse -q --verify "origin/$current_branch" >/dev/null 2>&1; then
+ upstream="origin/$current_branch"
+ branch_on_remote="tracking-ref"
+ else
+ remote_sha="$(git ls-remote --exit-code --heads origin "$current_branch" 2>/dev/null | awk 'NR==1{print $1}')"
+ if [[ -n "$remote_sha" ]] && git cat-file -e "${remote_sha}^{commit}" 2>/dev/null; then
+ # Comparing against the real remote tip also fixes the count.
+ upstream="$remote_sha"
+ branch_on_remote="ls-remote"
+ else
+ upstream="origin/HEAD"
+ fi
+ fi
and at the reporting site:
- if [[ "$upstream" == "origin/$current_branch" ]]; then
+ if [[ -n "$branch_on_remote" ]]; then
echo "There are $unpushed unpushed commit(s) on branch '$current_branch'. ..."
else
The signing block is deliberately untouched. Its guard is conservative on purpose — under a narrowed refspec HEAD --not --remotes would sweep in already-published commits and emit the destructive rebase advice that #69586 exists to prevent.
Verification
| case | expected | before | after |
|---|---|---|---|
| narrowed refspec, pushed + up to date | must NOT fire | fires | pass |
| narrowed refspec, 1 genuinely unpushed commit | fire, count 1 | fires, count 2 | pass |
| narrowed refspec, branch absent from remote | fire, "no remote branch" | pass | pass |
| normal refspec, pushed + up to date | must NOT fire | pass | pass |
| normal refspec, 2 unpushed commits | fire, count 2 | pass | pass |
| uncommitted change | fire | pass | pass |
| untracked file | fire | pass | pass |
5 passed / 2 failed → 7 passed / 0 failed.
Note for anyone hitting this
Patching the file locally does not hold: it is harness-managed and gets restored. In one observed case the patched file was byte-identical to the original again six minutes later, so a local fix does not even survive the session.
Workaround until fixed
Restore the full refspec in the affected clone:
git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'
git fetch origin