Stacked Changes: Stacked Pull Requests on GitHub

Authors

K19G

grok-bot

Updated

October 30, 2026

Stacked Changes: Stacked Pull Requests on GitHub

Thesis

A large change is easier to review as a stack: a chain of small branches where each one builds on the one below it, and each one is reviewed as its own pull request. Git has always been able to build that chain. What used to be missing was the hosting side. Each pull request showed the whole chain’s diff, rules and CI ran against the wrong base, and merging the bottom PR left every PR above it pointing at a branch that no longer existed.

On 6 October 2026 GitHub made stacked pull requests generally available on every github.com plan, with support in GitHub Enterprise Server promised for a later release. A stack is now a first-class object on the server. GitHub knows the order, shows each layer’s diff against the layer below, evaluates rules as if every PR targeted the stack’s trunk, rebases the remaining layers when the bottom one merges, and lands any prefix of the stack in a single atomic operation. The local side is the gh stack extension for GitHub CLI.

The skill this page teaches is keeping three views of the stack in agreement: your branches, the stack metadata gh stack keeps beside them, and the stack object on GitHub. Most stack problems come from one of the three changing without the other two.

The transcripts use Git 2.47, GitHub CLI 2.46 and gh stack 0.2.1 (released 9 October 2026), on Linux. The gh stack documentation asks for GitHub CLI 2.0+ and Git 2.36+.

Mental model

        trunk            layer 1              layer 2              layer 3
   main ◀───────── auth-model ◀──────── auth-route ◀──────── auth-tests
    │                 PR #41               PR #42               PR #43
    │              base: main         base: auth-model     base: auth-route
    │              diff: models.py    diff: routes.py      diff: test_routes.py
    │
    └── rules, required checks, CODEOWNERS, Actions: evaluated as if every PR targeted main

   merge PR #43  →  #41, #42, #43 land together (one atomic operation, bottom-up)
   merge PR #42  →  #41, #42 land; #43 stays open and is retargeted to main
Piece Where it lives What it knows
Branches refs/heads/* in your clone and on GitHub Commits only. Git has no idea they form a stack
Local stack metadata .git/gh-stack (JSON) Trunk, branch order, and the commit each layer was last based on
Stack object GitHub PR order, merge state, queue state. Drives the UI, merge box, webhooks and automatic rebases
Rule (GitHub docs) Consequence
All branches in the same repository No cross-fork stacks. Contributors without push access cannot stack
A stack must have a linear history between its branches to merge After a lower layer or the trunk changes, run a cascading rebase (gh stack rebase, or Rebase stack in the merge box)
PRs merge bottom-up. Merging a PR merges every PR below it “Merge” on the top PR lands the whole stack
Merge commit / squash / rebase all supported Squash creates one commit per PR, so n PRs give n commits on the trunk
Merge queue The stack enters as one merge group. Ejecting a PR also ejects every PR above it
Rebase without diff changes Approvals are kept, even with “dismiss stale approvals” enabled
Bypass permissions Only the lowest unmerged PR can be merged with a bypass

Worked examples (Cases)

Install the extension once:

gh extension install github/gh-stack
gh stack --version
gh stack version 0.2.1

The lab below needs no GitHub repository and no network. A plain local repository called upstream stands in for GitHub: we commit to it directly to make “the trunk moved” or “a PR merged” happen, and desk-api is a clone of it. Nothing in the lab pushes. Source the setup file so your commit hashes match the transcripts:

# stack-setup.sh — source me: a local "GitHub" (upstream) and a clone of it (desk-api)
# upstream is an ordinary repository we commit to directly, so nothing in this lab pushes anywhere.
export GIT_AUTHOR_NAME=Ada GIT_AUTHOR_EMAIL=ada@example.com
export GIT_COMMITTER_NAME=Ada GIT_COMMITTER_EMAIL=ada@example.com
export GIT_AUTHOR_DATE="2026-10-13T08:00:00+05:30" GIT_COMMITTER_DATE="2026-10-13T08:00:00+05:30"
rm -rf /tmp/stack-lab && mkdir -p /tmp/stack-lab && cd /tmp/stack-lab
git init -q -b main upstream
printf 'def handler():\n    return 200\n' > upstream/app.py
git -C upstream add app.py && git -C upstream commit -qm "initial"
git clone -q upstream desk-api && cd desk-api
source stack-setup.sh

Case 1: Start a stack

gh stack init auth-model
printf 'class User:\n    def __init__(self, name):\n        self.name = name\n' > models.py
git add models.py && git commit -qm "Add User model"
✓ Created stack: main ← auth-model
  You're on auth-model (top of stack).

What's next:
  • commit your work as usual, then add a layer:  gh stack add
  • see your stack any time:                      gh stack view
  • when ready to open PRs:                       gh stack submit

gh stack init creates the first branch from the trunk and records the stack. From here on you commit with ordinary Git.

Case 2: One layer per reviewable idea

printf 'def login(user):\n    return 200\n' > routes.py
gh stack add -Am "Add /login route" auth-route
printf 'def test_login():\n    assert True\n' > test_routes.py
gh stack add -Am "Test /login" auth-tests
gh stack view --short
✓ Created branch auth-route (layer 2) with commit 576d4a2b6e114f9a1c8e322bd964323c6291278d
✓ Created branch auth-tests (layer 3) with commit 334abaf62c2030f3b831ac41c3a644cd2a0014d1
» auth-tests (current)
├ auth-route
├ auth-model
└ main

gh stack add -Am MSG NAME stages everything, commits, and creates the next layer in one step. The rule is in the docs and easy to miss: if the current branch has no commits yet, the commit lands on it. If it already has commits, a new branch is created on top. In Case 2 each current branch already had a commit, so each add created a new layer. Case 1 could have used gh stack add -Am "Add User model" instead of git commit. On a fresh auth-model the extension says so and commits in place:

⚠ Branch l1 has no prior commits — adding your commit here instead of creating a new branch

(That line comes from a separate one-layer scratch stack named l1.)

Case 3: Where the stack lives on your disk

jq . .git/gh-stack
{
  "schemaVersion": 1,
  "repository": "",
  "stacks": [
    {
      "trunk": {
        "branch": "main",
        "head": "c7ba999b678d838250f9f42aef4b8b3dcd9fe6a2"
      },
      "branches": [
        {
          "branch": "auth-model",
          "base": "c7ba999b678d838250f9f42aef4b8b3dcd9fe6a2"
        },
        {
          "branch": "auth-route",
          "base": "f789cde542e9cd2689a11b02cdc41b26b803950e"
        },
        {
          "branch": "auth-tests",
          "base": "576d4a2b6e114f9a1c8e322bd964323c6291278d"
        }
      ]
    }
  ]
}

This file is the second of the three views. It is plain JSON beside your refs. Note what it stores: for each layer, the commit it was based on, not the commit it currently points at. That is how gh stack notices that a lower layer has moved. repository is empty here because the lab’s remote is not on GitHub. In a real clone it names owner/repo.

Case 4: A review fix on the bottom layer

The reviewer of PR 1 wants a type annotation. Make the change in the layer it belongs to, not in the layer you happen to be on:

gh stack bottom
printf 'class User:\n    def __init__(self, name: str):\n        self.name = name\n' > models.py
git commit -qam "User: annotate name"
gh stack view --json | jq -r '.branches[] | "\(.name)\tneedsRebase=\(.needsRebase)"'
✓ Switched to auth-model
auth-model  needsRebase=false
auth-route  needsRebase=true
auth-tests  needsRebase=false

needsRebase=true on auth-route means “my recorded base is no longer the tip of the layer below”. On GitHub the merge box would show Rebase stack, and the stack cannot merge until its history is linear again.

Case 5: Trunk moves too; one cascading rebase fixes both

printf 'VERSION = "1.1"\n' > ../upstream/version.py                  # trunk moves on "GitHub"
git -C ../upstream add version.py && git -C ../upstream commit -qm "Bump version"
gh stack rebase
git log --oneline --graph --decorate --decorate-refs=refs/heads auth-tests
base=main   # what each PR's reviewer sees: the diff against the layer below
for b in auth-model auth-route auth-tests; do
  printf '%-11s vs %-11s %s\n' "$b" "$base" "$(git diff --shortstat "$base" "$b")"; base=$b
done
✓ Fetched latest main from origin
✓ Trunk main fast-forwarded to caee569
Stack detected: (main) <- auth-model <- auth-route <- auth-tests
Rebasing branches in order, starting from auth-model to auth-tests
✓ Rebased auth-model onto main
✓ Rebased auth-route onto auth-model
✓ Rebased auth-tests onto auth-route
All branches in stack rebased locally with main (caee569)
To push up your changes, run `gh stack push`
* cdf86eb (auth-tests) Test /login
* 4bfa7af (auth-route) Add /login route
* 9692066 (auth-model) User: annotate name
* 6459469 Add User model
* caee569 (main) Bump version
* c7ba999 initial
auth-model  vs main         1 file changed, 3 insertions(+)
auth-route  vs auth-model   1 file changed, 2 insertions(+)
auth-tests  vs auth-route   1 file changed, 2 insertions(+)

gh stack rebase fetched the trunk, fast-forwarded main, then rebased each layer onto the one below, bottom to top. The last loop prints what each PR’s reviewer sees. Every layer still shows only its own file, so nobody re-reviews models.py in PR 2.

To publish, gh stack push force-pushes each active branch with its own --force-with-lease. Its help text is explicit that this is not atomic:

Uses explicit per-branch --force-with-lease checks. Updates are not atomic: a
branch may update even if another branch is rejected. Fix the rejected branch
and run the command again; branches already updated will be unchanged.

gh stack sync (fetch, reconcile with GitHub, rebase, push, refresh PR state) pushes with --force-with-lease --atomic, so all branches move or none do. Prefer sync when reviewers are watching the stack.

gh stack submit is the step that creates things on GitHub. It pushes, creates one PR per layer with the right base branch, and links them into a stack object. The rest of this page stays local.

Case 6: What GitHub does that your clone cannot

These behaviours belong to the server. This lab cannot show them, so here they are as GitHub documents them:

  • Rules use the stack’s trunk. Required reviews, required checks, CODEOWNERS and code scanning are evaluated as if each PR targeted the trunk (usually main). A mid-stack PR is held to the same bar as the bottom one.
  • Actions see the trunk too. A workflow on pull_request for main runs for every PR in the stack, and github.event.pull_request.stack carries stack metadata to workflow expressions.
  • Merging the bottom PR rebases the rest automatically, and the next PR is retargeted to the trunk. If the trunk branch of a stack is deleted, GitHub now retargets the stack instead of closing its bottom PR.
  • Signed commits. The GA changelog and the stacked-PR reference say that a server-side Rebase stack creates signed replacement commits that keep the original authorship. The Managing stacked pull requests how-to still carried an older note saying server-side rebase commits are not signed. If your branch rules require signatures, test once on your repository, or rebase locally with gh stack rebase so your own signing configuration applies.
  • Webhooks. pull_request events include a stacked action when a PR joins a stack.
  • Other tools. Branches pushed by Jujutsu, Sapling or plain Git can be linked into a stack with gh stack link or on the website. The extension is a convenience, not a requirement.

The trap

A layer lands outside the stack, and the stack still believes it is open.

A colleague merges auth-model into main some other way: from a non-stacked PR, a squash merge done by hand, or a cherry-pick to main. GitHub never saw a stack merge, so neither your metadata nor the stack object knows that layer 1 has landed. The next cascading rebase tries to replay layer 1 onto a trunk that already contains it in squashed form:

# someone lands layer 1 on "GitHub" as a plain squash merge, outside the stack
git -C ../upstream fetch -q "$PWD" auth-model
git -C ../upstream merge -q --squash FETCH_HEAD >/dev/null
git -C ../upstream commit -qm "Add User model (#41)"
gh stack rebase; echo "exit=$?"
git status --short
gh stack rebase --abort
✓ Fetched latest main from origin
✓ Trunk main fast-forwarded to aae8b37
Stack detected: (main) <- auth-model <- auth-route <- auth-tests
Rebasing branches in order, starting from auth-model to auth-tests
⚠ Rebasing auth-model onto main — conflict
Conflict worktree: /tmp/stack-lab/desk-api
Resolve and stage files in that worktree; continuation may be run from any worktree.

Conflicted files:
  C models.py (lines 2–6)

To resolve:
  1. Open each conflicted file and look for conflict markers:
     <<<<<<< HEAD  (incoming changes from main)
     =======
     >>>>>>>  (changes being rebased)
  2. Edit the file to keep the desired changes and remove the markers
  3. Stage resolved files: `git add <file>`
  4. Continue:  `gh stack rebase --continue`

Resolve conflicts on auth-model, then run `gh stack rebase --continue`
Or abort this operation with `gh stack rebase --abort`
exit=3
AA models.py
✓ Rebase aborted and branches restored

Exit code 3 is the documented “rebase conflict” code. The conflict is not a real disagreement between two changes. It is the same change arriving twice under different hashes. Resolving it by hand (“keep both”, “keep ours”) is how stacks end up with duplicated or half-reverted code. --abort puts every branch back, which is why the last line is safe.

Fix the branches first, then fix the metadata, and only then let anything talk to GitHub:

git rebase --onto origin/main auth-model auth-tests --update-refs
git log --oneline --graph --decorate --decorate-refs=refs/heads auth-tests
gh stack unstack --local
gh stack init auth-route auth-tests
gh stack view --json | jq -r '.branches[] | "\(.name)\tneedsRebase=\(.needsRebase)"'
Rebasing (1/3)
Rebasing (2/3)
Rebasing (3/3)
Successfully rebased and updated refs/heads/auth-tests.
Updated the following refs with --update-refs:
    refs/heads/auth-route
* 6ef7b7a (auth-tests) Test /login
* 5dd7caa (auth-route) Add /login route
* aae8b37 (main) Add User model (#41)
* caee569 Bump version
* c7ba999 initial
✓ Stack removed from local tracking
✓ Adopted 2 branches: main ← auth-route ← auth-tests
  You're on auth-tests (top of stack).

What's next:
  • see the full stack:                          gh stack view
  • move between branches:                       gh stack switch
  • link these PRs into a Stack on GitHub:       gh stack submit
auth-route  needsRebase=false
auth-tests  needsRebase=false

--onto origin/main auth-model auth-tests replays only the commits above the landed layer (the third “Rebasing” step is the update-ref instruction), and --update-refs carries auth-route along. The mechanics are the same as for a squash-merged bottom branch with plain Git. See update-refs and keep-base in the rebasing chapter. gh stack unstack --local forgets the stale tracking without calling GitHub. Plain gh stack unstack would also dissolve the stack on the server. gh stack init with existing branch names adopts them in order. Then run gh stack submit. Its documented steps include updating the base branch of existing PRs, here moving the auth-route PR onto main, and creating or updating the stack object on GitHub so that it holds only the two remaining PRs.

Prevention is cheaper than cure. Land stacked work only through the stack, from the merge box or with gh stack merge. Then GitHub records the merge, rebases the layers above, and gh stack sync brings that state back into your clone: push skips merged and queued branches, and view marks them isMerged. No tool can recognise a change that landed some other way. When a cascading rebase conflicts on code you know is already on the trunk, abort and cut the layer off as shown above. Do not resolve the conflict.

The boring rule

One reviewable idea per layer, all branches in one repository. Make fixes in the layer they belong to, then gh stack rebase (or sync) and never hand-merge a layer out of order. Publish with gh stack sync (atomic) or submit, and land through the stack, bottom-up, never around it. When the three views disagree, fix the branches, then the local metadata (unstack --local + init), then GitHub.

Try this

  1. Run the lab, then add a fourth layer with gh stack add -Am "Document /login" auth-docs. Use gh stack down, up, top and bottom, and check with gh stack view --short which branch is current after each one.
  2. Repeat Case 4 with gh stack rebase --upstack from auth-model instead of a full rebase, and compare the output with Case 5.
  3. Reproduce the trap, but this time resolve the conflict with git checkout --theirs models.py, git add and gh stack rebase --continue. Inspect git log -p main..auth-tests and decide whether the result is what a reviewer approved.
  4. In a scratch repository on GitHub that you own, gh stack submit --auto --open a three-layer stack (without --open, --auto creates drafts). Merge the middle PR from the website and confirm that the bottom PR merged with it and the top PR was retargeted to main.
  5. In the same scratch repository, enable “Require signed commits” on main, click Rebase stack after moving main, and check git log --show-signature on the rewritten commits. Record which of the two documented behaviours you get.

Sources