From a599d8732e803dfef9be80c9581802ce3f3254f3 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 24 Aug 2026 08:49:33 -0700 Subject: [PATCH 1/2] docs: correct the stacked-PR guidance for a rebase-only repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms --- CLAUDE.md | 49 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7ef0ea6d..4ed9cb1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,21 +147,60 @@ Merge each PR **down** into its parent's branch, from the tip to the bottom: - Bring the bottom branch up to date with `main`, let `Validate` pass, then do the **single** protected merge to `main`. - Result: one CI cycle instead of N, and every PR gets a real **Merged** badge (not "closed/absorbed"). -**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: +**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. + +**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`: ```bash -git merge-base --is-ancestor origin/ origin/ && echo "OK" || echo "STRANDED" +# WRONG under rebase-and-merge — fails even when everything landed +git merge-base --is-ancestor origin/ origin/ + +# Right: ask whether the content differs +git diff --stat origin/ origin/ # empty = fully absorbed ``` -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/`, confirm the only diff vs. the tip is whatever landed on `main` separately, and push. +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. ### Never `--delete-branch` mid-stack `gh pr merge --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. -### Use merge-commit, not squash, for a stack +### Rebase is the only merge method, and a stack pays for it + +`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: + +```bash +gh api repos/{owner}/{repo} --jq '"squash=\(.allow_squash_merge) merge=\(.allow_merge_commit) rebase=\(.allow_rebase_merge)"' +# squash=false merge=false rebase=true +``` + +`gh pr merge --merge` and `--squash` both fail. Use `gh pr merge --rebase`. + +**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. + +Practically, landing a stack now looks like: + +```bash +gh pr merge --repo / --rebase +git fetch origin +git rebase -S origin/main # in the next branch's worktree +git push --force-with-lease=:$(git rev-parse origin/) +``` + +Two things that will bite: + +- **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. +- **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. + +### Rebase-and-merge lands unsigned commits on `main` + +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`: + +```bash +git log --format='%G? %h %s' -5 origin/main # N, N, N, … +``` -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. +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. ### Recovery if a child PR gets closed by base-branch deletion From de4ea5debacadfb9434dc8944411dcb25707a3e8 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 24 Aug 2026 13:27:16 -0700 Subject: [PATCH 2/2] docs: fix the branch-recovery procedure, which assumed merge commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught that the previous commit corrected three stale sections and left a fourth. "Recovery if a child PR gets closed by base-branch deletion" still resolved the deleted branch tip through `$MERGE_SHA^2`, and `^2` requires the merge commit to have two parents. Rebase-and-merge replays a branch as linear single-parent commits, so on this repository — rebase-only — that step fails with "unknown revision" every time. Replaced with `refs/pull//head`, which GitHub keeps after both the merge and the branch deletion. Verified against #153, whose branch is gone: the ref still resolves to 85c0a16, its pre-merge tip, and fetches. That form is correct under every merge method, which is a better reason to prefer it than "the other one is broken here". Also makes the force-push snippet copy-pasteable — it relied on unstated upstream tracking — and records that a `stale info` rejection usually means the lease SHA is out of date rather than the clone being shallow, since the two failures look identical and the shallow-clone note is right above. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Cyga14bww8rmazH2XrF8ms --- CLAUDE.md | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4ed9cb1a..4240e622 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -184,13 +184,14 @@ Practically, landing a stack now looks like: gh pr merge --repo / --rebase git fetch origin git rebase -S origin/main # in the next branch's worktree -git push --force-with-lease=:$(git rev-parse origin/) +git push origin --force-with-lease=:$(git rev-parse origin/) ``` -Two things that will bite: +Three things that will bite: - **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. - **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. +- **Read the lease SHA from the remote, not from memory.** `--force-with-lease=:` fails with `stale info` when `` is not what the remote currently holds — which includes the case where *you* rebased the branch a moment ago and reached for its old tip. `$(git rev-parse origin/)` after a `git fetch` is the value that works. The failure looks like the shallow-clone symptom in the git section above and is not: check whether the SHA is simply out of date before concluding anything about the clone. ### Rebase-and-merge lands unsigned commits on `main` @@ -206,11 +207,22 @@ Nothing is wrong and nothing needs fixing on `main`. Know it so that `%G?` on a This happens when the **parent** PR is merged with `--delete-branch`: deleting the parent's head branch (which is the child's base) closes the **child** PR. Two PRs are involved — the merged parent (``) and the closed child (``); `` is the deleted base, i.e. the parent's head branch. -1. Restore the deleted base branch ref at the **parent** merge commit's second parent (the deleted branch's pre-merge tip). Resolve the merge commit from `` — the PR that actually merged — **not** the closed child (it's unmerged, so its `mergeCommit` is `null` and `git rev-parse` would fail), and **not** `origin/main^2` (only that second parent while the parent merge is still `main`'s tip; any later merge, or a squash/rebase tip, makes it the wrong SHA): +1. Restore the deleted base branch from **GitHub's own copy of the parent's head**, `refs/pull//head`. GitHub keeps that ref after the branch is deleted and after the PR is merged, and it points at the pre-merge tip: + ```bash - MERGE_SHA=$(gh pr view --repo / --json mergeCommit --jq .mergeCommit.oid) - gh api --method POST repos///git/refs -f ref=refs/heads/ -f sha=$(git rev-parse "$MERGE_SHA^2") + SHA=$(git ls-remote origin "refs/pull//head" | cut -f1) + gh api --method POST repos///git/refs -f ref=refs/heads/ -f sha="$SHA" ``` + + **Do not reach for the merge commit's second parent.** The older form here was: + + ```bash + git rev-parse "$MERGE_SHA^2" # WRONG under rebase-and-merge + ``` + + `^2` needs the merge commit to *have* two parents, which is true only of a merge-commit merge. Rebase replays the branch as linear single-parent commits, so `^2` fails with "unknown revision" — and this repository is rebase-only, so it fails always. `refs/pull//head` is correct under every merge method, which is the better reason to prefer it. + + Take the ref from `` — the PR that actually merged — not from the closed child, whose head is a different branch. 2. Reopen the child via **REST** (GraphQL `gh pr reopen` fails on the Projects-classic deprecation): `gh api --method PATCH repos///pulls/ -f state=open` 3. Retarget it: `gh pr edit --base main` (only works once it's open).