Stop hook (stop-hook-git-check.sh) tells the agent to rewrite published history after a PR merges

Status Open
Maintainer reply None cached
Activity 3 comments · opened Aug 3, 2026

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 by
noreply@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:

  • --amend on a commit already on origin/master rewrites 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 is
origin/<current-branch>.

The moment a PR merges, origin/<branch> is frozen at the pre-merge tip while
master 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.sh correctly sets

user.email=noreply@anthropic.com, so the agent's own commits were never the problem.

View original on GitHub ↗

3 Comments

dontspamcuzo-tech · 2 months ago

The diff in this issue patches both call sites. On 2.1.221 only the signature one has landed — the unpushed count 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:

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).

While ~20 lines below, at stop-hook-git-check.sh:116:

-  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.

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 $upstream resolves to a tombstone.

Repro:

  1. Branch from main, commit, push, open a PR.
  2. Merge it (API or UI) with Automatically delete head branches enabled on the repo.
  3. git fetch origin main — 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.

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 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 main with 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

  • 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
antonismits · 1 month ago

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 unpushed half 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 --remotes for the range, plus a gpgsig header presence test replacing %G?, plus a linearity guard so the range-rebase advice is only offered when local-only history is a linear chain:

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

So the specific failure in your report — GitHub's merge commit 562bb50 E noreply@github.com flagged and an amend prescribed for it — can no longer happen. That commit is on origin/master, so HEAD --not --remotes excludes it.

What didn't

$ grep -n 'upstream\.\.HEAD' ~/.claude/stop-hook-git-check.sh
 59:  # Scope to commits that are on NO remote ref ($upstream..HEAD would sweep
116:  unpushed=$(git rev-list "$upstream..HEAD" --count 2>/dev/null) || unpushed=0

Line 59 is a comment in the fixed block explaining why $upstream..HEAD is 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 main after the remote deleted the head branch):

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

with HEAD equal to origin/main and git 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 main with 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 with exit 2 on 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:

-  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 stays 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_

stuthedew · 12 days ago

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 whatever origin/<branch> resolves to locally:

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

origin/$current_branch only 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 to origin/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):

#!/usr/bin/env bash
# Reproduce stop-hook-git-check.sh demanding a push when nothing is unpushed.
# Usage: bash repro.sh [path-to-hook]   (default: ~/.claude/stop-hook-git-check.sh)
set -euo pipefail
HOOK=$(realpath "${1:-$HOME/.claude/stop-hook-git-check.sh}")
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1   # hermetic: no signing, no user config
export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@example.com GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@example.com
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
git init -q --bare -b main "$T/remote.git"
git clone -q "$T/remote.git" "$T/team" 2>/dev/null && git -C "$T/team" commit -q --allow-empty -m base && git -C "$T/team" push -q origin main
land() { git -C "$T/team" pull -q origin main && git -C "$T/team" commit -q --allow-empty -m "$1" && git -C "$T/team" push -q origin main; }
hook() { local rc=0 err; err=$(cd "$1" && echo '{}' | bash "$HOOK" 2>&1 >/dev/null) || rc=$?
         printf '  hook exit %s, unpushed by --not --remotes: %s\n  %s\n' "$rc" "$(git -C "$1" rev-list HEAD --not --remotes --count)" "${err:-<no output>}"; }

echo "1. Branch pushed, PR squash-merged, remote branch deleted, branch restarted from origin/main"
git clone -q "$T/remote.git" "$T/s1" && cd "$T/s1"
git checkout -q -b claude/one && git commit -q --allow-empty -m work && git push -q -u origin claude/one
land "squash-merge of claude/one" && git -C "$T/team" push -q origin --delete claude/one   # GitHub deletes it server-side
git fetch -q origin main                                                       # no --prune: origin/claude/one stays
git checkout -q -B claude/one origin/main                                      # restart the branch from main once its PR merged
hook "$T/s1"

echo "2. Session branch seeded at start and never pushed; main moves; branch fast-forwarded to origin/main"
git clone -q "$T/remote.git" "$T/s2" && cd "$T/s2"
git checkout -q -b claude/two && git update-ref refs/remotes/origin/claude/two HEAD   # seeded as by createLocalBranch -> UpdateRemoteTrackingBranch
land "another PR merges"
git fetch -q origin main && git merge -q --ff-only origin/main
hook "$T/s2"

echo "3. Control: a commit that really is unpushed"
git commit -q --allow-empty -m "local only"
hook "$T/s2"

Against the shipped hook:

1. Branch pushed, PR squash-merged, remote branch deleted, branch restarted from origin/main
  hook exit 2, unpushed by --not --remotes: 0
  There are 1 unpushed commit(s) on branch 'claude/one'. Please push these changes to the remote repository.
2. Session branch seeded at start and never pushed; main moves; branch fast-forwarded to origin/main
  hook exit 2, unpushed by --not --remotes: 0
  There are 1 unpushed commit(s) on branch 'claude/two'. Please push these changes to the remote repository.
3. Control: a commit that really is unpushed
  hook exit 2, unpushed by --not --remotes: 1
  There are 2 unpushed commit(s) on branch 'claude/two'. Please push these changes to the remote repository.

Suggested fix: give the count the scoping the signing check already uses, under the same condition, so the origin/HEAD fallback is unchanged:

-  unpushed=$(git rev-list "$upstream..HEAD" --count 2>/dev/null) || unpushed=0
+  if [[ "$upstream" == "origin/$current_branch" ]]; then
+    unpushed=$(git rev-list HEAD --not --remotes --count 2>/dev/null) || unpushed=0
+  else
+    unpushed=$(git rev-list "$upstream..HEAD" --count 2>/dev/null) || unpushed=0
+  fi

With it, cases 1 and 2 exit 0 and case 3 still exits 2.