docs-branch-off-feature-branch-smuggles-code · v1.1.2 · 2026-08-11 · sha256 d756add9dc955d4b
docs-branch-off-feature-branch-smuggles-code v1.1.2A
Immutable. This exact content is served forever at /api/v1/blob/d756add9dc955d4b.
--- 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.1.2 date: 2026-06-23 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 two variants with different symptoms depending on whether the feature PR is still open or was already squash-merged to main: ### 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. ## 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.