DEV Community

Gaurav
Gaurav

Posted on

Github Stacked PR

🎯 What a “Stacked PR” Is (and Why You’ll Want One)

A stacked pull request (sometimes called a stacked PR, stacked diff, or dependent PR) is a series of PRs that build on top of each other, each one containing a small, logically‑isolated change.

main ──► A ──► B ──► C
          │      │      │
          │      │      └─ PR‑C (depends on B)
          │      └─ PR‑B (depends on A)
          └─ PR‑A (directly on main)
Enter fullscreen mode Exit fullscreen mode
  • A is based on main.
  • B is based on A (its head).
  • C is based on B, etc.

When you eventually merge the stack in order (A → B → C), each change lands cleanly, and reviewers can focus on one cohesive piece at a time.

Why Stack PRs?

Problem Stacked PR Solution
Huge, monolithic PRs that are hard to review & cause long CI times Break the work into bite‑size PRs (e.g., “feature flag”, “data model”, “UI”)
Inter‑dependent changes (e.g., a new API + its consumer) Each dependent change lives in its own PR, but they still get tested together because they are built on top of each other
Rebasing on main constantly drags in unrelated changes Only the bottom PR needs to be rebased onto main; the rest stay on top of it
Need to ship part of a larger change early Merge the first PR in the stack; the rest stay pending until they’re ready
CI resources Only the bottom PR runs the full suite against main; higher PRs can run a lighter subset because they already passed lower‑level tests

📦 The Landscape of Tools (as of 2026)

Tool / Service Key Features Installation / Setup Typical Workflow
ghstack (GitHub CLI plugin) - Creates stacked PRs automatically from a series of commits.
- Handles base‑branch updates, resolves merge conflicts, and can re‑stack after rebases.
- Works with GitHub's GraphQL API, so you get “dependent PR” links in the UI.
pip install ghstack (or brew install ghstack).
Requires a personal access token with repo scope.


bash git checkout -b feature/stacked\n# create many commits …\nghstack push\n# later, after rebasing on main\nghstack rebase

. |
| GitTown (aka git-town) | - git town ship can ship a stack of dependent branches in order.
- Not GitHub‑specific, works with any remote. | brew install git-town / cargo install git-town. |

bash git town new featureA\n# commit …\ngit town new featureB\n# work on B …\ngit town sync

|
| gstack (open‑source script) | - Very lightweight Bash script that creates a series of PRs from sequential commits.
- Good for CI‑only pipelines. | curl -L https://raw.githubusercontent.com/…/gstack.sh | bash. |

bash gstack create

. |
| GitHub’s “Draft PR” + “Depends on” labels | - No external tool needed; you manually create PRs and add a depends-on:<PR#> label (or a comment).
- GitHub UI now shows a “This PR depends on #123” banner (rolled out in early 2026). | No install. Just enable the “Pull request dependencies” preview in your org settings. | Create PR‑A → PR‑B → PR‑C, add depends-on: #A comment on B, etc. |
| pullrequest.io (SaaS) | - Managed service that visualises stacks, auto‑updates bases, and adds “stack‑status” checks.
- Works with public & private repos. | Sign up, link GitHub repo, generate a machine‑user token. | UI‑driven: select commits → “Create stack”. |
| Gerrit (if you’re on a hybrid workflow) | - Has native support for “dependent changes”.
- Works great if you already use Gerrit for code review. | Already part of Gerrit install. | Use git review -d <change-id> etc. |

Bottom‑line: If you just need a quick, “no‑install” solution, the built‑in draft‑PR + depends-on label works fine. If you want to automate the entire stack lifecycle (create, rebase, update, merge), ghstack is the most mature and GitHub‑native option in 2026.


🛠️ Step‑by‑Step Guide Using ghstack (the most popular choice)

Below is a complete workflow you can copy‑paste into a terminal. It assumes you have:

  • Git 2.40+ (or newer)
  • GitHub CLI (gh) installed and authenticated
  • Python 3.9+ (for ghstack)

1️⃣ Install the tools

# GitHub CLI (if you don’t have it)
brew install gh   # macOS
# or: sudo apt install gh   # Ubuntu

# ghstack (Python package)
pip install --user ghstack
# make sure ~/.local/bin is on your $PATH
Enter fullscreen mode Exit fullscreen mode

2️⃣ Prepare a branch and make a series of commits

# Start from the latest main
git checkout main
git pull origin main

# Create a “stack” branch (the base of the stack)
git checkout -b feature/stacked

# 1️⃣ First logical change (e.g., add a new API)
# -------------------------------------------------
echo "def hello(): return 'world'" > hello.py
git add hello.py
git commit -m "feat: add hello() helper"

# 2️⃣ Second logical change that depends on the first
# -------------------------------------------------
cat >> hello.py <<'EOF'

def greet(name):
    return f"Hello, {name}! " + hello()
EOF
git add hello.py
git commit -m "feat: add greet() that uses hello()"

# 3️⃣ Third logical change (e.g., tests)
# -------------------------------------------------
mkdir -p tests
cat > tests/test_hello.py <<'EOF'
import unittest
from hello import greet

class TestHello(unittest.TestCase):
    def test_greet(self):
        self.assertIn("Hello, Alice!", greet("Alice"))
EOF
git add tests/
git commit -m "test: add unit tests for greet()"
Enter fullscreen mode Exit fullscreen mode

Now you have three commits that you want to turn into three stacked PRs.

3️⃣ Push the stack to GitHub

# ghstack will create a separate remote branch for every commit
ghstack push
Enter fullscreen mode Exit fullscreen mode

What happens under the hood:

Commit Remote branch created PR title (auto‑derived) Base branch
1️⃣ ghstack/1 feat: add hello() helper main
2️⃣ ghstack/2 feat: add greet() that uses hello() ghstack/1
3️⃣ ghstack/3 test: add unit tests for greet() ghstack/2

Each PR appears in GitHub with the “depends on #XYZ” banner (thanks to the new UI feature). The PR descriptions contain a tiny block:

--- ghstack metadata ---
base: ghstack/2
stack: [ghstack/1, ghstack/2, ghstack/3]
--- end ---
Enter fullscreen mode Exit fullscreen mode

4️⃣ Review & Iterate

  • Reviewers see each PR separately.
  • CI runs on each branch; GH‑Actions can be configured to run only the diff against its base (fast) because the lower PR already passed the full suite.

If you need to amend the second commit:

git checkout ghstack/2   # switch to the branch created for commit #2
# Make change, amend commit
git commit --amend -m "feat: improve greet() implementation"
# Push the new version (ghstack takes care of the dependent PRs)
ghstack push
Enter fullscreen mode Exit fullscreen mode

ghstack will automatically re‑stack all downstream PRs (3, 4, …) on top of the updated commit.

5️⃣ Rebase the whole stack onto a newer main

Suppose main has moved forward and you want to keep your stack up‑to‑date:

git checkout main
git pull   # get latest
ghstack rebase
Enter fullscreen mode Exit fullscreen mode

Result:

  • ghstack/1 is rebased onto the new main.
  • ghstack/2 and ghstack/3 are automatically rebased onto the new ghstack/1.
  • All open PRs get updated automatically – no manual git push -f gymnastics.

6️⃣ Merging the stack

When every PR is approved:

# Merge bottom‑most PR first (or let GitHub auto‑merge with “Require linear history”)
gh pr merge ghstack/1 --merge
gh pr merge ghstack/2 --merge
gh pr merge ghstack/3 --merge
Enter fullscreen mode Exit fullscreen mode

Because each PR’s base is the previous PR, merging in order results in a perfectly linear history on main.

If you enable GitHub’s “auto‑merge when all checks pass” on each PR, the merges will happen automatically as soon as the lower PR merges.

7️⃣ Clean up

# Delete the temporary remote branches
ghstack delete-branches
Enter fullscreen mode Exit fullscreen mode

You’re left with a clean main branch plus the series of logical commits merged in order.


📋 Alternative Workflows (No‑Tool, “Manual Stack”)

If you prefer not to install anything, you can still get a stacked workflow using plain Git:

# 1️⃣ Create the base branch (on main)
git checkout -b feature/a main
# commit A … push and open PR‑A

# 2️⃣ Create B *on top of* A
git checkout -b feature/b feature/a
# commit B … push and open PR‑B
# In PR‑B comment: “Depends on #PR-A”

# 3️⃣ Create C on top of B
git checkout -b feature/c feature/b
# commit C … push and open PR‑C
# Comment: “Depends on #PR-B”
Enter fullscreen mode Exit fullscreen mode

Key manual steps to remember:

Action How to do it manually
Keep the base up‑to‑date git fetch origin && git rebase origin/main on the bottom branch, then git checkout feature/b && git rebase feature/a, etc.
Close a PR when you want to drop a change Delete the branch (git push origin --delete feature/b) and remove the dependent PRs (or git rebase them onto the new base).
Show the dependency in GitHub UI Add a comment Depends on #123 (GitHub renders a banner). You can also add a label depends-on:123.

Pros: No extra tooling, works everywhere.

Cons: You have to re‑base manually, risk of diverging PR bases, and you won’t get the nice “re‑stack” automation that ghstack offers.


🧩 Integrating Stacked PRs with CI/CD

1️⃣ Split CI Jobs

  • Full‑suite job runs on the bottom PR (the one that targets main).
  • Incremental job runs on higher PRs, using git diff against its base to only test the new changes.

GitHub Actions example (.github/workflows/ci.yml):

name: CI

on:
  pull_request:
    types: [opened, synchronize, reopened]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # needed for diffing

      - name: Find base commit
        id: base
        run: |
          # GH provides GITHUB_BASE_REF (the branch PR is based on)
          echo "base=$(git merge-base HEAD origin/${{ github.base_ref }})" >> $GITHUB_OUTPUT

      - name: Run incremental tests
        run: |
          # Example with pytest: run only changed files
          git diff --name-only ${{ steps.base.outputs.base }} | grep '\.py$' | xargs -r pytest
Enter fullscreen mode Exit fullscreen mode

Higher PRs get a quick feedback loop; the bottom PR still validates the full integration test suite.

2️⃣ Guardrails (Branch Protection)

  • Require “dependents merged first”: In your repo’s Branch protection rules enable the optional “Require status checks to pass before merging” and add a custom check that verifies “All dependent PRs are merged”.
  • Enforce linear history: Turn on “Require linear history”—this guarantees that merges happen in order and never create merge commits that would break the stack.

📚 Best‑Practice Checklist

✅ Practice Why it matters
1️⃣ One logical change per PR (e.g., “add API”, “add consumer”, “add tests”) Keeps review size small and makes stacking natural.
2️⃣ Name branches feature/stack‑N‑<description> (or let ghstack generate them). Makes it obvious which branch belongs to which stack level.
3️⃣ Keep the bottom PR always rebased onto main. Guarantees that merging the stack never introduces merge conflicts.
4️⃣ Add explicit “Depends on #XYZ” comments or labels (if you’re not using ghstack). Human reviewers and bots can see the order at a glance.
5️⃣ Enable GitHub’s “Pull request dependencies” preview (Org → Settings → Features). Gives the UI banner and a built‑in dependency graph.
6️⃣ Run incremental CI on top‑of‑stack PRs. Faster feedback for downstream changes.
7️⃣ Never force‑push the bottom PR without re‑stacking downstream. If you do, downstream PRs will diverge and CI will fail.
8️⃣ When a stack is ready to ship, merge bottom‑up (or enable auto‑merge with “Merge when the head branch is up‑to‑date”). Guarantees linear history and avoids “merge commit” noise.
9️⃣ Delete temporary ghstack/* branches after the stack lands. Keeps the repo tidy and avoids accidental pushes.
🔟 Document the stack in the PR description (e.g., “Stack: #12 → #13 → #14”). Future maintainers know the intent and can locate related changes quickly.

📦 Example: Real‑World Scenario (Feature Flag Rollout)

Suppose you need to:

  1. Add a feature flag in the config library.
  2. Guard a new endpoint behind that flag.
  3. Add integration tests for the endpoint.
  4. Deploy a monitoring dashboard.

You can create four stacked PRs:

PR Description Branch (ghstack) Base
#101 feat: add “new‑feature” flag ghstack/1 main
#102 feat: new endpoint (guarded by flag) ghstack/2 ghstack/1
#103 test: integration tests for new endpoint ghstack/3 ghstack/2
#104 chore: dashboard for new feature ghstack/4 ghstack/3

Team workflow:

  • Day 1: Open PR #101, get flag reviewed.
  • Day 2: Merge #101, rebase the rest automatically (ghstack rebase).
  • Day 3: Open #102, review the endpoint.
  • Day 4: Merge #102 → #103 now runs against the updated code.
  • Day 5: Merge #103 → #104, then ship the whole stack in one go if you want the dashboard to go live only after tests pass.

If a stakeholder decides to skip the dashboard for now, you simply close #104—no need to rewrite history.


🔧 Quick Reference Cheat Sheet

Command What it does
ghstack push Creates a remote branch for each local commit and opens a PR for each.
ghstack rebase Rebases the entire stack onto the current main (or any other base you specify).
ghstack status Shows the current stack layout (base → ... → HEAD).
ghstack delete-branches Deletes the temporary ghstack/* branches after the stack is merged.
git checkout -b feature/X <base> Manual way to start a new stacked branch off <base>.
git rebase <new-base> Rebase the bottom branch onto a newer base; then re‑base the children manually (git checkout B && git rebase A).
gh pr merge <num> --merge Merge a specific PR (use --squash or --rebase if you prefer those strategies).
`gh pr comment --body "Depends on

Top comments (0)