DEV Community

Irvin Gil
Irvin Gil

Posted on

Mastering Stacked Pull Requests (PRs)

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
Enter fullscreen mode Exit fullscreen mode

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 break main on 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
Enter fullscreen mode Exit fullscreen mode

Prerequisites: gh ≥ 2.90.0, git ≥ 2.20.

1. Start from a clean trunk

git switch main
git pull --ff-only
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

Or scaffold the whole shape up front:

gh stack init auth-layer api-routes ui-components
Enter fullscreen mode Exit fullscreen mode

Or adopt branches that already exist locally:

gh stack init feat/auth feat/api feat/ui
Enter fullscreen mode Exit fullscreen mode

Inspect at any time:

gh stack view          # tree view with PR status per branch
gh stack view --short  # compact one-line-per-branch
Enter fullscreen mode Exit fullscreen mode

3. Push and open the PRs

gh stack submit
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

--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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

After merges land upstream, prune locally:

gh stack sync --prune                    # rebase + delete local branches for merged PRs
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 rebase and 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:

  1. 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 @Disabled the affected tests in the lower-layer PR with a TODO(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.
  2. 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 rerere on 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 sync will need to reconcile a divergence. Prefer gh 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.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)