Skip to content
Back to skills

Stacked Pr Decomposition

ASecurity

Break long-lived pull request branches into a mergeable stack of small PRs with clear dependency order and story flow. Use when a branch has grown too large, when the user asks to split a PR into stacked PRs, when a stacked PR shows no CI checks because workflows filter on main (fix: gh stack init), when a freshly gh-stack-registered stack shows no workflow runs (fix: push one real commit to fire synchronize), when the user asks about GitHub's native stacked-PR feature or gh stack commands, w...

  • 39 stars
  • 0 votes
  • 1 copy
  • 9 views
  • Added May 26, 2026
documentationgoshellbashgitbackenddocumentation

Works with

  • cli

Security analysis

A100/100

Scanned September 30, 2026

npx -y skills add ericmjl/skills --skill stacked-pr-decomposition --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Stacked Pr Decomposition?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Stacked Pr Decomposition
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/ericmjl-stacked-pr-decomposition/badge)](https://www.skillsdirectory.com/skills/ericmjl-stacked-pr-decomposition)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: stacked-pr-decomposition
description: >-
  Break long-lived pull request branches into a mergeable stack of small PRs with clear dependency order and story flow. Use when a branch has grown too large, when the user asks to split a PR into stacked PRs, when a stacked PR shows no CI checks because workflows filter on main (fix: gh stack init), when a freshly gh-stack-registered stack shows no workflow runs (fix: push one real commit to fire synchronize), when the user asks about GitHub's native stacked-PR feature or gh stack commands, when MERGING a registered/native stack (gh stack merge: atomic all-or-nothing, draft-PR gate, async enqueue-then-poll), when each PR must be reviewable in about 5 minutes, or when merging UNREGISTERED stacked PRs one-by-one (deleting a base branch auto-closes dependent PRs — retarget dependents FIRST; recover closed+deleted-base PRs by recreating them, reopen is impossible).
license: MIT
---

# Stacked PR Decomposition

Use this skill to split a long-lived branch into a sequence of small, mergeable stacked PRs that are easy to review and follow.

## Usage

Use when:

- A branch is too large for practical review.
- The user asks to break work into stacked PRs.
- Review goals include very fast human review (about 5 minutes per PR).
- The changes must retain a clear narrative from foundation to feature.

## Requirements

- Clean understanding of the current base branch (usually `main`).
- `git` available locally.
- `gh` available if opening PRs from CLI.
- Ability to run minimal verification (tests/lint/build checks relevant to each slice).

## What It Does

1. Maps the large branch into logical change groups.
2. Designs a narrative order for those groups.
3. Enforces a strict reviewability budget per PR.
4. Creates a stacked branch chain where each PR targets the previous PR branch.
5. Produces consistent PR descriptions so reviewers can move quickly.

## How It Works

### 1) Establish safe baseline and scope

1. Identify:
   - Base branch (`main` or project default).
   - Long-lived source branch to split.
2. Confirm branch is up to date enough to split safely.
3. Inventory changed files and classify by concern:
   - Pure refactor or mechanical prep.
   - Data/model or contract changes.
   - Core behavior changes.
   - UI/integration follow-through.
   - Cleanup/docs/tests.

### 2) Build a narrative stack plan

Plan PRs in dependency order:

1. Foundation first (no behavior change when possible).
2. Contract and model evolution second.
3. Behavior implementation third.
4. Integration/UI adoption fourth.
5. Cleanup and documentation last.

Each PR should answer:

- Why does this PR exist independently?
- Why must it come before the next PR?
- What reviewer mental model does it establish for the next layer?

### 3) Enforce 5-minute reviewability

Target each PR to be digestible in about 5 minutes:

- Prefer one concern per PR.
- Keep diffs compact and high-signal.
- Avoid mixing refactors with behavior changes.
- Include only tests that validate the slice.
- If a PR exceeds review budget, split it again.

Practical heuristics:

- Single intent, single headline.
- Small file count when possible.
- Minimal cross-cutting edits unless mechanically generated.
- Reviewer should summarize the change in one sentence after one read.

### 4) Materialize the stack with git branches

Create branches in sequence from base to tip:

```bash
git switch main
git pull

# PR 1
git switch -c stack/01-foundation
# cherry-pick or edit commits for slice 1

# PR 2 based on PR 1
git switch -c stack/02-contracts
# add slice 2

# Continue similarly...
```

Rules:

- Branch `stack/0N-*` starts from previous stack branch.
- Keep commit history clean and focused per slice.
- Avoid hidden dependencies that skip layers.

### 5) Validate each layer before opening PRs

For each stack branch:

1. Run targeted checks required for that slice.
2. Confirm branch compiles/tests independently relative to its base.
3. Ensure next branch still rebases cleanly if needed.

Do not open the next PR until the current slice is coherent.

### 6) Open stacked PRs with explicit chain context

When opening PRs:

- PR 1 base: `main`.
- PR N base: `stack/0(N-1)-...`.

In each PR body include:

- **Role in stack**: "PR 2 of 5"
- **Depends on**: previous PR link
- **Unblocks**: next PR link (if already opened)
- **Scope**: exactly what is in/out
- **Review guidance**: where to start, expected review time

Template:

```markdown
## Role
PR 2 of 5 in the stacked series.

## Scope
- ...

## Out of Scope
- ...

## Dependencies
- Base PR: #123

## Review Guide (5 min)
1. Read `path/a`
2. Read `path/b`
3. Run/inspect `test_x`
```

### 7) Keep the stack mergeable

As reviews land:

1. Merge from the bottom of the stack upward.
2. Retarget child PR branches onto `main` after their parent merges (see the
   `rebase --onto` technique below).
3. Keep PR descriptions updated so chain state remains obvious.

If one PR becomes contentious, isolate and resolve without bloating adjacent PRs.

#### Retargeting a child PR after its base merges (`rebase --onto`)

When a base PR merges — especially via **squash-merge** or **rebase-merge** — two
things happen at once:

- GitHub **auto-retargets** each dependent stacked PR's base from the merged
  branch to `main`.
- The child branch still carries the base PR's commits as **ancestors with the
  OLD SHAs**, while the commits that landed on `main` now have **NEW SHAs**
  (squash/rebase-merge rewrites them).

Because the SHAs differ, a plain `git rebase origin/main` can **fail to
auto-skip** the already-applied ancestor commits via patch-id detection, so you
end up resolving conflicts on commits that are *logically already in main*. The
fix is to replay **only your new commits** and drop the merged ancestors
explicitly:

```bash
# 1. Fetch and survey the boundary
git fetch origin
git log --oneline origin/main..HEAD   # your NEW commits (to keep)

# 2. CUTOFF = parent of your FIRST new commit (= old tip of the merged base branch)
CUTOFF=$(git rev-parse <your-first-new-commit>^)

# 3. Replay ONLY commits after the cutoff, onto main
git rebase --onto origin/main "$CUTOFF"

# 4. Force-push the retargeted branch
git push --force-with-lease origin <child-branch>
```

`git rebase --onto <newbase> <upstream>` replays every commit reachable from
`HEAD` but NOT reachable from `<upstream>`, on top of `<newbase>`. Setting
`<upstream>` to the cutoff (old base tip) excludes the merged ancestors from the
replay set entirely — no patch-id guessing, no phantom conflicts on
already-landed commits.

Verify on GitHub: `gh pr view <N> --json mergeable` reports `MERGEABLE`, and the
diff against `main` shows **only your new commits** with no resurrected ancestor
commits.

### 8) Quality bar checklist

Before considering the stack ready:

- [ ] Every PR has one clear intent.
- [ ] Every PR can be reviewed in about 5 minutes.
- [ ] Dependency chain is explicit and correct.
- [ ] Narrative order moves from foundation to outcome.
- [ ] Tests/checks are proportionate and passing per layer.
- [ ] Reviewers can understand each PR without reading the entire future stack.

## CI gap on stacked PRs (branch-filtered workflows)

A workflow configured with `on: pull_request: branches: [main]` (or any branch
filter) evaluates the filter against the PR's **base branch**. Stacked PRs
target the previous stack branch, not `main`, so branch-filtered workflows —
CI, preview deploys, required checks — **do not run** on them. Symptom: the PR
shows "no checks reported" and no preview environment. This is the workflow
working as configured, not a broken workflow or a blocked PR.

Rules:

- Do not debug the workflow or the PR when a stacked PR shows no checks —
  first read the workflow's `pull_request` branch filter.
- Before pushing a stacked PR, run the CI-equivalent verification locally
  (the same build/test/lint commands the workflow runs — e.g. `next build`
  plus backend deploy checks separately) so the slice is validated without CI.
- State the gap in the PR body or wrap-up ("CI runs once this PR retargets to
  `main` after the base merges") so a check-less PR is not a surprise to
  reviewers.

Retargeting nuance (CORRECTED 2026-08-27): earlier this section claimed base
deletion reliably triggers auto-retarget — for UNREGISTERED stacks merged via
`gh pr merge --delete-branch` it does NOT; dependents may be auto-CLOSED
instead (with or without a retarget). See "Merging an UNREGISTERED stack
one-by-one: the base-deletion collapse" below for the retarget-first protocol
and recovery. If the base PR merges with the branch KEPT, the child PR stays
based on a merged/dead branch — retarget manually (`gh pr edit <N> --base
main`, open PRs only) and confirm CI then kicks off.

### Fix: register the stack natively with `gh stack init` (public preview 2026-07-30)

GitHub ships NATIVE stacked PRs (public preview 2026-07-30). A manually-chained
PR stack (base branch = previous PR's head) has the stacked *shape* but is NOT a
first-class stack until registered. Registering it FIXES the CI gap above and
should be offered BEFORE retargeting or local-only verification:

- Create: `gh stack init --base main <branch1> <branch2> ...` (built-in gh
  command; also creatable from the web UI). The stack becomes a first-class
  object; member PRs carry a `stack` field in the REST resource, and the merge
  box shows a stack map.
- CI semantics: workflows trigger AS IF each PR in the stack targets the
  stack's BASE — a workflow with `on: pull_request: branches: [main]` runs for
  EVERY PR in the stack, not just the bottom one. Preview deploys and required
  checks fire on upper layers without retargeting.
- Merge semantics: merging the TOP PR merges the whole stack; merging a lower
  layer auto-rebases and auto-retargets the PRs above (replacing the manual
  `rebase --onto` / `gh pr edit --base` dance for that boundary).

After init, verify with `gh pr checks <N>` / `gh run list` and RUN the check in
the same turn — do not narrate a wait-and-watch plan (execution-discipline).
Follow-up discovery 2026-08-16 on learn-anything: user asked to use GitHub's
native stacked-PR feature; Stack #46 created via `gh stack init` linking PR #44
(planning/board-sync-gantt) + PR #45 (planning/portal-gantt) precisely so
deploy-website.yml would fire for #45.

Discovered 2026-08-16 on learn-anything: PR #45 (base
`planning/board-sync-gantt`, stacked on PR #44) ran no `deploy-website.yml`
CI because that workflow filters `pull_request` to `branches: [main]`; the
build was verified locally instead (`next build` + `convex dev --once`).

### Gotcha: stack registration alone does NOT trigger workflows

Registering a stack with `gh stack init` + `gh stack submit` (submit also pushes
the branches) emits NO `pull_request` event — stack creation is not a PR
activity type. The as-if-base CI semantics apply only to SUBSEQUENT
pull_request events (synchronize, reopened, ...), so after registering
already-open PRs, branch-filtered workflows still look dead until the next
push. Confirmed 2026-08-16 (learn-anything Stack #46): zero new workflow runs
immediately after `gh stack submit`; one real push later, `deploy-website.yml`
ran for the upper-layer PR (base = another stack branch) exactly as if it
targeted `main` — the semantics WORK, they just need an event to evaluate. Do
not conclude the feature is broken or not-yet-rolled-out when a
freshly-registered stack shows no runs.

To fire the needed `synchronize` event without violating git rules:

- PREFERRED: push ONE REAL commit. Bundle a genuinely pending improvement (a
  review fix, an a11y patch, a docs nit) as the trigger commit — the 2026-08-16
  session used a CDO a11y fix (click-only spans → keyboard-focusable anchors +
  `text-decoration: none` on `.gbar`/`.gdiamond-lg`) as the CI trigger, so the
  event double-counted as real work.
- Do NOT create an empty commit just to fire the event (empty commits require
  an explicit request).
- Do NOT amend + force-push to fire it (force-push requires an explicit
  request).
- Do NOT close + reopen the PR: `reopened` IS a default `pull_request` event
  type and would mechanically work, but it violates the SPIRIT of any "don't
  close the PR" instruction — never spend a user-issued constraint to save a
  turn.

### Adding a layer to an existing stack (`gh stack add`)

When follow-up work must NOT pile onto an already-open top PR (e.g.
post-review operational updates discovered after submit), add a NEW layer to
the registered stack instead:

```bash
gh stack add <branch-name>   # new branch layered on the current stack tip,
                             # created in the CURRENT worktree
# ... commit the follow-up work on the new branch ...
gh stack submit              # pushes branches and opens/updates the stack PRs
```

- `gh stack add <name>` creates the new branch on top of the CURRENT branch
  (the stack tip) in the CURRENT worktree, so an existing stack worktree
  continues in place — no new worktree needed.
- Stack membership state is tracked in per-branch `.stack` files inside the
  worktree — check for the `.stack` file when unsure how a worktree stack is
  tracked locally.
- The new layer PR targets the previous tip branch; a brand-new PR fires
  `pull_request` (opened) on creation — unlike re-registering already-open
  PRs, which fires no event (see the gotcha above) — so branch-filtered
  workflows evaluate with as-if-base semantics immediately.

Discovered 2026-08-16 (learn-anything board migration: `gh stack add
board/org-project` on top of `planning/portal-gantt` in the portal-gantt
worktree, landing org-project reference updates as a separate layer under
PRs #44/#45).

### Merging a registered stack (`gh stack merge` — atomic, async, draft-gated)

Discovered 2026-08-16 merging learn-anything Stack #46 (PRs #44 ← #45 ← #47):

```bash
gh stack merge <pr-number>   # merges everything up to AND INCLUDING that PR
# or: gh stack merge <stack-number>; bare numbers resolve stack-first, then PR
# non-interactive shells: pass --yes (or it merges whole stack w/o prompting
# using your last-used merge method unless one is specified)
```

Semantics (from `gh stack merge --help`):

- **Atomic all-or-nothing**: all members of the stack up to and including the
  chosen PR merge into the base branch in ONE operation — if any PR cannot
  merge, NONE are. Merging the TOP PR lands the whole stack; merging a lower
  PR merges everything below it and auto-rebases/retargets the layers above.
- **Draft gate (the gotcha)**: only basic PR state is pre-checked — "open and
  not a draft". `gh stack submit` opens NEW PRs as **drafts by default**, so a
  freshly-submitted top PR blocks the whole stack merge. Fix: `gh pr ready
  <top-PR>` then retry. Nuance: PRs that pre-existed the stack registration
  (opened via `gh pr create`, then linked with `gh stack init`/`link`) keep
  their non-draft status — only the submit-created PR needs the ready flip.
- **Async enqueue**: the merge runs via GitHub's async merge path
  (`PUT .../merge-async`; with a merge queue the stack joins the queue) — the
  call returns immediately and the merge completes in the background. POLL
  until the PRs report MERGED; do not assume failure or success at enqueue
  time. Bypassing merge requirements is NOT supported for stacks.
- **Verification**: the stack lands LINEARLY on main as one commit range
  (Stack #46: 7 commits, `d239376..cd3313f`, rebase-style history preserved).
- **Post-merge leftovers (both verified 2026-08-16)**: member branches are NOT
  auto-deleted from origin (all three remained on `git ls-remote`) — delete
  them explicitly if wanted; and the local checkout's main is NOT auto-synced
  — `git fetch origin && git merge --ff-only origin/main` (preserves unrelated
  unstaged changes) to catch up. Stack worktrees hold the stack's `.stack`
  files — safe to remove after the merge lands.

### Merging an UNREGISTERED stack one-by-one: the base-deletion collapse (retarget dependents FIRST)

Discovered 2026-08-27 merging learn-anything PRs #84-#89 (a plain 6-branch
chain opened with `gh pr create`, NOT `gh stack`-registered): merging a
stacked PR with `gh pr merge <n> --rebase --delete-branch` DELETES its
branch, and any OPEN PR based on that branch gets **CLOSED**, not cleanly
retargeted. Observed outcomes were inconsistent: one dependent PR (#85) was
closed with its base left pointing at the deleted branch
(`docs/tony-kulesa-reply`); another (#88) had its base retargeted to `main`
but was STILL closed. Do not rely on GitHub's auto-retarget for unregistered
stacks — and do not burn turns theorizing about which variant fires; check
`gh pr view <N> --json state,baseRefName` and act.

Prevention (the protocol, in order):

1. BEFORE merging the bottom PR, retarget every open dependent while it is
   still open and its base still exists: `gh pr edit <N> --base main` (base
   edits work only on OPEN PRs).
2. OR merge without `--delete-branch` and delete branches at the end
   (`git push origin --delete <branch>` per merged branch).
3. Merge bottom-up, rebase-merge only (standing user rule).

Recovery from the collapse (classified by observed state):

- **Closed PR, base branch DELETED** → dead end, do not fight it: `gh pr
  reopen` fails with "Could not open the pull request" (a PR whose base
  branch is gone cannot be reopened), and a closed PR's base cannot be
  edited — chicken-and-egg. Recreate instead:
  `gh pr create --base main --head <branch>` — GitHub dedupes
  already-merged commits (the new PR's commits list shows only the unique
  ones; verified for #85 where only the Shemra commit appeared), so the
  recreated PR diffs cleanly against `main`. Then rebase-merge it.
- **Closed PR, base retargeted to `main`** → `gh pr reopen <N>` works (base
  exists); then verify `gh pr view <N> --json commits,mergeable` FRESH — the
  commits list may be stale/cached and show already-merged ancestor commits
  that a rebase merge will not actually replay.
- **Still-OPEN PR whose base branch still exists** → retarget it to `main`
  NOW, before anything deletes that base (merging the PR below it without
  retargeting first would auto-close it again).

Side gotcha: if the PR's branch is checked out in a worktree, `gh pr merge
--delete-branch` still merges remotely and deletes the REMOTE branch, but
fails to delete the LOCAL branch ("error deleting local branch") — that
local-delete error does NOT mean the merge failed. Confirm with
`gh pr view <n> --json state,mergeCommit`.

#### Per-boundary stale-stack recurrence (rebase-merge mode)

Validated 2026-08-27 on learn-anything (6-PR atomic stack, PRs #84-#90): a
retargeted child PR that still carries its base PR's commits under the OLD
SHAs goes CONFLICTING **again** the moment the base PR rebase-merges — even
if the child was freshly rebuilt and MERGEABLE beforehand, and even though a
local content-level 3-way merge would be clean. GitHub's rebase-merge
mergeable computation does NOT dedupe same-patch commits (local `git rebase`
auto-drops empty / already-upstream patches; GitHub's infrastructure flags
them as conflicts). Do not bet a merge order on GitHub deduping the child's
commit list after the base lands — it will not.

Rules:

- Right after any merge, `mergeable: UNKNOWN` means GitHub is recomputing
  asynchronously — wait and re-poll rather than concluding conflict. But a
  computed `CONFLICTING` on a stacked child carrying merged-ancestor commits
  is real until you rebuild the branch.
- Re-apply the `rebase --onto` drop at EACH boundary: after a base PR merges,
  in the child's worktree run `git rebase --onto origin/main <merged-SHA>`
  (upstream = the now-merged commit to drop) + `git push --force-with-lease`,
  then verify `gh pr view <child> --json mergeable` = MERGEABLE and the
  commit list shows only the child's own commits. The fix is per-boundary,
  not one-time: a three-deep stack needs it after each of the first two
  merges. A child rebuilt to sit DIRECTLY on main (carrying only its own
  commits) stays MERGEABLE through the base merge — the flag comes
  specifically from carrying the base's commits. Content-dependent children
  cannot pre-flatten (their diff needs the base's content), so for those,
  plan the post-merge rebase as a required step of the merge sequence itself.
- `gh pr merge <n> --rebase --delete-branch` also tries to delete the LOCAL
  branch; when that branch is checked out in a worktree the local delete
  fails with a warning — cosmetic (the remote delete succeeds). Clean the
  worktrees after the whole stack lands.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.