The problem
Picture a Monday morning stand-up. A new feature has just landed on the board, and it is too big for one person. Someone takes the database schema, someone else picks up the API layer, and a third developer claims the UI. In practice, each piece depends on the one below it: the API needs the schema, and the UI needs the API.
Nobody wants to sit idle for two or three days while the first pull request waits for a reviewer, so everyone starts building on top of code that has not been approved yet.
Now picture a different conversation, this time with another team. The mobile developers need our new endpoints today. The business logic behind those endpoints, however, will take at least another week. The sensible move is to ship the API contract first, with the routes, the request and response types, and even mocked handlers returning realistic data. The mobile team can start integrating immediately, while we replace the mocks with real persistence and real business rules in follow-up pull requests.
Both stories end in the same place: a chain of dependent changes, where each pull request only makes sense on top of the previous one. This is the natural shape of most non-trivial work. The real question is how we manage them without losing our minds.
Why branches off branches no longer cut it
For years the answer has been simple and slightly painful. You create feature-b from feature-a, then feature-c from feature-b, and you open pull requests that target each other instead of main. As long as nothing changes, this works surprisingly well. The trouble is that something always changes.
Suppose a reviewer asks for a small fix in the bottom branch. You make the change, and suddenly every branch above it is out of date. You check out the second branch and rebase it, then the third, then the fourth, resolving the same conflict again and again because the same lines moved in every layer. With five branches it becomes a project of its own.
Squash merging makes things worse. Most teams like squash because it keeps main tidy, but a squash merge takes all the commits of the bottom branch and turns them into a single new commit with a brand-new SHA. Git no longer recognises the original commits sitting inside the branches above, so a plain git rebase main tries to replay them, producing duplicated changes and conflicts on lines that are already in main. Regular merge commits avoid much of this pain, because they preserve the original SHAs, but that is precisely the option many teams have chosen to avoid.
Then there are the quieter problems. It is easy to merge a middle pull request into its parent branch rather than into main, which silently strands the code in a branch that will soon be deleted. Branch protection rules, CODEOWNERS and CI workflows are often scoped to pull requests targeting main, so the middle layers of a chain can be merged with weaker guarantees than everything else. And reviewers get no real signal about the order of the work: we have all written "depends on #123, please review that first" in a description and hoped someone would read it.
None of this is impossible to handle. It is just manual, fragile and easy to get wrong, and it puts the burden of remembering the structure of the chain on the developer instead of the platform.
A concrete example with stacks
GitHub now supports stacked pull requests natively, together with a gh stack extension for the GitHub CLI. The chain is the same, but this time GitHub knows it exists and manages it for you.
Let's go back to the mobile team waiting for our endpoints. Setting up the stack takes a handful of commands:
gh extension install github/gh-stack
gh stack init -b main
# commit: OpenAPI contract + mocked handlers
gh stack add -A -m "Persistence layer"
# commit: repository + migration
gh stack add -A -m "Business rules"
gh stack submit --open
The result is three pull requests. The first, A, contains the API contract and the mocks. The second, B, adds the persistence layer. The third, C, implements the business rules. Each one targets the branch beneath it, and, crucially, each diff shows only its own layer. The reviewer of B never scrolls past the contract from A.
Now the familiar complication arrives: a reviewer asks for a change in A before approving it. Instead of rebasing three branches by hand, you check out A, commit the fix, and run gh stack rebase followed by gh stack push. The change cascades through B and C automatically. If you prefer not to touch your local checkout at all, a cascading rebase can also be triggered on the server, straight from the pull request page.
Once the mobile team approves A, we squash merge it. This used to be the most painful moment; now it is where stacks shine. GitHub automatically retargets B to main and rebases both B and C on top of it, dropping the original commits of A even though the squash produced a different SHA. The stack remembers where each layer begins, so it does not need to guess which commits are already in main. Back on our machine, a single gh stack sync --prune brings every local branch up to date and cleans up the merged one.
The multi-developer scenario from the stand-up fits the same model. The database expert owns the first layer, the backend developer the second, and the frontend developer the third. Each person can keep working on their own layer while the others are in review. CODEOWNERS routes each pull request to the right reviewer, and, importantly, the merge requirements of the stack come from its base branch, so the protection rules and CI of main apply to every layer, including the ones in the middle.
Merging is just as flexible. Merging the top pull request lands the entire stack from the bottom up, and it works with merge queues. Merging a pull request in the middle lands it together with everything below it, while the layers above stay open and are retargeted to main. On the pull request page, a stack map in the merge box shows every layer with its status, so reviewers always know where they are and what comes next.
Stacks do not make every problem disappear. If
mainchanges on the same lines as one of your layers, you still have a real conflict to resolve: you rungh stack rebase, fix the files, and continue withgh stack rebase --continue. The difference is that this is now the only manual step left, and it is the one that genuinely requires human judgement.
Conclusion π
Stacked pull requests don't invent a new idea; chained branches always existed. What changes is that GitHub understands the chain. Retargeting, cascading rebases, squash handling and protection rules become automatic, leaving developers free to collaborate, unblock other teams early, and focus on the only part tools can't automate: real conflicts.
You canΒ follow me on GitHub, where I'm creating cool projects.
I hope you enjoyed this article, until next time π


Top comments (0)