Skip to content

Commit 7888c76

Browse files
theCodeDriftclaude
andcommitted
docs: correct the stacked-PR guidance for a rebase-only repository
`main` keeps a linear history now, so the repository allows rebase-and-merge only — squash and merge-commit are both disabled. Three pieces of guidance were wrong under that, and two of them fail quietly. "Use merge-commit, not squash, for a stack" recommended `gh pr merge --merge`, which now errors. The advice it carried is also unavailable rather than merely unfashionable: rebase replays a branch onto main as new commits, so every child of a merged branch has diverged history and must be rebased and force-pushed between merges. That is the expensive path, and there is no cheaper one to prefer. The stack-health check was unsound. `git merge-base --is-ancestor` asks whether the tip's commits are ancestors of the branch that absorbed them, and after a rebase merge they never are — it reports STRANDED for a stack that landed perfectly. Replaced with a content diff, and the healthy state named explicitly, because an empty diff over differing SHAs looks alarming and is not. Records two things measured while landing #153 and #154: branch protection is strict, so the next PR reads `mergeable_state: "behind"` until rebased, and `rebaseable` reads `null` for a while after each merge while GitHub recomputes — reading that null as "not mergeable" is what stranded commits mid-stack before. Also records that GitHub does not re-sign what it rebases, so every commit on main reports `N` despite `-S` being mandatory locally. Worth knowing in both directions: on main it is expected, and before main it means the flag was missed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms
1 parent 5d4ed4f commit 7888c76

1 file changed

Lines changed: 44 additions & 5 deletions

File tree

‎CLAUDE.md‎

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -147,21 +147,60 @@ Merge each PR **down** into its parent's branch, from the tip to the bottom:
147147
- Bring the bottom branch up to date with `main`, let `Validate` pass, then do the **single** protected merge to `main`.
148148
- Result: one CI cycle instead of N, and every PR gets a real **Merged** badge (not "closed/absorbed").
149149

150-
**Merge the down-merges one at a time, not in a loop.** Merging a child immediately invalidates the parent PR's mergeability (`gh pr merge` fails with "Pull Request is not mergeable") until GitHub recomputes. In a tight loop this makes merges land **out of order**, which strands the tip's commits part-way down the stack (e.g. `skill`/`eval` never propagate past `help`). Merge each PR, wait a few seconds, confirm the next is `MERGEABLE`, then continue. After the down-merges, verify the bottom branch actually contains the tip before the final merge:
150+
**Merge the down-merges one at a time, not in a loop.** Merging a child immediately invalidates the parent PR's mergeability until GitHub recomputes — `gh pr merge` fails with "Pull Request is not mergeable", and the API reports `rebaseable: null`. In a tight loop this makes merges land **out of order**, which strands the tip's commits part-way down the stack (e.g. `skill`/`eval` never propagate past `help`). Merge each PR, wait for the next to report a boolean `rebaseable`, then continue.
151+
152+
**Verify by content, not by ancestry.** Rebase-and-merge replays commits under new SHAs, so the tip's original commits are never ancestors of the branch that absorbed them, and the obvious check reports a false `STRANDED`:
151153

152154
```bash
153-
git merge-base --is-ancestor origin/<tip-branch> origin/<bottom-branch> && echo "OK" || echo "STRANDED"
155+
# WRONG under rebase-and-merge — fails even when everything landed
156+
git merge-base --is-ancestor origin/<tip-branch> origin/<bottom-branch>
157+
158+
# Right: ask whether the content differs
159+
git diff --stat origin/<bottom-branch> origin/<tip-branch> # empty = fully absorbed
154160
```
155161

156-
If stranded, reconcile from the tip (a tip branch contains the entire stack): on the bottom branch, `git merge origin/main` then `git merge origin/<tip-branch>`, confirm the only diff vs. the tip is whatever landed on `main` separately, and push.
162+
An empty diff with differing SHAs is the *expected* healthy state after a rebase merge, not evidence of a problem. If the diff is genuinely non-empty, reconcile from the tip — a tip branch contains the whole stack — then re-check the diff and push.
157163

158164
### Never `--delete-branch` mid-stack
159165

160166
`gh pr merge <n> --delete-branch` on a stacked PR **closes the child** PR (its base branch vanishes) instead of retargeting it. Leave branches in place during the stack; clean them up only after the whole stack has landed.
161167

162-
### Use merge-commit, not squash, for a stack
168+
### Rebase is the only merge method, and a stack pays for it
169+
170+
`main` keeps a linear history, so the repository allows **rebase-and-merge only** — squash and merge-commit are both disabled. Confirm rather than assume, since this changed:
171+
172+
```bash
173+
gh api repos/{owner}/{repo} --jq '"squash=\(.allow_squash_merge) merge=\(.allow_merge_commit) rebase=\(.allow_rebase_merge)"'
174+
# squash=false merge=false rebase=true
175+
```
176+
177+
`gh pr merge --merge` and `--squash` both fail. Use `gh pr merge <n> --rebase`.
178+
179+
**This is the expensive case for a stack, and there is no cheaper option available.** Rebase-and-merge replays the branch onto `main` as *new commits with new SHAs*. Every child then contains the pre-rebase versions of its ancestors' commits, so the child is not merely behind — its history diverged. After each merge you must rebase the next branch onto the updated `main` and force-push it. The old guidance to prefer merge-commits so children stay clean no longer applies; that door is closed.
180+
181+
Practically, landing a stack now looks like:
182+
183+
```bash
184+
gh pr merge <bottom> --repo <owner>/<repo> --rebase
185+
git fetch origin
186+
git rebase -S origin/main # in the next branch's worktree
187+
git push --force-with-lease=<branch>:$(git rev-parse origin/<branch>)
188+
```
189+
190+
Two things that will bite:
191+
192+
- **Branch protection is `strict_up_to_date: true`,** so the next PR reports `mergeable_state: "behind"` until you rebase it. That is not a conflict; it is the protection asking for the rebase you owe it.
193+
- **Right after a merge, `rebaseable` reads `null`** while GitHub recomputes. Poll until it is a boolean rather than treating `null` as "not mergeable" — reading it as a failure is what stranded commits mid-stack before.
194+
195+
### Rebase-and-merge lands unsigned commits on `main`
196+
197+
Commits are signed locally (`git commit -S`, mandatory above), but **GitHub rewrites them when it rebases, and does not re-sign.** Every commit on `main` reports `N`:
198+
199+
```bash
200+
git log --format='%G? %h %s' -5 origin/main # N, N, N, …
201+
```
163202

164-
Stacked branches share commits (each child contains its ancestors). Prefer explicit `gh pr merge --merge` — `--merge` keeps children clean, while squash rewrites the parent into a new commit the children don't have, forcing a manual `git merge origin/main` reconciliation on every child between merges and inviting phantom conflicts.
203+
Nothing is wrong and nothing needs fixing on `main`. Know it so that `%G?` on a merged commit is not mistaken for a signing failure, and so a fresh commit reading `N` **before** it reaches `main` is recognised as the real problem it is — that one means `-S` was missed.
165204

166205
### Recovery if a child PR gets closed by base-branch deletion
167206

0 commit comments

Comments
 (0)