docs-branch-off-feature-branch-smuggles-code · v1.2.0 · 2026-09-17 · sha256 c4d55ac3ff8ca35c
docs-branch-off-feature-branch-smuggles-code v1.2.0A
Immutable. This exact content never changes and is served at /api/v1/blob/c4d55ac3ff8ca35c.
--- name: docs-branch-off-feature-branch-smuggles-code description: | Catch the bug class where a "docs follow-up" PR silently ships the parent feature's code under a `docs(sN):` title because the docs branch was created from the current working branch (a feature branch), not from `origin/main`. Use when: (1) you just opened a PR titled `docs(...)` or `chore(...)` after wrapping up a feature session, (2) `gh pr diff <N> --name-only` shows files OUTSIDE `docs/` (source code, Dockerfile, tests, generated artefacts), (3) `git log origin/main..HEAD` shows TWO+ commits where you only authored one or two docs commits, (4) the surprise commit author/message is from your own earlier feature work on the parent branch, (5) the PR body claims "docs-only" but the actual diff carries code. Trigger surface: end-of-session handoff workflows that `git checkout -b docs/sN-handoff` from inside a feature worktree that's still on `feat/sN-feature`. Squash-merging such a PR auto-promotes the feature code under a "docs" title — bypassing the planned merge-and-deploy gate for the feature PR, and silently deploying if cloudbuild.yaml auto-fires on main push. Sister to `stale-base-pr-silently-reverts-upstream-content` (which is about PR-vs-PR overlap on the same files); this skill covers the branch-base trap that smuggles ENTIRE commits into the docs PR. Sister to `pr-hijack-via-stale-worktree-branch-ref` (which is about worktree branch-ref staleness); this skill covers the orthogonal "wrong base at branch-creation time" trap. Detection: code-reviewer agent or a `gh pr diff --name-only | grep -v '^docs/'` check before squash-merge. Recovery: rebase `--onto origin/main feat/sN-feature docs/sN-handoff` to drop the smuggled commits, force-push. author: Claude Code version: 1.2.0 date: 2026-09-17 disable-model-invocation: true --- # Docs follow-up branch off a feature branch smuggles feature code ## Problem You finish a feature session, the work lives on `feat/sN-feature` (PR already open). You want to write a session handoff + the next-session prompt as a docs-only PR. You run: ```sh git checkout -b docs/sN-handoff-and-sN+1-prompt # ...write docs, commit, push, gh pr create... ``` The new branch was created from the current `HEAD` — which is still `feat/sN-feature`'s tip. The new docs branch carries the feature commit AND your docs commits. The PR title is `docs(sN+1):...`. The diff includes Dockerfile + src/routes/*.py + tests/*.py from the feature work. If a reviewer squash-merges the "docs" PR, you've just landed the feature code under a docs title — bypassing the planned merge order, potentially auto-deploying if cloudbuild.yaml triggers on main push, and definitely confusing anyone who reads the git log later. ## Context / Trigger Conditions There are three variants. A and B differ by whether the feature PR is still open or was already squash-merged; C is the case where the branch is not yours at all: ### Variant A — Feature PR still open (silent code smuggling) You are in this trap when **all** of these hold: 1. **End-of-session handoff workflow.** You're wrapping up a session that produced code changes (already on a feature branch with an open PR) and you're about to write the handoff doc + next-session prompt as a separate PR. 2. **You created the docs branch via `git checkout -b` from inside the feature worktree** without specifying an explicit base. Most common variant: you're sitting on `feat/sN-feature` and run `git checkout -b docs/sN-handoff` — the new branch points at `feat/sN-feature`'s tip. 3. **`gh pr diff --name-only` shows non-`docs/` files.** Code, Dockerfile, tests, lock files, generated artefacts. The docs PR title says it ships docs but the diff contradicts. 4. **`git log origin/main..HEAD` shows more commits than you authored this session for docs.** The extra commit(s) are from your own feature work, carried over by the implicit base. 5. **PR title prefix is `docs(...)` or `chore(...)`.** This is the signal that maximises the trap's damage — reviewers may assume the PR can be squash-merged without scrutiny because "it's just docs". ### Variant B — Feature PR already squash-merged (CONFLICTING state) This variant occurs when the feature PR **has already merged to main via squash**. The local feature branch tip (`feat/sN-feature`) has a different SHA than the squash commit that landed on main — so creating a docs branch from it diverges from main immediately. Symptoms differ from Variant A: - GitHub shows the PR as **`CONFLICTING`** (cannot merge cleanly) - The PR diff lists **many files** (all the feature files + the docs files), not just docs - `git merge-base HEAD origin/main` returns an ancestor SHA well before the feature merge - The code-reviewer agent report says something like "PR lists 18 files including production code" even though you only committed 2 This variant is actually LESS dangerous than Variant A (the feature code is already on main, so squash-merging the docs PR wouldn't re-deploy it — it would just create a conflict), but it still blocks the docs PR from merging cleanly and requires a fix. ### Variant C — Someone else named the branch, and you are the one opening the PR **Added 2026-09-17.** A peer or coordinator hands you an instruction of the form *"push `<branch>` (`<sha>`) and open a PR titled `<title>`"*. You did not create the branch and you did not write most of what is on it. Both of the checks above still apply, and now nothing in your own memory disagrees with the answer — the diff looks unfamiliar because it IS unfamiliar, which is exactly the signal Variants A and B rely on. You are in this variant when: 1. **The branch name, the head SHA and the PR title all arrived in a message**, rather than from work you did. A message can be precise about the head and silent about everything beneath it, and usually is — naming a tip is not describing a stack. 2. **The commits under the tip were never pushed anywhere.** `git log origin/main..<branch>` returns several commits and only the last one or two match what you were asked to ship. Nothing upstream has ever seen the rest. 3. **Files outside the change you were briefed on appear in `gh pr diff --name-only`** — most dangerously production-path code under a title describing a doc, a test or a data artefact. **The damage is different from A and B, and worse in one specific way: your PR becomes the publication point for code nobody reviewed.** In Variant A you are re-shipping your own code that already has an open PR; in Variant B it is already on main. Here the stack underneath has no PR, no CI history and no reviewer, and opening yours is the first time any of it is proposed for the trunk — under a title that describes only the tip, with you as the author of record. **A peer's instruction is a claim, not a diff.** Run the same two commands you would run on your own branch, and if they disagree with the brief, say so and ask before pushing rather than trimming the branch yourself — it is not your branch to rewrite, and the peer may be relying on the stack being there. ## Diagnostic — pre-merge Run BEFORE squash-merging any docs-titled PR: ```sh # 1. Confirm the PR is truly docs-only. gh pr diff <N> --name-only | grep -v '^docs/' && echo "TRAP: code in docs PR" # 2. Inspect actual commits relative to main. git fetch origin main git log origin/main..HEAD --oneline # Should be only your docs commits. If you see commits authored on the # feature branch (their SHAs match `git log feat/sN-feature` output), # the docs branch was implicitly stacked. # 3. If you're already on the docs branch in a worktree: git merge-base HEAD origin/main # This should equal `git rev-parse origin/main` if the branch is clean. # If it equals an older commit, the branch is based on something # older than main — possibly the feature branch's base. ``` ## Solution ### Variant A recovery — rebase onto main The standard fix is `git rebase --onto`: ```sh # Form: git rebase --onto <new-base> <old-base> <branch> # In English: "take <branch>, drop everything since <old-base>'s tip, # and replant the remainder onto <new-base>." git rebase --onto origin/main feat/sN-feature docs/sN-handoff # Verify only your docs commits remain. git log origin/main..HEAD --oneline # Verify the diff is now docs-only (3 dots: what the branch PROPOSES, # measured from the merge-base, which is what GitHub's diff shows). git diff --name-only origin/main...HEAD # Force-push (use --force-with-lease to refuse if someone pushed # concurrently — saves you from clobbering review comments etc.). git push --force-with-lease ``` After force-push, `gh pr diff <N> --name-only` will refresh on the next call and show only the docs files. ### Variant B recovery — fresh branch + git show (faster when feature already merged) When the feature is already on main, rebasing is unnecessary. It's faster to close the conflicting PR and create a clean branch: ```sh # 1. Close the conflicting docs PR. gh pr close <N> --comment "Closing — branch based on pre-squash feature tip. Re-opening clean." # 2. Create a fresh branch off origin/main. git fetch origin main git checkout -b docs/sN-handoff-clean origin/main # 3. Extract only the docs files from the old branch — no code included. git show docs/sN-handoff:docs/handoffs/session_N_handoff.md \ > docs/handoffs/session_N_handoff.md git show docs/sN-handoff:docs/handoffs/session_N+1_prompt.md \ > docs/handoffs/session_N+1_prompt.md # 4. Commit and push. git add docs/handoffs/session_N_handoff.md docs/handoffs/session_N+1_prompt.md git commit -m "docs(sN): session handoff ..." git push -u origin docs/sN-handoff-clean # 5. Open the new PR. gh pr create --title "docs(sN): ..." --body "..." ``` Verify the new PR diff is docs-only: ```sh gh pr diff <new-N> --name-only | grep -v '^docs/' && echo "TRAP" || echo "clean" ``` **Optional: refresh the PR body** if it had any "ships X, Y, Z" claims that the original (stacked) state contradicted. Per the PR-description-refresh feedback rule. ## Prevention Two preventive patterns: ### Pattern A: explicit `origin/main` base at branch creation ```sh # DON'T: implicit base = current HEAD (the feature branch's tip) git checkout -b docs/sN-handoff # DO: explicit base git checkout -b docs/sN-handoff origin/main ``` This is the cheapest prevention and works in any worktree. ### Pattern B: dedicated worktree off main for docs work ```sh # DON'T: write docs from inside the feature worktree cd .claude/worktrees/feat-sN git checkout -b docs/sN-handoff # implicit feature-branch base — trap # DO: spin a fresh worktree pinned to origin/main git worktree add /absolute/path/.claude/worktrees/sN-docs \ -b docs/sN-handoff origin/main ``` Worktrees enforce branch isolation. The docs worktree starts on `origin/main`, period. Sister skill: `feedback_worktree_add_absolute_paths` (use absolute paths for `git worktree add` to defeat Bash cwd persistence). ### Pattern C: PR-body sanity check before opening the PR ```sh # Run this as part of any handoff PR workflow: gh pr diff <N> --name-only | sort | tee /tmp/pr-files.txt case "$(cat /tmp/pr-files.txt | grep -v '^docs/')" in "") echo "PR is docs-only — safe to merge" ;; *) echo "ABORT: non-docs files in PR" && cat /tmp/pr-files.txt ;; esac ``` This is the cheapest detection at PR creation time. The `session-handoff` skill's Phase 4 step 22 already runs a `code-reviewer` agent that includes this check — but if you skip the agent for time pressure, run the grep manually. ## Verification After rebase + force-push: - `gh pr diff <N> --name-only` returns ONLY paths under `docs/` (or whatever your taxonomy is for the PR's intended scope). - `git log origin/main..HEAD --oneline` shows only the commits you authored this session for docs. - The PR's "Files changed" tab on GitHub shows the same — refresh if stale. - Squash-merge proceeds cleanly with the expected title. ## Example — 2026-06-23 the project S211 (Variant B: feature already merged) End of session S211 (per-record drawer behaviour filter, PR #1360 already squash-merged to main as `b5871bc0`). Wrote handoff + S212 prompt. Created the docs branch: ```sh git checkout -b docs/s211-session-handoff # HEAD was feat/s211-drivers-drawer-behaviour-filter's tip (pre-squash SHA) ``` `code-reviewer` agent caught it during the auto-merge review pass: > HIGH — Not "docs-only" and it won't merge as-is. PR lists 18 files > including production code (app.py, bq_queries.py, behaviour_taxonomy.py, > JS/CSS/HTML, tests). Branch was cut from `56e189f5` before `b5871bc0` > merged, so it carries a divergent duplicate of the shipped feature. > PR state = CONFLICTING/DIRTY. Recovery using the Variant B (fresh branch) approach: ```sh gh pr close 1366 --comment "Closing — branch based on pre-squash feature tip." git fetch origin main git checkout -b docs/s211-handoff-clean origin/main git show docs/s211-session-handoff:docs/handoffs/2026-06-23-s211-...-handoff.md \ > docs/handoffs/2026-06-23-s211-...-handoff.md git show docs/s211-session-handoff:docs/handoffs/2026-06-24-s212-...-prompt.md \ > docs/handoffs/2026-06-24-s212-...-prompt.md git add docs/handoffs/*.md git commit -m "docs(s211): session handoff ..." git push -u origin docs/s211-handoff-clean gh pr create --title "docs(s211): ..." # → PR #1370, merged cleanly ``` The reviewer also caught an internal-consistency error (§6 said `docs/s210-session-handoff` instead of `docs/s211-handoff-clean`) — fixed before the clean PR was merged. ## Example — 2026-05-27 brief-runner Session 20 End of session, sitting in `.claude/worktrees/first-delivery` on branch `feat/s20-wire-render-route` (PR #90, F10+F11 fix, ~600 LOC across Dockerfile + src/routes/render.py + tests/test_routes/test_render.py + docs/analysis/discovery_v2_e2e_smoke.md). Need to write the S21 prompt as a separate docs PR. ```sh git checkout -b docs/s20-handoff-and-s21-prompt # IMPLICIT BASE: feat/s20-wire-render-route # ...wrote session_21_prompt.md, committed, pushed, opened PR #92 # PR title: "docs(s21): session 21 prompt — deploy+verify, metrics, design sweep, polish" ``` `code-reviewer` agent caught it on the auto-merge review pass: > HIGH — PR is NOT docs-only. User briefed this as "ships two files" > but PR #92 actually ships 6 files including a 254-line rewrite of > `src/routes/render.py`, `Dockerfile`, and 161 lines of test > changes. Squash-merging would auto-deploy F10+F11 fix without the > explicit Task 1 live-verification step the S21 prompt mandates as > non-negotiable. Fix took 90 seconds: ```sh git rebase --onto origin/main feat/s20-wire-render-route \ docs/s20-handoff-and-s21-prompt # Successfully rebased and updated. git diff --name-only origin/main...HEAD # docs/handoffs/session_20_handoff.md # docs/handoffs/session_21_prompt.md ← only docs, as intended git push --force-with-lease # (forced update) → PR #92's diff refreshes to 2 files ``` Then squash-merged cleanly. The render.py / Dockerfile changes stayed on PR #90 where they belong, awaiting their own merge gate. ## Notes - **The trap is asymmetrically dangerous for docs PRs vs feature PRs.** A reviewer scrutinising a `feat(...)` PR will see the code diff and treat it as the feature. A reviewer scrutinising a `docs(...)` PR may auto-approve without reading the diff carefully. Squash-merging it auto-deploys if your CI is wired to deploy on main pushes. - **The trap is invisible to `gh pr view` if you only check title + body.** You MUST inspect `gh pr diff --name-only` or `git log origin/main..HEAD` to see it. - **`--force-with-lease` is the right force flag here.** Plain `--force` would overwrite any review comments + co-author commits; `--force-with-lease` refuses if the remote moved underneath you. - **Sister trap (different mechanism, similar damage):** `pr-hijack-via-stale-worktree-branch-ref` covers the case where a worktree's local branch ref drifts from origin and a `git push` re-uses the wrong ref. This skill covers the case where the branch was CREATED from the wrong base in the first place. ## References - Sister: `stale-base-pr-silently-reverts-upstream-content` — parallel-PR overlap on same files; different mechanism. - Sister: `pr-hijack-via-stale-worktree-branch-ref` — worktree ref drift; different mechanism. - Sister: `feedback_worktree_add_absolute_paths` — prevention via explicit worktree base. - `session-handoff` skill — a separate plugin, published at https://github.com/wan-huiyan/session-handoff — Phase 4 step 22, the `code-reviewer` agent pass that catches this trap if you skip the manual check.