Skip to content

[Bug] Tag-based drift hook keeps firing after squash-merged sync PRs #106

@atlas-apex

Description

@atlas-apex

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:

  1. Fork forks upstream me2resh/apexyard (pre-v1.1.0).
  2. /update creates a sync branch; user runs git merge upstream/main on it, then pushes as a PR.
  3. GitHub merges the PR via squash-merge (default setting on many repos) — 27 upstream commits collapse into one synthetic squash commit.
  4. Fork's main is now at the squash SHA. Content is identical to upstream v1.1.0.
  5. 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.
  6. 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

  • After a squash-merged sync PR lands on a fork, the SessionStart banner goes silent.
  • The fix doesn't regress the existing merge-commit / rebase workflows (both already work today).
  • CHANGELOG + docs updated to reflect the new behaviour.
  • If fix is (a) fork-tag convention, /update skill spec gains a "step 9" documenting the post-merge tag step.
  • If fix is (b) or (c) content-based, include a regression test or a manual verification recipe.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions