Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 59 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,31 +147,82 @@ 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/<tip-branch> origin/<bottom-branch> && echo "OK" || echo "STRANDED"
# WRONG under rebase-and-merge — fails even when everything landed
git merge-base --is-ancestor origin/<tip-branch> origin/<bottom-branch>

# Right: ask whether the content differs
git diff --stat origin/<bottom-branch> origin/<tip-branch> # 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/<tip-branch>`, 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 <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.

### 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 <n> --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 <bottom> --repo <owner>/<repo> --rebase
git fetch origin
git rebase -S origin/main # in the next branch's worktree
git push origin --force-with-lease=<branch>:$(git rev-parse origin/<branch>) <branch>
```

Three things that will bite:

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.
- **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=<branch>:<sha>` fails with `stale info` when `<sha>` 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/<branch>)` 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`

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, …
```

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
Comment thread
thecodedrift marked this conversation as resolved.

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 (`<parent>`) and the closed child (`<child>`); `<branch>` 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 `<parent>` — 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/<parent>/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 <parent> --repo <owner>/<repo> --json mergeCommit --jq .mergeCommit.oid)
gh api --method POST repos/<owner>/<repo>/git/refs -f ref=refs/heads/<branch> -f sha=$(git rev-parse "$MERGE_SHA^2")
SHA=$(git ls-remote origin "refs/pull/<parent>/head" | cut -f1)
gh api --method POST repos/<owner>/<repo>/git/refs -f ref=refs/heads/<branch> -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/<n>/head` is correct under every merge method, which is the better reason to prefer it.

Take the ref from `<parent>` — 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/<owner>/<repo>/pulls/<child> -f state=open`
3. Retarget it: `gh pr edit <child> --base main` (only works once it's open).
Expand Down
Loading