Making a Version of Stacked Diffs Work for a GitHub Org

We're a small team, and our implementation work comes from a mix of human engineers and LLM agents. We wanted what stacked diffs promise — small reviews, fast iteration, a big change built as a sequence of steps a reviewer can hold in their head — without adopting new tooling to get it.
Stacked diffs are the workflow Meta's engineers use instead of GitHub-style pull requests: big changes cut into chains of small ones, each reviewed on its own. People who leave Meta miss it enough that a small industry of tools — Graphite, ghstack — exists to recreate it on GitHub.
We got about half of it. This post is about which half, what the missing half cost us, and the rules we now follow.
Stacked diffs are two ideas, not one
Most descriptions of stacked diffs focus on the review experience. That's only half the design.
The review half. Work is cut into small diffs that form a dependency chain. Each diff is reviewed alone. A child's visible diff is only its delta over its parent. Reviewers never face a 10,000-line change.
The landing half. Each diff lands on main individually, as soon as it's approved. The stack exists only during review. main integrates continuously. There is no end-of-project integration event.
The landing half is the part most GitHub orgs can't use, because it has a precondition: every diff must be safe on main by itself. In practice that means the work can ship dark — each increment either sits behind a feature flag or is unreferenced by live code until a final wiring change turns it on. It also needs tooling GitHub doesn't provide: per-diff CI, automatic restacking when a parent changes.
What we built instead
For a large project that could not ship in pieces, we used what we call a collector branch:
- One long-lived branch off
main. That's the collector. - Work cut into segment PRs. Segment 1 branches off the collector. Segment 2 branches off segment 1's head. And so on. Every segment PR targets the collector.
- Because each segment branches from its predecessor's head, its visible diff shrinks to just its own delta once the predecessor merges. This is the stacked-diff review experience, rebuilt with nothing but branch choices.
- Review runs serially. When a segment is next in line, it gets machine and human review, every comment gets resolved or answered, and it merges into the collector. One review in flight at a time.
- At the end, the collector merges to
mainas one squash.
Compare that to the two ideas above. We rebuilt the review half faithfully. Then we inverted the landing half: main saw nothing for weeks, then saw everything at once.
What it cost
The costs came from the two things we didn't have: the restacking tooling, and continuous landing. One we paid every week. The other we held off by hand.
We restacked the chain by hand. Here's the shape of the thing:
main o──o──o──o──o──o──►
\ │
\ daily sync
\ ▼
collector C────────C'───────►
\
segment 1 A──A' ← review rework lands here
\
segment 2 B ← now B must move onto A'
\
segment 3 D ← then D onto the new B
Segment 4 is based on segment 3's head, 3 on 2's, 2 on 1's. So when review rework changed a parent segment — or when we brought main into the collector — every open segment below it had to be carried forward, one by one, by hand. Every arrow that moves, everything under it moves too. This is exactly the work Meta's tooling automates, and it was our largest recurring cost. The review experience of stacked diffs is cheap to recreate with branch choices. The maintenance of the stack is not.
Staying close to main was discipline, not a gift. The famous risk of a long-lived branch is the final merge: main moves for weeks, and the integration debt comes due all at once. That mostly didn't happen to us — but only because we kept syncing the collector with main throughout, and paid the restacking cost above each time we did. By the time the collector was ready, its diff to main was large but trivial. Nothing in the tooling made that happen. An undisciplined collector gets the full end-loaded risk back.
We squashed the landing, and shouldn't have. The collector merged to main as one squash: a dozen careful segments became one enormous commit. Bisect lost its resolution, and every branch that still held the original commits showed a phantom "ahead by N" until someone rotated it. For a branch this long-lived, land with a real merge (or a rebase-merge) and keep the history you were careful to build.
Review automation ignored us. Our machine reviewer only auto-reviews PRs that target the default branch. Segment PRs target the collector, so every review had to be triggered by hand. The protocol worked, but it lived in people's memories, and things that live in memories get skipped.
Serial review slowed the tail. Segment N+1 waited while segment N was in review. That's the price of delta-only diffs without restacking tooling.
It was still the right call
Here's the part most stacked-diff writing skips: the landing half is not always available. If a segment would leave main in a broken intermediate state, or the change swaps a live surface and can't hide behind a flag, then landing each diff on main was never an option. Much of our project was like that. The collector gave us the two things we actually needed — small reviews and a single release gate.
The mistake isn't using a collector. The mistake is using one when you didn't have to, or using one without paying its costs as you go. So we wrote the decision down as two rules.
Default: stack on main with what GitHub already gives you. If the work can ship dark, make small PRs straight to main. Where PRs depend on each other, base PR 2 on PR 1's branch — when PR 1 merges, GitHub retargets PR 2 onto main automatically. That's a stack with zero new tooling, and the end-integration problem never exists.
Exception: a collector, with the costs paid daily. When the batch must land as one unit:
- Name the collector clearly and record it on the tracking issue, so anyone joining mid-project can find the right base branch.
- Open the collector→
mainPR as a draft on day one. The full diff stays visible, and CI previews the real merge the whole time. - Keep the collector current with
main— every day, and after every segment lands. Merge or rebase, whichever your team runs on; the requirement is freshness, not the mechanism. (If you rebase, everyone with an open segment moves to the new commits — with a hand-maintained chain you're doing that work anyway.) This is the discipline that made our final merge boring, written down so it survives us: skew surfaces the day it's created instead of the day you ship. - Only segment PRs touch the collector. No direct pushes.
- Land the collector with a real merge or a rebase-merge — anything but a squash. The segments survive on
main, so bisect works and the history you built segment by segment is still there. - Slice out an interim landing when one is reasonably possible. Only when. Some collectors are just big — ours was always going to be a big diff, and no process would have made it meaningfully smaller. The test is "could we land a coherent slice without breaking
main?", not a line count.
Rules an agent can check
Half our implementation traffic comes from LLM agents, and agents taught us something about process design that applies equally to new human teammates.
Contributors fail a collector in four recurring ways. They target main when they should target the collector. They rewrite the collector's history without telling anyone, stranding every open segment. They treat green collector CI as green main CI. They forget the manual review trigger.
What fixed this was not more documentation. It was restating the protocol as invariants a contributor can verify mechanically:
- The task states the target base. Before review, verify it:
gh pr view --json baseRefNamemust equal the collector. - "Ready to merge" has two definitions. For a segment: CI green against the collector, every review comment resolved. For the collector:
mainmerged in first, CI green after that merge. - Rewriting history on a branch other people base work on is a team event, not a solo act. Announce it, or merge instead.
- A human performs every merge, at both levels.
A convention written as a story gets absorbed by the people who lived it and skipped by everyone who arrives later. A convention written as checks gets followed by anyone — human or agent — because "verify the base branch" is a command, not a story.
The short version
Stacked diffs are two ideas: chain your reviews small, and land continuously on main. On stock GitHub you can have the first one cheaply — stack your PR bases and let GitHub retarget on merge — and you should take it whenever the work can ship dark. When it can't, a collector branch gives you stacked review with batched integration. That trade is fine, if you pay for it daily: stay current with main, keep the draft PR visible, and don't squash the landing.
And however you run it: write the rules as checks, not stories.
Postscript: GitHub just shipped half of this
While we were writing this, GitHub put stacked pull requests into public preview. A stack is an ordered series of PRs, each targeting the layer below it. Reviewers open any PR in the stack and see only that layer's diff. When a lower layer merges, the PRs above it stay open and automatically rebase and retarget. You can also merge the latest ready PR and land it plus every unmerged layer below it in one operation.
Map that onto the two ideas. The review half — delta-only diffs, chained bases — is now native, no branch tricks required. And the automatic rebase-and-retarget is precisely the restacking work that was our largest recurring cost, done by hand. If the preview holds up, the two biggest reasons this post exists get cheaper.
What it doesn't change is the precondition. Every layer still lands on main when it merges, so each one still has to be safe there — the work still has to ship dark. When it can't, you still need a collector, and the rules above still apply.






