Web/CCR git stop hook: two false positives; the signature one prescribes an amend loop that can never converge

Status Open
Maintainer reply None cached
Activity 10 comments · opened Jul 30, 2026

Summary

The git stop hook shipped with Claude Code on the web (~/.claude/stop-hook-git-check.sh) has two false positives that block agent turns on correct repository state. The first is worse than noise: the remedy it prescribes rewrites history on already-correct commits, and because the underlying check can never pass in this environment, it does so repeatedly.

Both are environment-wide, not project-specific.

---

Bug 1 — %G? conflates signature verification with signature presence

Location: stop-hook-git-check.sh, the commit.gpgsign block.

unverifiable=$(git log --format='%h %G? %ce' "$upstream..HEAD" | awk '$2 == "N" || $3 != "noreply@anthropic.com"')

An inline comment asserts the check is sound:

%G? is N for unsigned commits; signed-but-locally-unverifiable commits report B/U/E, so this is a reliable presence check even though CCR doesn't configure local verification.

That premise is false for SSH signing. The container sets gpg.format=ssh and user.signingkey, but never sets gpg.ssh.allowedSignersFile. Without it, git cannot verify SSH signatures at all and returns N — not B/U/E. Git says so directly:

$ git log --format='%h %G?' -2
error: gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification
error: gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification
21a7dfe N
75fa952 N

Both commits are genuinely signed:

$ git cat-file commit 75fa952 | sed -n '/^$/q;p' | grep -c '^gpgsig'
1
$ git log --format='%h %ce' -2
21a7dfe noreply@anthropic.com
75fa952 noreply@anthropic.com

So %G? returns N for every commit in this environment, and the hook's condition $2 == "N" is always true. The signature branch fires unconditionally.

Impact: the prescribed remedy rewrites history and cannot converge

The hook's message prescribes:

git commit --amend --no-edit --reset-author

An agent that follows this instruction — which is the hook's intended behavior — produces a new commit that is also signed and also reports N, so the hook fires again on the next turn. In the session below the agent complied twice before recognizing the check itself was wrong; a less suspicious agent would keep going. Because %G? returns N unconditionally here, no number of amends can ever satisfy this check.

$ git reflog --date=iso
21a7dfe HEAD@{2026-07-30 14:57:48}: commit: Add /pm/digest to the visual check routes
75fa952 HEAD@{2026-07-30 14:15:56}: commit (amend): Fix design-system consistency on /pm/digest
5f3f31d HEAD@{2026-07-30 14:15:29}: commit (amend): Fix design-system consistency on /pm/digest
4051a51 HEAD@{2026-07-30 14:13:52}: commit: Fix design-system consistency on /pm/digest

Two amends, 27 seconds apart, both issued by the agent in direct response to the hook's message (confirmed in the session transcript — git commit --amend --no-edit --reset-author at 14:15:27, then git commit --amend --no-edit -S at 14:15:54). All three revisions:

| SHA | tree | signed | committer |
|---|---|---|---|
| 4051a51 | 8927cf2c66 | yes | noreply@anthropic.com |
| 5f3f31d | 8927cf2c66 | yes | noreply@anthropic.com |
| 75fa952 | 8927cf2c66 | yes | noreply@anthropic.com |

git diff 4051a51 75fa952 is empty. Identical content, already signed, already the correct identity. The only field that changed was the committer timestamp — enough to change the SHA. The original commit had been pushed before the first amend, so the branch was rewritten after publication.

This is silent history rewriting caused entirely by a bad diagnosis. On a branch another party had already fetched, it would cause divergence for no reason.

Fix

Read the raw commit object's gpgsig header. That is ground truth for "is this signed" and needs no local verification config. GitHub verifies the signature against the account's registered keys regardless of anything set in the container.

git cat-file commit "$sha" | sed -n '/^$/q;p' | grep -q '^gpgsig'

sed -n '/^$/q;p' stops at the first blank line so only the header block is inspected — a commit message containing "gpgsig" cannot be mistaken for a signature. The ^gpgsig anchor also matches gpgsig-sha256.

---

Bug 2 — a missing remote-tracking ref is treated as "not pushed"

Location: same file, upstream resolution.

if git rev-parse "origin/$current_branch" >/dev/null 2>&1; then
  upstream="origin/$current_branch"
else
  upstream="origin/HEAD"     # <-- wrong conclusion
fi

A missing origin/<branch> ref does not imply the branch is unpushed. Repos attached via add_repo are cloned --depth 1, giving a single-branch refspec:

remote.origin.fetch = +refs/heads/main:refs/remotes/origin/main

No origin/<branch> is ever created for any branch other than main, even after a successful push. The hook falls through to origin/HEAD and counts already-pushed commits as unpushed:

$ git rev-parse HEAD
21a7dfe0deb88ea3b18df33b9828f838b94e318a
$ git ls-remote origin claude/pm-digest-consistency | cut -f1
21a7dfe0deb88ea3b18df33b9828f838b94e318a

$ echo '{"stop_hook_active":false}' | bash ~/.claude/stop-hook-git-check.sh
Branch 'claude/pm-digest-consistency' has 1 unpushed commit(s) and no remote branch.
Please push these changes to the remote repository.

Identical SHAs; nothing is unpushed. This fires on every turn for any branch that isn't the default one — which is every branch the agent is told to create, since it is instructed to branch off the default before committing.

Fix

When the tracking ref is absent, ask the remote before concluding anything:

branch_on_remote=0
if git rev-parse --verify --quiet "refs/remotes/origin/$current_branch" >/dev/null 2>&1; then
  upstream="origin/$current_branch"; branch_on_remote=1
else
  remote_sha=$(git ls-remote --heads origin "$current_branch" 2>/dev/null | cut -f1)
  # Require the object locally, or "$upstream..HEAD" cannot be computed.
  if [[ -n "$remote_sha" ]] && git cat-file -e "$remote_sha^{commit}" 2>/dev/null; then
    upstream="$remote_sha"; branch_on_remote=1
  else
    upstream="origin/HEAD"
  fi
fi

The later message selection should then branch on $branch_on_remote rather than comparing $upstream to origin/$current_branch, so the "and no remote branch" wording stays accurate.

---

Verification

I applied both fixes locally and tested against a local bare remote using the container's real SSH signing key:

| Scenario | Result |
|---|---|
| Signed + pushed, tracking ref missing | silent — the Bug 2 false positive |
| Genuine unpushed commit, branch on remote | 1 unpushed commit(s) on branch 'feature' |
| Branch never pushed at all | ...and no remote branch |
| Genuinely unsigned commit | ab5ef91 ... (unsigned) |
| Unsigned + wrong committer email | a4f1656 ... (unsigned, committer is rogue@example.com) |

Both real detections still work; only the false positives stop.

Note

Edits to ~/.claude/stop-hook-git-check.sh do not persist — the file was restored to its original contents at turn boundaries three times during one session (verified by mtime and content). So this cannot be worked around from inside the container; it needs fixing wherever the canonical copy is provisioned.

View original on GitHub ↗

3 Comments

alexander-turner · 1 month ago

Bug 3 — a repo with core.bare = true is reported dirty on every turn, forever

Both existing bails pass in this state: git rev-parse --git-dir succeeds (prints .git)
and git remote returns origin. The uncommitted-changes check then runs:

$ git diff --quiet
fatal: this operation must be run in a work tree
$ echo $?
128

! git diff --quiet reads exit 128 as "there are changes", so the hook prints
"There are uncommitted changes in the repository" and exits 2 on every turn. Nothing the
agent does can clear it. git diff --cached --quiet exits 1 rather than 128 — the index
is readable without a work tree — so the hook fires either way.

This is not a git clone --bare. The repo has a .git/ directory, a populated index,
and its checked-out files on disk; only core.bare = true in .git/config makes git
refuse every work-tree command:

$ git rev-parse --is-inside-work-tree
false
$ git config --show-origin --get core.bare
file:.git/config true
$ ls -A | wc -l
70

Two separate defects follow. The harness should not leave a repo with a work tree at
core.bare = true — that is the root cause and I could not identify what sets it. The hook
should also not treat "git refused" as "dirty", which is a one-line fix alongside the
existing bails:

# Every check below needs a work tree. Without one `git diff` exits 128 and `!` reads
# that as "dirty". Linked worktrees report "true" here, so they are still checked.
if [[ "$(git rev-parse --is-inside-work-tree 2>/dev/null)" != "true" ]]; then
  exit 0
fi

Verified non-vacuously: in the affected directory the guard exits 0; in a linked worktree
of the same repo --is-inside-work-tree is true, and a modified tracked file still
reaches exit 2 with the uncommitted-changes message.

antonismits · 1 month ago

Your Bug 2 still reproduces on 2.1.261, and I hit it from the other direction while verifying #83924 — that issue is the stale-ref twin of the same line. Detail and a full old-vs-patched matrix are in my comment there; cross-linking because one change fixes both, and because the mechanism turns out to be slightly broader than described here.

The two bugs are the same line

stop-hook-git-check.sh:116 asks "how far is HEAD ahead of one specific ref", and gets a wrong answer in both directions depending on whether that ref is absent or stale:

  • This issue (Bug 2), ref absent — a pushed branch reported as unpushed, or, when origin/HEAD doesn't resolve either, rev-list fails into || unpushed=0 and genuinely unpushed work is reported as nothing.
  • #83924, ref stale — after a PR merges and the remote auto-deletes the head branch, the surviving pre-merge origin/<branch> makes already-merged commits count as unpushed, and the hook tells the agent to push them.

Correction to the mechanism, in your favour

You attribute the missing tracking ref to add_repo's --depth 1 giving a single-branch refspec. In this session's container the refspec is the full one and origin/HEAD is still absent:

$ git rev-parse --is-shallow-repository
true
$ git config remote.origin.fetch
+refs/heads/*:refs/remotes/origin/*
$ git for-each-ref --format='%(refname)' refs/remotes
refs/remotes/origin/main
$ git rev-parse origin/HEAD
fatal: ambiguous argument 'origin/HEAD': unknown revision or path not in the working tree.

So the trigger isn't the refspec — it's that the clone only ever fetched main and origin/HEAD was never set. The bug therefore isn't limited to add_repo-attached repos; it applies to any CCR checkout, which makes it broader than the report claims.

On the two candidate fixes

Both proposals target the same defect and the choice between them is a real trade-off, not a strict improvement:

  • git rev-list HEAD --not --remotes --count (#83924's fix, which I verified): no network, and it closes the stale-ref false positive and this issue's silent false negative together, since it doesn't depend on either ref resolving. It's also already the idiom the signature block ten lines above uses. Its cost: a branch deleted on the remote while its tip still holds unmerged commits stays silent — a false negative in a rare case.
  • git ls-remote --exit-code --heads origin "$current_branch" (this issue's fix): resolves liveness exactly, including that case, at the price of a network round-trip on every turn end.

I'd lean to the first — a stop hook that blocks turn completion shouldn't need the network, and the failure it trades away is quieter than the one it removes — but the second is strictly more accurate if a round-trip per turn is acceptable.

On Bug 1, one datapoint only

I can confirm the config premise on 2.1.261 — gpg.format=ssh and user.signingkey are set, gpg.ssh.allowedSignersFile is not:

commit.gpgsign             = true
gpg.format                 = ssh
user.signingkey            = /home/claude/.ssh/commit_signing_key.pub
gpg.ssh.allowedSignersFile = (unset)

I did not create a locally-signed commit to test %G? end to end, so I'm not confirming the "returns N for every commit" claim — noting only that in this repo's existing history %G? yields N for some commits and E for others, so whatever the correct diagnosis is, it may be worth stating in terms of "not N for all values" rather than a single blanket return. Your gpgsig-header approach looks unaffected by that either way.

One practical note for anyone writing regression tests against either bug: set commit.gpgsign=false in the test repo, or the signature block returns first and the unpushed path is never reached at all.

---
_Generated by Claude Code_

antonismits · 1 month ago

Correction to my comment above: I said I wasn't confirming Bug 1 either way. Having since read the whole signature block on 2.1.261 — Bug 1 is already fixed, using exactly the approach you proposed. %G? is gone from the logic entirely:

if [[ "$ce" != "noreply@anthropic.com" ]] ||
   ! git cat-file commit "$sha" 2>/dev/null | sed '/^$/q' | grep -qE '^gpgsig(-sha256)? '; then

That's your git cat-file commit … | sed -n '/^$/q;p' | grep -q '^gpgsig', including the gpgsig-sha256 case you called out. The only two remaining %G? mentions in the file are comments explaining why it must not be used, citing your rationale:

# Signature presence is tested via the raw gpgsig/gpgsig-sha256 header,
# NOT %G?. CCR/BYOC configure SSH signing (gpg.format=ssh, /tmp/code-sign;
# byoc.go:754-758) but never set gpg.ssh.allowedSignersFile, so %G?
# reports 'N' for correctly SSH-signed commits

So the non-converging amend loop is resolved, and the N-vs-E observation in my previous comment is moot — it was about pre-existing GitHub-authored commits, not the SSH-signed local commits your report was about, and nothing reads %G? any more regardless.

Bug 2 is the part still open, at line 116, and it's the only live $upstream..HEAD left in the file. Flagging the split so nobody re-fixes the half that already shipped. Same split as #83490, which I've noted there: its signature-block fix landed, its unpushed fix didn't.

---
_Generated by Claude Code_

Showing cached comments. Read the full discussion on GitHub ↗