Given / When / Then
Given a fork of apexyard that has merged upstream via a squash-merged PR,
When a fresh Claude Code session starts on that fork,
Then the SessionStart drift banner keeps printing "ApexYard: vX.Y.Z available. Run /update to sync." even though the fork is content-caught-up to upstream v1.1.0.
Repro
Discovered on me2resh/ops#10 (2026-04-23) — the first real-world exercise of the /update skill shipped in v1.1.0:
- Fork forks upstream
me2resh/apexyard (pre-v1.1.0).
/update creates a sync branch; user runs git merge upstream/main on it, then pushes as a PR.
- GitHub merges the PR via squash-merge (default setting on many repos) — 27 upstream commits collapse into one synthetic squash commit.
- Fork's main is now at the squash SHA. Content is identical to upstream v1.1.0.
- BUT: upstream's
v1.1.0 tag points at the upstream-native commit 4e9e840, which is NOT an ancestor of the fork's main after squash-merge.
- The hook runs
git tag --list --sort=-v:refname --merged main | head -1 — and since v1.1.0's target SHA isn't reachable, the hook says "v1.1.0 available" even though the fork is up-to-date content-wise.
Root cause
.claude/hooks/check-upstream-drift.sh uses commit-SHA reachability (git tag --merged main) as the proxy for "has this tag been absorbed". That proxy holds for merge-commit and rebase workflows, but BREAKS for squash-merge because the squash commit has no ancestor link to the upstream commits it contains.
GitHub-default squash-merge is widespread. A hook that only works for non-default merge modes is a footgun — fork maintainers will see the banner keep firing after they've synced, conclude the banner is noisy, and ignore it (the exact failure mode v1.1.0's tag-based design was meant to prevent).
Proposed fixes (pick one or combine)
(a) Fork-tag convention — recommended
After a sync PR merges, tag the fork's main with the upstream tag name at the squash commit. That tag IS reachable from fork main, so --merged main finds it.
- Add a post-merge step to the
/update skill: "after the sync PR merges, run git tag vX.Y.Z <squash-sha> && git push origin vX.Y.Z".
- Document in CHANGELOG +
docs/multi-project.md.
- Pro: zero hook change, trivial workflow addition.
- Con: manual step; easy to forget. Forks that forget see the banner keep firing — same failure mode.
(b) Content-based drift check
Replace the --merged main check with a CHANGELOG-based one: "is there a ## [vX.Y.Z] heading in CHANGELOG.md on the fork's main?". If yes, the fork has incorporated that release's notes (a reliable proxy for "we synced").
- Pro: works regardless of merge mode. No manual step.
- Con: assumes the fork's CHANGELOG has that exact heading format (currently true for apexyard; not guaranteed for every fork's workflow if they customise CHANGELOG).
(c) Fallback hybrid
Current primary: --merged main tag check. If that fails, SECONDARY check: grep CHANGELOG.md on the fork's main for the ## [vX.Y.Z] pattern. If secondary passes, treat the fork as caught up.
- Pro: keeps the working merge-commit case unchanged; squash-merge case now also works.
- Con: the CHANGELOG grep is brittle to heading-format changes.
(d) Last-sync state file
On /update completion, the skill writes .claude/session/last-upstream-sync with the upstream SHA it merged. The hook reads that and compares against upstream/main HEAD.
- Pro: accurate regardless of merge mode.
- Con: session state is gitignored per-machine — would need to move this file OUT of
.claude/session/ to survive re-clones. New file location required.
Non-fix: "just use merge-commit"
Document that forks should configure squash-off for sync PRs. Leaves the footgun in place; relies on every adopter configuring their fork correctly.
Acceptance criteria
Discovery artifact
- Fork:
me2resh/ops
- Symptom PR:
me2resh/ops#10 (merged 2026-04-23 via squash)
- Upstream tag unreachable:
v1.1.0 pointing at 4e9e840 in me2resh/apexyard
- Banner output post-merge:
ApexYard: v1.1.0 available. Run /update to sync. (incorrect — fork is caught up)
Severity
Medium. The feature (tag-based drift detection, shipped in v1.1.0) works correctly in merge-commit and rebase flows but silently fails in the squash-merge flow, which is GitHub's default. Every fork that adopts ApexYard using GitHub-default settings will hit this. Fork maintainers will either learn to ignore the banner (bad) or manually work around it (toil). Not a data-loss or security bug; a UX-correctness bug.
Given / When / Then
Given a fork of apexyard that has merged upstream via a squash-merged PR,
When a fresh Claude Code session starts on that fork,
Then the SessionStart drift banner keeps printing
"ApexYard: vX.Y.Z available. Run /update to sync."even though the fork is content-caught-up to upstream v1.1.0.Repro
Discovered on
me2resh/ops#10(2026-04-23) — the first real-world exercise of the/updateskill shipped in v1.1.0:me2resh/apexyard(pre-v1.1.0)./updatecreates a sync branch; user runsgit merge upstream/mainon it, then pushes as a PR.v1.1.0tag points at the upstream-native commit4e9e840, which is NOT an ancestor of the fork's main after squash-merge.git tag --list --sort=-v:refname --merged main | head -1— and since v1.1.0's target SHA isn't reachable, the hook says "v1.1.0 available" even though the fork is up-to-date content-wise.Root cause
.claude/hooks/check-upstream-drift.shuses commit-SHA reachability (git tag --merged main) as the proxy for "has this tag been absorbed". That proxy holds for merge-commit and rebase workflows, but BREAKS for squash-merge because the squash commit has no ancestor link to the upstream commits it contains.GitHub-default squash-merge is widespread. A hook that only works for non-default merge modes is a footgun — fork maintainers will see the banner keep firing after they've synced, conclude the banner is noisy, and ignore it (the exact failure mode v1.1.0's tag-based design was meant to prevent).
Proposed fixes (pick one or combine)
(a) Fork-tag convention — recommended
After a sync PR merges, tag the fork's main with the upstream tag name at the squash commit. That tag IS reachable from fork main, so
--merged mainfinds it./updateskill: "after the sync PR merges, rungit tag vX.Y.Z <squash-sha> && git push origin vX.Y.Z".docs/multi-project.md.(b) Content-based drift check
Replace the
--merged maincheck with a CHANGELOG-based one: "is there a## [vX.Y.Z]heading inCHANGELOG.mdon the fork's main?". If yes, the fork has incorporated that release's notes (a reliable proxy for "we synced").(c) Fallback hybrid
Current primary:
--merged maintag check. If that fails, SECONDARY check: grepCHANGELOG.mdon the fork's main for the## [vX.Y.Z]pattern. If secondary passes, treat the fork as caught up.(d) Last-sync state file
On
/updatecompletion, the skill writes.claude/session/last-upstream-syncwith the upstream SHA it merged. The hook reads that and compares againstupstream/mainHEAD..claude/session/to survive re-clones. New file location required.Non-fix: "just use merge-commit"
Document that forks should configure squash-off for sync PRs. Leaves the footgun in place; relies on every adopter configuring their fork correctly.
Acceptance criteria
/updateskill spec gains a "step 9" documenting the post-merge tag step.Discovery artifact
me2resh/opsme2resh/ops#10(merged 2026-04-23 via squash)v1.1.0pointing at4e9e840inme2resh/apexyardApexYard: v1.1.0 available. Run /update to sync.(incorrect — fork is caught up)Severity
Medium. The feature (tag-based drift detection, shipped in v1.1.0) works correctly in merge-commit and rebase flows but silently fails in the squash-merge flow, which is GitHub's default. Every fork that adopts ApexYard using GitHub-default settings will hit this. Fork maintainers will either learn to ignore the banner (bad) or manually work around it (toil). Not a data-loss or security bug; a UX-correctness bug.