squash-merge-content-preservation-vs-ancestor-check · v1.0.0 · 2026-05-29 · sha256 be5b958112d62589
squash-merge-content-preservation-vs-ancestor-check v1.0.0A
Immutable. This exact content is served forever at /api/v1/blob/be5b958112d62589.
--- name: squash-merge-content-preservation-vs-ancestor-check description: | Use when verifying that an upstream commit (e.g., a colleague's work, a feature-branch commit) is preserved in a target branch after a propagation PR has merged — and the verification script uses `git merge-base --is-ancestor <upstream-sha> <target-branch>`. If the merge mode was **squash**, that ancestor check ALWAYS returns non-zero even when content is preserved verbatim, because squash collapses every commit into a single new SHA on the target. Apply this skill when: (1) preservation audit for a recently-merged propagation PR returns "MISSING" or non-zero exit on `git merge-base --is-ancestor`, (2) handoff prompt mixes `is-ancestor` AND content checks without specifying merge mode, (3) you need to gate a merge on "did colleague's signature work survive". The correct probe depends on merge mode — fast-forward / no-ff preserves lineage; squash does not. version: 1.0.0 date: 2026-05-27 author: Huiyan / Claude Opus 4.7 --- # Squash Merge Content Preservation vs Ancestor Check ## Problem Your verification script says: ```bash git merge-base --is-ancestor 0b6303e76 origin/main && echo "OK" || echo "MISSING" # Prints: MISSING ``` … but `wc -l <signature-file>` returns the expected line counts and `grep -c <signature-string>` returns ≥ the expected ref count. The exit code from `is-ancestor` is non-zero **even though every byte of the upstream work is preserved on the target branch**. The "MISSING" verdict is wrong. Root cause: a squash merge collapses every commit on the source branch into a **single new commit** on the target. The original SHAs (including the upstream commit you're checking) never appear in the target's history. `is-ancestor` returns "false" because the SHA truly isn't an ancestor of the merge commit — but the SHA's *content* is preserved verbatim inside the squash commit. This trap fires whenever: - A handoff prompt or skill mixes ancestor-based checks with content-based checks for the same preservation claim - The handoff was written before the merge mode was decided - The verification template assumed fast-forward / no-ff (which preserves lineage) but the actual merge used squash ## Context / Trigger Conditions You're in the right place if **all** of these are true: - A propagation-style PR (`branch-A → branch-B`, often 20+ commits ahead) has recently merged - The verification step instructs you to check `git merge-base --is-ancestor <upstream-sha> <target-branch>` AND content-level probes (line counts, grep ref counts, file existence) - The ancestor probe returns non-zero / "MISSING" / exit 1 - The content probes all PASS (line counts match, ref counts ≥ expected, signature files exist) - The PR's merge method was **squash** (check via `gh pr view <N> --json mergeCommit,mergedBy` — the merge commit's parent commits are NOT the source branch's tip) The trap also applies in reverse direction: any check that asks "is this old commit in our new history?" will fail for squash workflows. ## Solution ### Step 1 — Confirm the merge was a squash ```bash gh pr view <PR-N> -R <owner>/<repo> --json mergeCommit # Inspect: the merge commit has ONE parent (the previous target HEAD), # NOT two parents (target HEAD + source branch tip). One-parent = squash; # two-parents = no-ff merge. ``` Or in git: ```bash git log -1 --format='%P' <merge-sha> # One SHA printed = squash or fast-forward # Two SHAs printed = no-ff or merge commit ``` If two-parent (no-ff/merge), `is-ancestor` SHOULD work — diagnose further (maybe the SHA is wrong, or the target branch is stale). ### Step 2 — Replace ancestor checks with content-level checks For the same preservation claim, swap to content probes: ```bash # File existence + line count for f in path/to/sig1.py path/to/sig2.py; do echo -n "$f: " git show origin/main:$f 2>/dev/null | wc -l done # Expect known line counts from the upstream work # Grep ref count git show origin/main:path/to/consumer.py | grep -c "<upstream-symbol>" # Expect >= N from the upstream registration # Byte-identical SHA on a known block (strongest proof — see # `byte-identical-sha-gate-as-refactor-safety-net`) for branch in origin/main <source-branch-pre-merge>; do git show $branch:path/to/registry.py | sed -n '<start>,<end>p' | sha256sum done # Two matching SHAs = block-level byte preservation proven ``` ### Step 3 — Update the verification template If a handoff prompt, runbook, or skill recommended the ancestor check unconditionally, fork the recommendation on merge mode: ```markdown **Verification depends on merge mode:** - Fast-forward or no-ff merge → `git merge-base --is-ancestor <sha> <target>` - **Squash merge** → ancestor check ALWAYS fails by design. Use content probes: file existence, line counts, grep ref counts, byte-identical SHA on signature blocks. - Rebase merge → ancestor fails on original SHA but rebased equivalent may exist; use `git log --cherry-mark <target>...<source>` for definitive answer. ``` ### Step 4 — When squash is the team default, default to content checks If your team uses squash merge as the policy default (GitHub's "Squash and merge" button), pre-write preservation audits using content checks only. Don't write `is-ancestor` into the audit at all — it'll be wrong every time. ## Verification A correct audit after squash propagation produces: ``` === Katie preservation check === (a) ancestor check: SKIP (squash merge — see L-256) (b) signature file line counts on origin/main: google_trends_covariates.py: 703 ← matches expected weather_covariates.py: 271 ← matches expected validation_tests.py: 303 ← matches expected (c) brand-share daily ref count on origin/main: 9 ← ≥ 5 expected === Verdict: content preserved === ``` If `is-ancestor` is still in the script, mark it as expected-to-fail with a comment, or delete it. ## Example Session 72 (2026-05-27) propagated `chore/promote-release-uk-to-main → main` via squash-merge (PR #146 → commit `abe1cad7`). The S72 prompt included this verification block: ```bash git merge-base --is-ancestor 0b6303e76 HEAD && echo "OK" || echo "MISSING" wc -l webapp/google_trends_covariates.py # expect 703 wc -l webapp/weather_covariates.py # expect 271 wc -l webapp/validation_tests.py # expect ~303 grep -c "google_trends_brand" webapp/research_utils.py # expect >= 5 ``` Post-merge result: - Ancestor check: **MISSING** (0b6303e76 not in origin/main's history) - Line counts: 703 / 271 / 303 ✓ - grep ref count: 9 ≥ 5 ✓ Initial reaction was confusion — was Katie's work clobbered? No. The squash merge collapsed all 35 source-branch commits into the single new SHA `abe1cad7`. Content preserved; lineage rewritten. The ancestor probe was the wrong tool. Recovery: trust the content checks, update the verification template (this skill + L-256 in lessons.md). ## Notes - **Sister skill**: `byte-identical-sha-gate-as-refactor-safety-net` — when you want a stronger proof than line counts (block-level SHA equality between source and target). - **Related**: `pre-merge-client-variant-regression-audit` Step 5 already uses byte-identical SHA for the right reason (regression detection on enrichment registration blocks). Apply the same content-only discipline to post-merge preservation audits. - **Related**: `gh-squash-merge-closes-only-one-issue` — another squash-merge surprise (closes only one `Closes #N` issue per PR body). - The bug isn't `is-ancestor` — it's mixing it with content checks under a single preservation claim without specifying merge mode. Either probe is fine when scoped to its merge-mode assumption. - Some teams use `gh api PUT` with `merge_method=squash` to merge from worktree contexts (per `gh-pr-merge-worktree-checkout-trap`); when that's your default merge call, your verification templates MUST assume squash. ## References - Git docs: [`git merge-base`](https://git-scm.com/docs/git-merge-base) — documents that `--is-ancestor` returns 0/1 based on commit-DAG reachability, not content equivalence. - GitHub docs: [About merge methods](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/about-merge-methods-on-github) — squash merge collapses commits into one; "the commits on the head branch are not preserved in the history." - Project memory: `~/.claude/projects/-Users-<user>-Documents-the-causal-impact-repo/memory/lessons.md` L-256.