Stacked pull requests without a dedicated tool Jump to heading

Dependent work creates a choice nobody likes: bundle three changes into one enormous pull request, or wait for the first to merge before starting the second. Stacking is the third option β€” each change is its own pull request, based on the one before it, reviewed in parallel and merged in order. The reputation for complexity comes almost entirely from restacking after a rebase, and Git has had a flag for that since 2.38. This recipe uses plain Git throughout, within code review workflow engineering.

When to use this approach Jump to heading

  • A change naturally decomposes into steps that build on each other.
  • The first step is reviewable on its own and would be merged on its own merits.
  • Waiting for each review before starting the next would cost days.
  • Your forge lets a pull request target a branch other than the default.
  • If the parts are independent rather than dependent, open them side by side instead; stacking adds ordering you do not need.

Step 1 β€” Build the stack, each branch on the one before Jump to heading

git switch -c refactor/extract-client origin/main
# ... work, commit ...

git switch -c feat/retry-policy          # based on the branch above, not on main
# ... work, commit ...

git switch -c feat/retry-metrics         # third layer
# ... work, commit ...
# Verification: each branch contains the one below it
git log --oneline --graph origin/main..feat/retry-metrics | head -12
git merge-base --is-ancestor refactor/extract-client feat/retry-policy && echo "stacked correctly"
What a three-change stack looks likeEach branch is created from the tip of the one below, so the third contains all three changes. The pull requests target their own base rather than the default branch, which is what keeps each diff limited to its own step.each layer contains everything below itmainABextract-clientBC1retry-policyC1C2retry-metricsC2C3three pull requests, three diffs, one dependency chain

Step 2 β€” Target the correct base on each pull request Jump to heading

This is the step that makes each diff small. A pull request targeting the default branch shows every commit in the stack; targeting its parent shows only its own.

gh pr create --base main  --head refactor/extract-client --title 'refactor: extract the HTTP client'
gh pr create --base refactor/extract-client --head feat/retry-policy  --title 'feat: retry policy'
gh pr create --base feat/retry-policy --head feat/retry-metrics --title 'feat: retry metrics'
# Verification: each diff should be the size of one step
for b in refactor/extract-client feat/retry-policy feat/retry-metrics; do
  printf '%-30s ' "$b"; gh pr view "$b" --json additions,deletions --jq '.additions + .deletions'
done

Add one line to each description saying what it sits on and what sits on it. Reviewers who land on the middle of a stack with no context assume they are looking at a broken change.

Step 3 β€” Restack after the base moves Jump to heading

The default branch advances while the stack is in review. --update-refs rebases the whole stack and moves every branch pointer in one operation.

git fetch origin
git switch feat/retry-metrics                       # the top of the stack
git rebase --update-refs origin/main
# Verification: every branch moved, and the stacking is intact
git log --oneline --graph origin/main..feat/retry-metrics | head
git merge-base --is-ancestor refactor/extract-client feat/retry-policy && echo "still stacked"
# Make it the default, so a plain rebase restacks
git config rebase.updateRefs true

Without --update-refs, rebasing the top branch leaves the lower branch pointers where they were, and the stack silently becomes three unrelated branches containing duplicated commits β€” the state that gives stacking its bad reputation.

Rebasing with and without --update-refsA plain rebase moves only the branch you are on, leaving the branches beneath it pointing at the old, now-orphaned commits. With --update-refs Git moves every branch pointer that appears in the rebased range, so the stack stays a stack.Plain rebaserebase --update-refsbranches belowleft behindmoved with the stackduplicate commitsappear in diffsnonepull request diffsballoonstay smallcommands neededone per branchonethis one flag is the difference between stacking being pleasant and being miserable

Step 4 β€” Force-push the stack safely Jump to heading

Rewritten branches need force-pushing, and the lease option is what stops you overwriting a colleague’s push.

git push --force-with-lease origin \
  refactor/extract-client feat/retry-policy feat/retry-metrics
# Verification: the remote branches now match, and reviews can resume
for b in refactor/extract-client feat/retry-policy feat/retry-metrics; do
  printf '%-30s %s\n' "$b" "$(git rev-parse --short "origin/$b")"
done

SAFETY WARNING β€” never use plain --force on a branch that is under review. --force-with-lease refuses when the remote has moved since your last fetch, which is exactly the case where someone else pushed a change you have not seen. Plain force discards it silently, and the author finds out when their commit is missing.

Give reviewers a range-diff after any restack so they can see that only the rebase changed β€” the technique is in reviewing a force-push with git range-diff.

Step 5 β€” Merge from the bottom, then restack Jump to heading

# Merge the bottom pull request first
gh pr merge refactor/extract-client --squash --delete-branch

# Retarget the next one at the default branch and restack
gh pr edit feat/retry-policy --base main
git fetch origin && git switch feat/retry-metrics && git rebase --update-refs origin/main
git push --force-with-lease origin feat/retry-policy feat/retry-metrics
# Verification: the remaining stack has no duplicated commits
git log --oneline origin/main..feat/retry-metrics
The life of a three-change stackAll three pull requests are opened and reviewed in parallel. The bottom one merges first, the remainder are retargeted and restacked, and the process repeats. Total elapsed time is one review cycle rather than three.All three openedreviewed in parallelday 1Bottom mergessquashed into mainday 2Restackone rebase commandday 2Middle mergesbase retargetedday 3Top mergesstack completeday 3serialised, the same work would have taken three review cycles

Squash merging the bottom of a stack rewrites its commits, so the restack afterwards is not optional β€” without it the next branch still contains the pre-squash commits and its diff shows them as new work.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

What if a reviewer wants a change in the bottom branch? Jump to heading

Make it there, then restack. git commit --fixup on the bottom branch followed by git rebase -i --autosquash --update-refs origin/main from the top applies the change and carries the whole stack with it in one operation.

Can CI run on every branch in the stack? Jump to heading

It can and it should, though the middle branches are testing a state that will never exist on the default branch exactly as written. Treat their results as information about that step rather than as a guarantee about the final merge, and let the merge queue validate the real tree.

Is a dedicated stacking tool worth it? Jump to heading

For teams doing this constantly, yes β€” the tools automate retargeting and restacking across many branches. For occasional stacks of two or three, --update-refs covers the hard part, and plain Git keeps the workflow legible to everyone rather than to whoever installed the tool.