GitStack vs GitHub stacked pull requests
A comparison of GitHub's stacked pull requests with GitStack, and what it means in practice that GitHub builds every stack out of pull requests and branches.
GitHub's stacked pull requests and GitStack both split one change into small reviews that build on each other and land in order. They differ in where those reviews live. In October 2026 GitHub made stacked pull requests generally available, and in a GitHub stack each review is a pull request with a branch of its own. In GitStack the reviews are slices of one branch, and a manifest stored in the repo lists which commits on that branch belong to which review.
The two designs behave differently when a review in the middle of a stack changes. In GitStack the fix becomes a new version of that one review, and the reviews above it move onto it as they were. Because GitStack knows which commits belong to each review, it can check that their own code is the same and mark them as only moved. In a GitHub stack the fix has to be rebased into the branch of every pull request above it and force-pushed, so each of those pull requests gets a new head commit. GitHub treats each new head as a new version of that pull request, whether its own code changed or only the code beneath it. As a result, reviewers get asked to look again at code that didn't change, CI runs on pull requests nobody touched, and the reviewer of an upper pull request can't see what changed.
A side by side comparison#
To better illustrate the difference let's build an example bookmarks API in both tools, with a model, an in-memory store, and an HTTP endpoint. Each feature will be built on the one below it and we will put up a single stack in each system. On GitHub each piece gets a branch, and each pull request targets the branch below it.
gh stack init bookmarks-model
git add -A && git commit -m "bookmarks: model"
gh stack add bookmarks-store
git add -A && git commit -m "bookmarks: in-memory store"
gh stack add bookmarks-api
git add -A && git commit -m "bookmarks: GET/POST /bookmarks"
gh stack submit --auto --open
That makes three branches and three pull requests. In GitStack the three
pieces are three reviews on one branch. Each gs commit below makes one
commit, and --review turns that commit into a review titled by -t. The
model becomes R1, the store R2, and the endpoint R3.
gs switch -c bookmarks
gs add bookmark.go
gs commit -m "bookmarks: model" --review -t "bookmarks: model"
gs add store.go
gs commit -m "bookmarks: in-memory store" --review -t "bookmarks: in-memory store"
gs add handler.go
gs commit -m "bookmarks: GET/POST /bookmarks" --review -t "bookmarks: GET/POST /bookmarks"
So far the only difference so far is the number of branches. However the two tools start to behave differently once a reviewer asks for a change.
Fixing a review comment#
On GitHub, a fix to one pull request force-pushes the branch of every pull request above it. Suppose a reviewer leaves a comment on the store's pull request that we decide to fix. We check out the store's branch, amend the commit, and push the stack.
gh stack checkout bookmarks-store
git commit -a --amend --no-edit
gh stack rebase --upstack
gh stack push
Amending replaces the store's commit with a new one. The endpoint's branch was
built on the old commit, so gh stack rebase rebuilds the endpoint on top of
the new one, and gh stack push force-pushes both branches.
A force-push replaces a branch's commits instead of adding to them, so each pull request's timeline records a force-push event, the replaced commits drop out of the pull request, and comments on lines that changed are marked outdated.
GitHub runs a pull request's CI every time its branch gets a new head commit.
It has no way to know that the endpoint's own code didn't change, so both
force-pushes start CI. Sometimes the endpoint's pull request can run CI twice
on the same commit, which is an open
issue in gh-stack that
concurrency settings don't prevent. The reviewer's comment on the store is
marked outdated and stays unresolved until someone resolves it by hand,
because gh stack has no way to tie a commit to the thread it answers. A fix
to the model review, at the bottom of the stack, is worse, since the author
has to force-push all three branches, which starts five CI runs, two of them
repeats on identical commits.
In GitStack the same fix is one push of one branch. The <thread> below is
the ID of the reviewer's comment, which gs review comments R2 lists.
gs checkout r2
gs add store.go
gs amend --no-edit --resolves <thread>
gs push
gs push prints a line for each review the push updated.
R2 patchset 2 (revised)
R3 patchset 2 (rebased)Every push saves a new version of each review it touches, called a
patchset, and GitStack labels each patchset by what changed in it. It
compares the review's own diff with the diff in its previous patchset. In our
example the store's diff changed, so its patchset is revised, its approval
resets, and the reviewer looks again. However the endpoint's review diff is
the same and only the commit under it moved, so its patchset is rebased and it
keeps its approval. The comment thread closes in the same push, because
--resolves ties the fix to it.
On a repo mirrored to GitHub, that push runs CI once, on the top of the
branch, since there is one branch with one new head. The endpoint's run
includes the store's fix below it. A lower review isn't forced to get a run of
its own unless someone asks for it with gs review ci R2.
Landing the bottom pull request#
The model's pull request is approved, so we land it while the store and the endpoint are still in review.
gh stack merge 1 --squash --yes
gh stack sync
When the model's pull request merges, GitHub points the store's pull request
at main and rebases the store and endpoint branches on its servers. The
rebase writes new commits, so GitHub force-pushes both branches, and each
force-push starts CI on its pull request. The copies of those branches on our
machine still have the old commits, so gh stack sync rebases them again and
force-pushes both a second time, with new commit hashes for the same code.
On GitHub, landing the bottom pull request force-pushes the rest of the stack twice. This results in four force-pushes and five CI runs on the two pull requests that didn't merge. Every pull request still in the stack goes through both rounds, so a taller stack makes this worse for each pull request above the one that landed.
The way around this on GitHub is to wait until every pull request is approved and
land the stack with a single gh stack merge, which GitHub does atomically
and without the extra rebases. However, that gives up one of the reasons to
stack, since the model review can't be merged until the endpoint's review is
done.
It's less clear what happens to the approvals on the two pull requests left in the stack. GitHub's documentation says an approval survives a rebase that leaves the diff unchanged, but it doesn't say the same for part of a stack merging, and users have been asking GitHub to stop dismissing approvals when that happens. GitHub says they're working on it though.
The most-reacted open bug in gh-stack is
stack merges failing because
an approval went stale. The error in those reports says the review "is
stale because it was submitted before the merge base changed." GitHub
dismissed the approval because the branch under the pull request moved, since
it can't tell that apart from a change to the pull request's own code.
These are just a sample of a few issues that can occur when dealing with branch and PR backed stacks because fundamentally branches are and PRs are intended to be independent. GitHub has chosen to bolt stacks on top of them and it leads to a bunch of weird edge cases because the abstraction is wrong.
In GitStack, gs submit lands the bottom review.
gs submit R1
GitStack lands R1 and rebases R2 and R3 onto the new main on the server. R2
and R3 are slices of one branch rather than branches of their own, so there
are no review branches to force-push. GitStack records their new patchsets as
rebased, which keeps their approvals, and resets our local branch to match in
the same command, so there's no second push.
Comparing versions#
The force-pushes from fixing and landing also change what the endpoint's reviewer sees when they come back to its pull request.
After a restack, GitHub can't show the endpoint's reviewer what changed.
The endpoint's Files changed tab compares it against the store's branch, with
no option to compare it against main. Worse, its commit picker lists only
the endpoint's current commit, so you can't compare any changes to the stack
that may have occurred. The force-push replaced the commit the reviewer
commented on, so that commit is gone from the list, and "Changes since your
last review" shows an empty diff, with nothing to say the store changed
underneath. The old version survives only as a force-push event in the pull
request's timeline. Each event shows the diff between the old and new
commits, but it's read-only, so the reviewer can't leave a comment on it.
That makes it hard to follow how a pull request got to its current state. Suppose the reviewer approved the endpoint, then the store changed twice under it and the endpoint got one small fix of its own. Every one of those changes force-pushes the endpoint's branch, including the two that only happened lower in the stack. When the reviewer comes back, the commit picker shows one commit, and the three versions since the approval exist only as three force-push events in the timeline. To find the one line in the endpoint that changed, the reviewer has to open those diffs one at a time, work out which force-push came from the store and which from the endpoint, and then go back to the Files changed tab to comment, or read the endpoint again from the top. The same gap shows up later, when someone tracing a bug wants to see the version of the pull request that was approved. With an agent pushing a new version after every round of feedback, those force-push events pile up quickly.
In GitStack the stack is one branch, and GitStack keeps every patchset of
every review in its own record instead of in branch history, so a rebase
doesn't replace anything the reviewer saw. The review page compares the
endpoint against main, against the review below it, or against any earlier
patchset of itself, so the reviewer can pick the version they approved and
see exactly what changed since. The endpoint's new patchset is labeled
rebased, which tells its reviewer there is nothing new to read before they
open the diff.
Reverting a stack#
GitHub reverts a stack one pull request at a time. Suppose we find a bug
after merging and the bookmarks feature has to come out. But GitHub has no
command to revert a stack. Reverting the endpoint, at the top, opens a revert
pull request against the store's old branch with no files changed. If the
repo deletes branches after a merge (GitHub's "Automatically delete head
branches" setting), GitHub reports the pull request as not revertable. Either
way the developer reverts the feature by hand, with a new branch off main, a
git revert of each squash commit from the top down, and a new pull request
for the result. I cannot emphasize enough how messy and dangerous this is for
any stack beyond just two or three reviews. It is incredibly easy to mess this
up as reverts typically happen under a stressful environment anyways.
In GitStack one command reverts the stack.
gs revert --branch bookmarks
It creates a revert-bookmarks branch with one revert review per landed
review, top first, and the revert lands like any other stack. GitStack knows
which reviews landed from the bookmarks branch and in what order, so it can
revert them as one stack on one new branch, with no old review branches to
target.
Why GitHub's stacks work this way#
GitHub's review is the pull request, so its stacks have to be built out of pull requests. A pull request is a request to merge one branch into another, and its diff is the difference between those two branches. Its threads, approvals, and merge button belong to that pair. A stack of pull requests is a stack of those pairs, and each one only knows its own base.
In our bookmarks example, when the store's pull request changes, the endpoint's branch has to be rewritten, and GitHub sees a new head on the endpoint's pull request with nothing to say whether the endpoint's code changed or only the store's did. Because GitHub can't tell those apart, it treats every rewritten branch as new code. That's why it force-pushes and reruns CI on pull requests nobody touched, why approvals need a special rule to survive, and why the commit picker loses the version the reviewer saw.
That's a poor foundation for stacking, and it raises the question of why GitHub built it this way. I think GitHub has to push stacking through the pull request because the pull request is what GitHub is, and that model doesn't fit a stack. I expect it to keep producing behavior like the commit picker above, and the maintenance grows with every pull request added to a stack. In the end, GitHub is built around the pull request, and it can't undo that. However, the way we write code now no longer fits the pull request, and I don't think GitHub will be able to meet the needs we have now. That's why I built GitStack.
GitStack keeps the stack on one branch and records which commits belong to which review, so it can tell the review that changed from the reviews that only moved. That is how it labels a version rebased or revised, and why approvals and threads stay with the review the change touched. The gap between the two models widens as agents write more of the code, because an agent builds a feature as a stack and revises it every time feedback comes in. On GitHub each of those revisions force-pushes every branch above it and asks reviewers to look again. On one branch, each revision stays in the review it changed, and the reviews above it keep their approvals.
Try GitStack on your next stack#
Agents are now writing code faster than at any point in history. No one can sustainably read a thousand-line pull request, and the number of modifications to existing PRs are only increasing. The PR has outlived its usefulness and we need to build tools that work more naturally in the age of AI. I think stacking is where code review goes from here, because small reviews that each do one thing are what a reviewer can read closely and we can more effectively manage human attention and code quality as the amount of code goes up. GitHub has added stacks, but it built them out of pull requests resulting in a poor user experience with a lot of wasted work, force pushes, and edge cases to think about. I don't think the pull request is the right abstraction for that work anymore, and I think it's time for a tool built around the stack.
If you stack pull requests on GitHub today, try your next feature on
GitStack. gs repo import github owner/repo brings in an existing repo, and
GitHub stays in the loop, so anyone still on GitHub can keep opening pull
requests.