Web/CCR git stop hook: two false positives; the signature one prescribes an amend loop that can never converge
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.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗