Skip to content

Branch hygiene: poisoned first-commit — OBJ-1766 post-mortem

A reference card for why a clean checkout matters before cutting a new phase branch, and why conflict-scoped diffing can't be trusted to catch what it doesn't look for.

Incident: phase-theme-creator-custom-colors (PR #1790) squash-merged to master (7910d9a95) carrying the entire, still-unmerged gsd/v4.14-activity-nexus-connected-apps tree (206 files changed vs. its parent, 144 of them present only on that unmerged branch) alongside its intended ~29-file Theme Creator diff. The unwired STRAVA_CLIENT_ID/STRAVA_CLIENT_SECRET/STRAVA_REDIRECT_URI references hard-failed staging.yml's deploy-lint gate, skipping Rails API deploy, Frontend deploy, and both Playwright smoke suites for the whole team until an emergency revert (b4ead33e7, PR #1795). The feature was re-shipped clean as PR #1796 (f86e371db, 29 files, zero overlap).

The crew's working theory was wrong

The crew spent roughly three hours diagnosing and fixing a "master-drift saga" — three real merge-conflict rounds as master churned underneath the branch, including one round a human drove directly after an escalated bounce-loop. That work was real, but it wasn't the source of the contamination.

The branch's own commit graph (801a22f57, the branch's very first commit — docs(planning): Theme Creator phase plan — custom Supporter colors (OBJ-1758)) predates the master-drift saga by roughly 20 hours, which already rules that saga out as the entry point. But the specific mechanism first suspected — a dirty workdir whose staged/on-disk files got swept into 801a22f57 by a broad git add — does not hold up either: git diff --stat 87e326edb0 801a22f57 shows exactly 2 files changed, 251 insertions, 0 deletions (CONTEXT.md + PLAN.md, both legitimate Theme Creator planning docs). 801a22f57 itself is clean against its own parent.

Root cause

The problem is one level up: 801a22f57's parent, 87e326edb0 (docs(planning): tick Phase 5 complete (Task 5 shipped), dispatch Phase 6), is not a master commit at all. Walking its own history shows a chain of v4.14 Activity-Nexus/Connected-Apps planning commits, including 64c49f2e2 (Merge remote-tracking branch 'origin/master' into agent/codi/1362c63d) a few commits back — and git merge-base 87e326edb0 origin/gsd/v4.14-activity-nexus-connected-apps resolves to f513307c0, a shared ancestor deep inside that integration branch's own lineage. 87e326edb0 is not an ancestor of origin/master at all (git merge-base --is-ancestor 87e326edb0 origin/master is false; the real merge-base is 0d82472b3, several commits further back).

In other words: whoever cut phase-theme-creator-custom-colors didn't commit onto a dirty-but-otherwise-master workdir — they branched from the wrong ref entirely, a local checkout or branch (agent/codi/1362c63d) still sitting on the unmerged Activity Nexus integration line, not from origin/master. 801a22f57 is clean relative to that ref — the contamination was already the entire tree of the ref it branched from. Every one of the branch's three subsequent "merge origin/master" commits (6b97041b, 9b594b5a8, 758ed3a20) then merged real, legitimate master commits on top of that wrong starting point — additively, with no conflict, since the incoming master files never overlapped with the Activity-Nexus tree already present. None of those merges could have surfaced the mistake; they were all correct merges of a branch that was wrong from its very first commit.

The full squash-merged diff that landed on master (7910d9a95 vs. its parent fd6d83e81) touched 206 files, 144 of which were confirmed (via scripts/check-pr-scope.test.mjs, which replays this exact diff) to exist only on the unmerged gsd/v4.14-activity-nexus-connected-apps branch.

Every subsequent step — the three conflict-resolution rounds, a manual rebase, a UI-spec branch created off the already-poisoned commit, and two rounds of code review — inherited this wrong base and had no way to catch it.

Gotcha — conflict-scoped diffing structurally cannot detect non-conflicting large additions

Applies to: any merge/rebase conflict-resolution workflow, and any code review that treats "the merge was clean" as evidence the diff is scoped to the intended change.

git merge-tree and textual conflict resolution — what every tool in this incident used, including the reviewer — only surface textual overlaps. A file that exists cleanly on the incoming branch with no corresponding change on the target branch produces no conflict at all, merges silently, and is indistinguishable from an intentional addition unless someone runs an unscoped git diff --stat <target>...<branch> and actually reads the file count.

"Clean merge, N files conflicted" is not the same claim as "the branch's total diff is scoped to what this phase intended." Multi-round conflict firefighting is especially good at masking this, because every round's attention is on the files that did conflict — the 144 leaked files that didn't were never in view. This poisoned base survived 12+ hours and three conflict-resolution rounds undetected for exactly this reason.

How to confirm this is the real cause, next time: if a branch's total file count looks high relative to its declared scope, don't rely on "the merge resolved cleanly" as reassurance — run git diff --stat <target>...<branch> unscoped and check whether the file count and the touched paths match what the phase actually intended.

Fix pattern

See CONTRIBUTING.md § Branch Creation Hygiene for the concrete pre-branch checklist (verified-clean git status --porcelain, git reset --hard origin/master, or a fresh worktree/clone instead of a reused workdir).

This incident is also why CLAUDE.md's PR-scope gotcha and the pr-scope-guard.yml CI check (backed by scripts/check-pr-scope.mjs) exist: a mechanical backstop that hard-fails a PR whose changed files overlap an unmerged long-lived gsd/* integration branch, since the hygiene practice alone depends on every branch being cut correctly every time.

  • CONTRIBUTING.md § Branch Creation Hygiene — the pre-branch checklist this incident motivated
  • CLAUDE.md § Common Gotchas — PR-scope gotcha — the in-code/in-workflow pointer target
  • .github/workflows/pr-scope-guard.yml + scripts/check-pr-scope.mjs — the CI guard closing this gap (OBJ-1767)
  • docs/development/smoke-test-stale-branch-gotcha.md — a related but distinct pattern: a branch stale behind master causes unrelated test failures, rather than a branch poisoned with unrelated content
  • OBJ-1766 — this incident's root-cause investigation (Dave's diagnosis, comment dd102328-f939-40fb-b7d0-5ce616ed5f88). Note: this page's Root cause section supersedes that comment's specific commit citation — the diagnosis cited a git diff --stat between 801a22f57 and an unrelated commit (7db79befc, since identified as PR #1787, no relation to this branch); the verified root cause is 801a22f57's parent (87e326edb0) sitting on the unmerged Activity Nexus integration line rather than master, confirmed directly against this repo's git history during OBJ-1768's review round.
  • OBJ-1767 — the PR-scope guard that shipped as the mechanical backstop (PR #1797, 368f3dbb2)
  • Reverted PR: #1790
  • Revert PR: #1795
  • Clean re-ship: #1796

Last updated: 2026-07-26 (OBJ-1768 R2: corrected the root-cause commit citation and file counts after Roy's review found the original claim didn't hold up against git diff --stat)

Loading…