TL;DR: This is my personal findings and recommendations weaved into a cheatsheet for getting good at using Stacked PRs. Do note that not every user story implementation needs to be broken down and stacked into multiple PRs. At the end of this article, I've given a quick rule of thumb guide to help you decided whether the task you're working makes sense to be a set of Stacked pull requests.
Why stack PRs?
A stacked PR is a chain of branches where each one targets the branch below it instead of main:
main ← auth-layer ← api-routes ← ui-components
Each PR shows only the diff for its own layer. The goal is not fewer changes — it's changes that a reviewer can hold in their head.
- Optimise the reviewer's time. A 1,400-line change becomes 4 PRs of ~350 lines each, one focused concern per PR.
- Ship in dependency order. Types → data → API → UI. Each layer is independently revertible and independently correct.
- Keep momentum. You don't wait for the auth review to finish before opening the API PR — you stack on top and keep working. When the bottom merges, GitHub cascades the rebase automatically.
NOTE: The skill is the layer boundary, not the CLI.
A good stack has layers that are each atomic (small, coherent) and safely revertible. Schema goes before the code that reads it. A mechanical rename goes in its own layer, separate from the judgment call it enables. If a middle layer would breakmainon its own, the boundary is wrong — repartition before you submit.
When to stack 🤔
- Layered architecture change. Types/auth → data/API → UI.
- Refactors that touch coupled implementations. e.g. consumer → service/facade, or struct/class refactor + database entity changes.
- A tall change that reviewers keep asking you to split. Stacks pre-empt this.
- You want to keep working while an earlier layer is still under review.
When NOT to stack 👎
Tip: Stacking PRs is not a badge of engineering maturity. It's a tool for changes with real dependency order.
- Independent fixes. Open separate PRs. Stacking unrelated work adds ceremony without value.
- Tiny, coherent change. A three-file bug fix does not need three layers — a stack makes review slower, not clearer.
- No team convention. Stacks work when reviewers know to start at the bottom and authors keep each layer focused. If your team doesn't have that discipline yet, agree on the workflow first.
- Cross-fork work. GitHub's stacked PRs require all branches to live in the same repository.
The gh stack workflow
0. One-time setup
gh extension install github/gh-stack
gh stack --help
Prerequisites: gh ≥ 2.90.0, git ≥ 2.20.
1. Start from a clean trunk
git switch main
git pull --ff-only
2. Create the stack
Either build the layers one at a time:
gh stack init auth-layer # first layer, based on main
# ...make changes, git commit...
gh stack add api-routes # second layer, based on auth-layer
# ...make changes, git commit...
gh stack add ui-components # third layer, based on api-routes
# ...make changes, git commit...
Or scaffold the whole shape up front:
gh stack init auth-layer api-routes ui-components
Or adopt branches that already exist locally:
gh stack init feat/auth feat/api feat/ui
Inspect at any time:
gh stack view # tree view with PR status per branch
gh stack view --short # compact one-line-per-branch
3. Push and open the PRs
gh stack submit
submit opens an interactive editor listing every branch without a PR. Deselect any you don't want yet, draft titles and descriptions, choose ready-for-review vs. draft, then Ctrl+S to push and create the stack on GitHub in one shot.
Non-interactive equivalent:
gh stack submit --auto # auto-generated titles, new PRs open as draft
gh stack submit --auto --open # push straight to ready-for-review
4. Respond to review feedback on a middle layer
The classic pain point. Reviewer asks for a change on api-routes while you're already working on ui-components. Do this:
gh stack down # move to api-routes
# ...edit and commit...
gh stack rebase --upstack # cascade-rebase ui-components onto the new api-routes
gh stack push # push both updated branches, uses --force-with-lease
gh stack top # back to where you were
--upstack rebases everything above the current branch; --downstack rebases everything below. Bare gh stack rebase does the whole stack, starting with a fetch from trunk.
5. Keep the stack current with main
gh stack sync
sync fetches, fast-forwards trunk, cascade-rebases every branch onto its updated parent, pushes atomically with --force-with-lease --atomic, and reconciles the PR state on GitHub. If a rebase conflict is detected during sync, all branches are restored and you're told to run gh stack rebase interactively.
6. Restructure without pain ✏️
gh stack modify # opens a TUI to drop / fold / insert / reorder / rename
Operations in the TUI:
| Key | Action |
|---|---|
x |
Drop a branch from the stack |
d |
Fold current branch into the one below |
u |
Fold current branch into the one above |
i / I
|
Insert a new branch |
r |
Rename |
Shift+↑ / Shift+↓
|
Reorder |
z |
Undo |
Ctrl+S |
Apply all changes |
Prerequisites: active stack checked out, clean working tree, no in-progress rebase, no PRs queued for merge, linear history (run gh stack rebase first if not). After a modify that touches PRs, run gh stack submit to sync GitHub.
7. Merge
gh stack merge # interactive: choose how far up to merge, pick method, confirm
merge performs an atomic stack merge — all included PRs succeed or none do. GitHub cascade-rebases the remaining branches onto trunk automatically once the bottom lands.
Non-interactive variants:
gh stack merge --yes --squash # merge whole stack, squash
gh stack merge 42 # merge everything up to and including PR #42
After merges land upstream, prune locally:
gh stack sync --prune # rebase + delete local branches for merged PRs
Worked example: auth-layer → api-routes → ui-components ✅
Here's a personal work experience where i've tried the Stacked PRs workflow:
# 0. Fresh trunk
git switch main && git pull --ff-only
# 1. Layer 1: auth types + middleware
gh stack init auth-layer
# ...code + test...
git add src/auth tests/auth
git commit -m "auth: token verification middleware"
# 2. Layer 2: API routes that depend on auth
gh stack add api-routes
git add src/api tests/api
git commit -m "api: user search endpoint behind auth middleware"
# 3. Layer 3: UI that consumes the API
gh stack add ui-components
git add src/ui tests/ui
git commit -m "ui: user search component"
# 4. Push all three PRs
gh stack submit
# 5. Reviewer asks for a rename in api-routes
gh stack down # → api-routes
git commit -am "api: rename param userQuery → q"
gh stack rebase --upstack # ui-components picks up the rename
gh stack push
gh stack top # → ui-components
# 6. Main moved. Bring the whole stack forward.
gh stack sync
# 7. Everything approved. Merge.
gh stack merge --yes # atomic, bottom-up
gh stack sync --prune # tidy up locally
Merge strategy and branch protection
⚠️ WARNING: Merge-commit-only for stack identity.
Repositories configured to squash-merge or rebase-merge by default should switch to merge commits for stacked PRs — otherwise GitHub's stack-identity tracking breaks and cascading rebases stop working reliably.
Other rules that carry over:
- Every layer runs the same required checks as a direct PR to
main. Stacking does not bypass branch protection. -
Signed commits: the server-side "Rebase stack" button in the GitHub UI does not produce signed commits. If your repo requires signatures, rebase locally with
gh stack rebaseand push. - Merge queue: if the base branch uses a merge queue, the whole stack is added to the queue and merges when the queue processes it. Large stacks may split across consecutive merge groups (GitHub allows ~50% size headroom to keep integrity).
CI/CD compatibility
Every branch in the stack triggers your normal per-PR pipeline. Two failure shapes to expect and plan for:
-
Tests owned by an upper layer fail on a lower-layer PR. Business logic in the lower layer changed; the tests that assert against the new behaviour live in a higher layer.
- Recommended: design layers so tests live in the same layer as the behaviour they cover.
-
Fallback: comment out or
@Disabledthe affected tests in the lower-layer PR with aTODO(stack): re-enabled in <upper-layer branch>marker, and re-enable them in the upper layer. Use this sparingly — it's a corner-cut with an easy way to forget.
-
Integration/e2e tests that need the whole stack. They will fail on lower layers by construction. Two options:
- Run e2e only on the top layer (add a CI condition).
- Gate e2e behind a label; only the top PR gets the label.
💡 TIP: Enable
git rerereon your machine.
git config --global rerere.enabled true— during rebases across the stack, git records how you resolved a conflict and reapplies the same resolution when it sees the same conflict again. Saves real time on multi-layer rebases.
Troubleshooting
Rebase conflict during gh stack rebase
# fix conflicts, then:
git add <files>
gh stack rebase --continue
# or bail out:
gh stack rebase --abort
Conflict during gh stack sync
Sync restores all branches to their pre-sync state on conflict and asks you to resolve interactively:
gh stack rebase # walk each conflict
gh stack push
Local and remote stacks have diverged
gh stack sync prompts (in an interactive terminal) to take the remote as source of truth, delete the stack on GitHub and recreate, or cancel. Cancelling — or any divergence in a non-interactive terminal — aborts without pushing.
gh stack modify won't start
Requires clean working tree, no in-progress rebase, no queued merges, and linear history. Run gh stack rebase first.
Merge stopped mid-stack
Successfully merged PRs land; the failing PR and everything above stay open. Fix the failing PR, then retry gh stack merge. If a PR is removed from the merge queue, all upstream PRs are ejected with it — re-queue after the fix.
A mid-stack PR was closed
This blocks every PR above it. Dissolve or restructure the stack via gh stack modify (or the GitHub Unstack UI), then resubmit.
Cross-fork stack
Not supported. All branches must live in the same repository.
Anti-patterns 🙅♂️
- Stacking unrelated changes to bulk up. A stack is dependency order, not a folder.
- Layers that only compile as a set. If layer N doesn't build without N+1, N+1 has been mis-scoped down.
-
Force-pushing outside the tooling. Break the parent chain and
gh stack syncwill need to reconcile a divergence. Prefergh stack push/gh stack rebase. - Deep stacks (>4–5 layers). Reviewers stop reading. Land the bottom, keep going with a shorter follow-on stack.
-
Silently commenting out tests to make CI green. Leave a
TODO(stack): re-enabled in <branch>marker and confirm they're back before merging the top layer.
Command cheatsheet
| Task | Command |
|---|---|
| Install extension | gh extension install github/gh-stack |
| Start a stack | gh stack init <branch> |
| Add a layer | gh stack add <branch> |
| Push & open PRs | gh stack submit |
| Inspect |
gh stack view / gh stack view --short
|
| Move up/down in stack |
gh stack up / gh stack down
|
| Jump to top/bottom |
gh stack top / gh stack bottom / gh stack trunk
|
| Rebase whole stack | gh stack rebase |
| Rebase above only | gh stack rebase --upstack |
| Rebase below only | gh stack rebase --downstack |
| Continue / abort rebase |
gh stack rebase --continue / --abort
|
| Sync with remote & trunk | gh stack sync |
| Push only (no PR ops) | gh stack push |
| Restructure interactively | gh stack modify |
| Merge stack (atomic) | gh stack merge |
| Merge up to specific PR | gh stack merge <pr-number> |
| Prune merged branches | gh stack sync --prune |
| Check out any stack | gh stack checkout <stack# / PR# / URL / branch> |
| Link external-tool branches into a stack | gh stack link <branch-or-pr> <branch-or-pr> ... |
| Dismantle a stack |
gh stack unstack (add --local to keep the remote stack) |
Quick decision guide 👍
Is the change more than ~400 lines and touching multiple concerns?
└─ No → single PR. Stop.
└─ Yes → Are the concerns in a natural dependency order?
└─ No → open several independent PRs.
└─ Yes → each independently revertible and correct?
└─ No → repartition until they are.
└─ Yes → stack it.
Top comments (0)