| name | parallel-pr-template-fork-duplicates-moved-section |
| description | Diagnose silent semantic duplication after two parallel PRs ship. Use when:
(1) one PR (the **mover**) relocates a section/component/block from template
X to template Y (X loses it, Y gains it), (2) a sibling PR (the **forker**),
authored against pre-mover main, creates / promotes / copies template Z from
the OLD version of X (when X still contained the section), (3) both PRs
squash-merge without textual conflict because they touch different files,
(4) after both deploy, the section appears on BOTH the mover's destination
Y AND on the forker's route Z. Symptom is user-visible: "I see this radar /
card / nav block in two places, why?" Root cause is structural — git's
textual 3-way merge can't see that file Z ⊆ pre-mover-X. Fix: hand-delete
the section from one location (usually the forker's), update tests if any
asserted on the duplicate render. Prevention: when the mover merges first,
rebase the forker BEFORE squash and audit any moved sections; or add a
cross-route uniqueness test (`grep -c "<section-id>"` across primary
routes ≤ 1). Sibling to `route-orphan-fetch-after-template-carveout`
(covers within-PR template/handler drift; this skill covers across-PR
template duplication).
|
| author | Claude Code |
| version | 1.0.0 |
| date | "2026-05-07T00:00:00.000Z" |
| disable-model-invocation | true |
Parallel-PR Template Fork Duplicates Moved Section
Problem
Two PRs ship within hours of each other. Neither shows a textual merge
conflict. Both pass tests. Both deploy cleanly. After deploy, a user reports
"this section is appearing in two places now — that's wrong, right?"
The mechanism is structural, not textual:
- PR #A (mover): relocates a
<section>...</section> from template X.html
to template Y.html. X loses the block; Y gains it.
- PR #B (forker): promotes / copies / forks
X.html into a new template
Z.html (e.g., the route is moving up in the IA, or the page is being
split). The fork is literal — Z.html was authored as a near-copy of X.html
as it existed before the mover landed.
- The two PRs touch different files on disk. Squash-merge is "clean" by
git's textual measure:
X lost a block (mover), Y gained it (mover), Z
was added (forker). No 3-way merge conflict.
- Reality:
Z was forked from a snapshot of X that still contained the
section. So after both ship, the block lives in BOTH Y and Z.
The user sees the duplication immediately. Code review on each PR in isolation
wouldn't have caught it — each diff was correct against its own base.
Trigger conditions
ALL of these are typically true:
- Two PRs merged to main within a short window (hours / a day), authored
independently.
- One PR's diff includes lines that look like "move section from X to Y" —
classic markers: deletion in one template, equivalent insertion in another,
plus a comment saying "moved per IA brief §N" or similar.
- The other PR's diff includes "create new template Z" or "rename X → Z"
where the
+ lines for Z look very similar to (or are a literal copy of)
the pre-mover version of X.
- Neither PR's branch was rebased onto the other after both were opened.
- User-visible symptom: rendered HTML on two different routes contains the
same heading / chart / SVG / id (e.g.,
<h2>Cohort Fingerprint</h2> or
id="cohort-radar-chart" appears on /route-y AND /route-z).
- Tests still pass because each route's tests were authored to assert on
their own template; cross-route uniqueness wasn't part of the contract.
Diagnostic — confirm the duplication
SECTION_ID="cohort-radar-chart"
grep -rln "$SECTION_ID" path/to/templates/
python3 -c "
from app import app
with app.test_client() as c:
for route in ['/route-y', '/route-z', '/legacy-x']:
r = c.get(route)
n = r.data.decode().count('$SECTION_ID')
print(f'{route}: count={n}')
"
If grep shows the identifier in two templates AND two routes render it,
you've reproduced the duplication.
Confirm timing — was this really the parallel-PR pattern?
gh pr list --repo OWNER/REPO --state merged --limit 30 \
--json number,title,mergedAt,files \
--jq '.[] | "\(.mergedAt) #\(.number) \(.title)"' | head -20
The forker is usually the one that copy-pasted from a stale base. The mover
is usually first by merge timestamp (because the forker would have noticed
the conflict if it were the other way around).
Fix
Hand-delete the duplicate from one location. Usually the forker's
template — because the mover's destination is the canonical home of the
section per the new IA.
- <h2>Cohort Fingerprint</h2>
- <p>Compare up to six funnel-stage cohorts...</p>
- <div id="cohort-radar-chart" class="radar-chart"></div>
+ {# Section moved to <Y.html> by PR #A (#mover-issue). The earlier PR #B
+ (#forker-issue) inherited an older snapshot before the move — drop
+ the orphan section here so it lives only on the canonical route. #}
If any tests assert on the duplicate render (e.g., a test for /route-z
that calls assert "Cohort Fingerprint" in html), update them too.
If a downstream JS bootstrap (e.g., radar.js self-bootstraps on
document.getElementById('cohort-radar-chart')) was relying on the duplicate
for some reason, check that the JS still finds its target on the canonical
route only.
Verification
grep -rln "$SECTION_ID" path/to/templates/
python3 -c "..."
pytest tests/test_<route_y>.py tests/test_<route_z>.py
Prevention
Three layers, in order of cheapness:
-
Rebase the forker onto current main BEFORE squash-merging if any other
PR touched a related file in the interim. The squash-merge command's
"Mergeable" indicator is a TEXTUAL check; it does NOT detect
semantic-section duplication.
-
Add a cross-route uniqueness invariant test for any section/component
that has a single canonical home:
import pytest
PRIMARY_ROUTES = ["/", "/actions", "/drivers", "/monitor", "/library/explorer", ...]
UNIQUE_SECTIONS = {
"Cohort Fingerprint": "/library/explorer",
"Pipeline by Propensity": "/",
}
@pytest.mark.parametrize("title,canonical", UNIQUE_SECTIONS.items())
def test_section_appears_on_canonical_route_only(client, title, canonical):
hits = []
for route in PRIMARY_ROUTES:
if title in client.get(route).data.decode():
hits.append(route)
assert hits == [canonical], (
f"{title!r} should appear only on {canonical}, found on: {hits}"
)
This invariant catches the duplication at PR-CI time, regardless of which
PR gets merged first.
-
PR-author audit checklist when promoting / forking a template: search
the most recent N merges to main for any "move section X to file Y"
patterns. If any apply to the source template you're forking, port the
move forward in your PR.
Worked example (a client propensity dashboard, 2026-05-07)
Architecture:
the-dashboard-service Cloud Run service serves multiple dashboard routes.
- Sidebar IA went through Phase 5 (4 tabs) → Phase 6 (5 tabs: Overview /
Actions / Drivers / Monitor / Library) over ~24h via 4 PRs.
Two of those PRs collided semantically:
- PR #296 (mover, merged 2026-05-06 21:01 UTC): "Move Cohort Fingerprint
radar /library/methods → /library/explorer." Removed
<h2>Cohort Fingerprint</h2>
- radar
<div> from library_methods.html; added them to library_explorer.html.
- PR #303 (forker, merged 2026-05-06 23:48 UTC): "Promote Methods page to
top-level Drivers tab." Created new
drivers.html as a near-copy of the
pre-#296 library_methods.html (which still had the radar block).
Also flipped /library/methods to a 302-redirect handler (→ /drivers).
GitHub squash-merge of #303 reported "Mergeable" because the textual diff was:
+ templates/drivers.html (new file)
- {old library_methods.html content} / + {redirect handler} (in app.py)
~ templates/library_methods.html (other minor changes)
No textual collision with library_explorer.html (which #296 had modified).
Result: /library/explorer shows the radar (PR #296 ✓), and /drivers ALSO
shows the radar (PR #303's literal copy of pre-#296 library_methods.html).
User-visible: "this looks nothing like the mockup; I see Cohort Fingerprint
twice." Tests stayed green because each route had its own test file and
neither asserted cross-route uniqueness.
Diagnostic:
grep -rln 'cohort-radar-chart' <analytics_pkg>/cloudrun/<dashboard_app>/templates/
Render confirmation:
DASHBOARD_USE_MOCK=true python3 -c "
from app import app
with app.test_client() as c:
for r in ['/drivers', '/library/explorer']:
h = c.get(r).data.decode()
print(r, 'count:', h.count('cohort-radar-chart'))
"
Fix: deleted the radar <div> block from drivers.html (lines 90-103),
replaced with a {# moved to /library/explorer per IA brief / PR #296 #}
comment. Drive-by: updated test_library_methods_does_not_call_get_cohort_fingerprint
to expect the post-#303 redirect-shape (302 → /drivers) instead of the stale
200 it had.
Shipped as PR #317 (cat7-7dq), squash-merged + redeployed pulse rev
00010-bt2. Post-fix render: /drivers count = 0; /library/explorer
count = 1.
Notes
- Why git can't catch this: 3-way merge operates on file-level diffs.
When PR #B forks file X to a new file Z, git's view is "Z is a new file";
it doesn't know Z's content was copy-pasted from a snapshot of X. So when
the merge base of PR #A doesn't intersect with Z's lifetime, there's no
diff to conflict on. Fundamentally a limitation of textual diff vs.
semantic equivalence.
- Most fragile during IA reorganizations: the duplication risk is highest
when multiple PRs are reshaping the navigation/IA in parallel — each PR
thinks it owns "the" canonical home for a component, and the timing of
who-merges-first determines which view wins.
- Squash-merge intensifies the problem: a non-squash merge would carry
forward each constituent commit, and the rebase step before merge would
surface conflicts. Squash-merge collapses everything into a single textual
diff at merge time, making the semantic comparison even harder.
- Code review caveat: each PR's reviewer is looking at THAT PR's diff
against THAT PR's base. There's no point in the review process where both
PRs' diffs are inspected together — that's exactly when semantic
duplication would be visible.
- Don't blame the squash-merge feature: the same pattern can happen with
rebase-and-merge or merge-commit strategies. The root cause is "fork from
stale base, sibling PR moves something out of the fork's source." Squash
just makes detection harder.
Sister skills
route-orphan-fetch-after-template-carveout — within-PR drift: a section
was deleted from a template but the handler still fetches its data. This
skill covers the across-PR equivalent where the section was duplicated
rather than orphaned.
jinja-tojson-undefined-after-refactor — the opposite within-PR direction:
template ref survives, kwarg removed → render fails.
pr-conflict-from-mid-flight-merges — the textual sibling: two PRs touch
the same lines and git flags mergeStateStatus: DIRTY. This skill
documents the silent (non-textual) failure mode of the same scenario.
pr-conflict-site-regen — project-specific playbook for resolving
TEXTUAL parallel-PR conflicts on auto-generated docs. Doesn't catch
semantic duplication.
References