Stop hook (stop-hook-git-check.sh) tells the agent to rewrite published history after a PR merges
Stop hook tells the agent to rewrite published history after a PR merges
Component: Claude Code Remote — ~/.claude/stop-hook-git-check.sh
(installed by the container image, registered via ~/.claude/launcher-settings.json)
Summary
After a PR is merged and the working branch is repointed at the updated default
branch — which is exactly what CCR's own system prompt instructs the agent to do —
the Stop hook flags GitHub's own merge commit as an unverified commit belonging
to the agent, and instructs the agent to git commit --amend --no-edit --reset-author
it and push.
Following that instruction rewrites already-published master history and
reassigns the human's merge commit to Claude.
What it printed
There are commit(s) on branch 'claude/framework-plugin-analysis-rzmgtm' that GitHub
will show as Unverified (missing signature, or committer email is not
noreply@anthropic.com):
562bb50 E noreply@github.com
Please run 'git config user.email noreply@anthropic.com && git config user.name
Claude', then 'git commit --amend --no-edit --reset-author' for the tip commit ...
562bb50 is Merge pull request #22 from chenFreeman/claude/..., committed bynoreply@github.com — created by GitHub when the user clicked Merge. It was
already on origin/master. Every commit the agent actually authored was correctly
signed as noreply@anthropic.com.
Why this is worth fixing
The advice is not merely noisy, it is destructive if followed:
--amendon a commit already onorigin/masterrewrites published history- the subsequent push must be a force-push to succeed
- the human's merge commit gets reattributed to Claude
An agent that trusts hook output — which is the intended posture, hook output is
surfaced as user feedback — will do this. I only avoided it because the commit
was obviously not mine.
It is also self-inflicted: the CCR system prompt says
If the pull request for your designated branch has already been merged … Restart your designated branch from the latest default branch (keep the same branch name)
which is precisely the state that triggers the false positive.
Root cause
The hook computes its range as "$upstream..HEAD" where $upstream isorigin/<current-branch>.
The moment a PR merges, origin/<branch> is frozen at the pre-merge tip whilemaster moves ahead. Repointing the local branch at the merged master therefore
places GitHub's merge commit inside "$upstream..HEAD", because that range is
computed against a ref that is now stale.
origin/claude/framework-plugin-analysis-rzmgtm e04fb36 (pre-merge tip)
HEAD 562bb50 (= origin/master, merge commit)
origin/<branch>..HEAD → 562bb50 ← flagged
The same range is reused a few lines below for the unpushed count, so that check
has the identical bug (it reports the merge commit as an unpushed commit).
Reproduction
git init -q --bare remote && git clone -q remote work && cd work
git config commit.gpgsign true
git config user.email noreply@anthropic.com
echo a > a.txt && git add . && git commit -qm base && git push -qu origin main
git checkout -qb feature && echo c > c.txt && git add . && git commit -qm work
git push -qu origin feature
git checkout -q main
git -c user.email=noreply@github.com merge --no-ff -qm "Merge pull request #99" feature
git push -q origin main
git checkout -qB feature main # repoint branch at merged master
echo '{"stop_hook_active":false}' | ~/.claude/stop-hook-git-check.sh
# → flags the merge commit, exit 2
Suggested fix
Compute the range as "reachable from HEAD but from no origin ref at all", which is
the set of commits that are genuinely local and therefore safe to amend:
+ local_only_range=(HEAD --not --remotes=origin)
+
if [[ "$(git config --type=bool commit.gpgsign 2>/dev/null)" == "true" ]]; then
- unverifiable=$(git log --format='%h %G? %ce' "$upstream..HEAD" 2>/dev/null | awk ...)
+ unverifiable=$(git log --format='%h %G? %ce' "${local_only_range[@]}" 2>/dev/null | awk ...)
- unpushed=$(git rev-list "$upstream..HEAD" --count 2>/dev/null) || unpushed=0
+ unpushed=$(git rev-list "${local_only_range[@]}" --count 2>/dev/null) || unpushed=0
The invariant this restores: never ask anyone to rewrite a commit that is already
published. Commits still local are caught exactly as before, so the change is
strictly narrower, not weaker.
Verification
Patched the script in-container and tested four cases:
| Case | Before | After |
|---|---|---|
| Branch repointed at merged master | flags GitHub's merge commit, exit 2 | exit 0 |
| Local commit with wrong committer email, unpushed | caught | caught (unchanged) |
| Same commit after pushing | not caught | not caught (unchanged) |
| Clean tree, branch level with remote | exit 0 | exit 0 |
Notes
- Observed on 2026-08-02 in a CCR container; hook file timestamp 04:46 (image-provided).
- The related SessionStart hook
session-start-git-identity.shcorrectly sets
user.email=noreply@anthropic.com, so the agent's own commits were never the problem.
3 Comments
The diff in this issue patches both call sites. On
2.1.221only the signature one has landed — theunpushedcount still uses"$upstream..HEAD", and it produces a false positive from a different trigger than the one described above.The file now argues against itself. The signature block explains precisely why that range is wrong, citing #69586:
While ~20 lines below, at
stop-hook-git-check.sh:116:$upstreamis still used for message wording, so the two distinct messages ("on branch X" vs "and no remote branch") are unaffected.A second trigger: auto-deleted head branches
This issue reaches the stale ref by repointing the branch at the merged default. There's a more common route that needs no repointing at all: merge a PR with Automatically delete head branches enabled. GitHub deletes the head branch server-side, but the local remote-tracking ref
origin/<branch>survives pointing at the pre-merge tip, so$upstreamresolves to a tombstone.Repro:
main, commit, push, open a PR.git fetch origin main— this does not pruneorigin/<branch>, because the refspec is scoped tomain.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
HEADwas byte-identical toorigin/main:git log origin/main..HEADwas empty andgit status -sbshowed no ahead/behind markers at the same moment.Verified
Patched locally and exercised both directions:
HEAD == origin/mainwith a staleorigin/<deleted-branch>present → silent, exit 0 (was: exit 2)Why this half is worth applying too
The failure mode is milder than the one in the original report — the hook asks for a push rather than an amend, so it can't rewrite published history. But it isn't harmless: pushing recreates a branch identical to
mainwith no PR behind it, and since auto-delete only fires on merge, that branch lingers until someone removes it by hand. It also gets more frequent as auto-delete adoption grows, which is the recommended setting for repos where agents open PRs.Environment
2.1.221 (Claude Code)Checked this against 2.1.261: your suggested fix was a two-call-site diff, and the first call site landed in full while the second did not. The destructive half you reported is fixed; the
unpushedhalf you flagged in passing ("The same range is reused a few lines below … so that check has the identical bug") is still shipping unchanged.Related: #83924 reports that surviving half from the auto-delete trigger, and #82624 reports its absent-ref counterpart. Old-vs-patched matrix and a self-contained repro in my comment on #83924; mechanism notes in my comment on #82624.
What landed
The signature block now does what you asked for, and then some —
HEAD --not --remotesfor the range, plus agpgsigheader presence test replacing%G?, plus a linearity guard so the range-rebase advice is only offered when local-only history is a linear chain:So the specific failure in your report — GitHub's merge commit
562bb50 E noreply@github.comflagged and an amend prescribed for it — can no longer happen. That commit is onorigin/master, soHEAD --not --remotesexcludes it.What didn't
Line 59 is a comment in the fixed block explaining why
$upstream..HEADis the wrong primitive. Line 116 is the only live use left in the file — the check is now documented against itself, twelve lines apart. Same for%G?: both remaining mentions are comments explaining why it isn't used.Confirmed still firing on 2.1.261 in your exact scenario shape (branch repointed at merged
mainafter the remote deleted the head branch):with
HEADequal toorigin/mainandgit rev-list HEAD --not --remotes --count=0.Severity difference worth noting
The surviving half is materially less dangerous than the one that got fixed. Your original report's remedy rewrote published history and reattributed a human's merge commit; this one only asks for a push. Following it recreates a branch identical to
mainwith no PR behind it — annoying and self-perpetuating, since auto-delete only fires on merge, but not destructive. That's plausibly why it was deprioritised, and it's a fair call — but it does still block turn completion withexit 2on every subsequent turn, and it hides genuinely unpushed work in the absent-ref case (#82624), which is the part that argues for closing it out.The one-line change is the same primitive the block above already uses:
$upstreamstays in use for message wording, so the two message variants are unaffected — though the wording selector benefits from an ancestry guard too, per the #83924 comment.---
_Generated by Claude Code_
Still reproducible in the current Claude Code on the web container (Claude Code 2.1.288, checked 2026-10-03). The signing check has since been scoped to
HEAD --not --remotes, but the unpushed count after it still compares against whateverorigin/<branch>resolves to locally:origin/$current_branchonly has to resolve locally to become$upstream, so every commit the default branch gained since that ref was last written counts as unpushed. That happens after a merge, when the host deletes the head branch and the tracking ref stays behind. It also happens with the ref the environment manager seeds when it creates the session branch: a session that never pushed, and only fast-forwarded toorigin/main, is told it has unpushed commits. Obeying either demand pushes a branch with no commits of its own, and after a merge it recreates the deleted head branch.Hermetic reproduction (pass the hook's path; it ignores your git config):
Against the shipped hook:
Suggested fix: give the count the scoping the signing check already uses, under the same condition, so the
origin/HEADfallback is unchanged:With it, cases 1 and 2 exit 0 and case 3 still exits 2.