Skip to content
Corentin GS

GitHub Stacked Pull Requests: First Stack to Partial Merge

№52 · · ·2782 words ·14 min read
In this piece

Consider a feature request: “Add request IDs to every HTTP response.”

The work separates into three parts. The first introduces a RequestID type and generator. The second inserts it into middleware. The third sends it to the logger. A normal branch can hold all three commits, but the first part is ready for a review that the third part cannot yet answer.

Where do you stop making commits and start making pull requests?

You decide whether the type, middleware, and logging code deserve separate reviews before you create the first branch.

This walkthrough builds that three-layer stack, opens it on GitHub, and lands only the foundation and middleware while logging remains open.

Start with reviewable layers

The shared example has three layers:

LayerCodeReview question
request-idDefine and validate RequestIDDoes this type represent the value correctly?
request-id-middlewareCreate and attach the valueDoes the server add it at the right boundary?
request-id-loggingRecord the value in logsDoes logging receive the value without changing request behavior?

The order matters. Middleware uses the type. Logging uses the context that middleware populates. A reviewer can understand the lower layers without opening the higher ones.

That is the test to apply before creating a stack. Do not split a helper move, rename, and call-site update into three pull requests just because they happen in separate commits. Each layer should make a claim that an owner can approve, request changes to, or merge on its own.

The first article in this series explains what GitHub’s stacked pull request preview adds. This one uses the official CLI extension against a regular Git repository.

Install the extension once:

gh extension install github/gh-stack

The extension requires GitHub CLI. It stores local stack metadata under .git/gh-stack, outside the repository. The branches remain ordinary Git branches. A collaborator can use Git without installing the extension.

GitHub documents stacked pull requests as a public preview, subject to change. Recheck the current rollout in the repository where you plan to use them. The current quickstart requires GitHub CLI 2.90.0 or later, Git 2.20 or later, GitHub CLI authentication, and permission to push branches to that repository.

Create the bottom branch

Begin from an updated trunk branch. The command below creates the first branch and initializes a local stack whose trunk is main:

git switch main
git pull --ff-only
gh stack init request-id

gh stack init accepts branch names directly. With request-id, it creates that branch from main, checks it out, and records it as the bottom layer. If your repository uses develop or a release branch as trunk, pass it explicitly:

gh stack init --base develop request-id

Add the type. It must compile and work without the middleware or logger:

package requestid

import (
	"crypto/rand"
	"encoding/hex"
	"errors"
)

type RequestID string

func NewRequestID() (RequestID, error) {
	var bytes [8]byte
	if _, err := rand.Read(bytes[:]); err != nil {
		return "", err
	}

	return RequestID(hex.EncodeToString(bytes[:])), nil
}

func ParseRequestID(value string) (RequestID, error) {
	if len(value) != 16 {
		return "", errors.New("request ID must contain 16 characters")
	}
	if _, err := hex.DecodeString(value); err != nil {
		return "", errors.New("request ID must be hexadecimal")
	}

	return RequestID(value), nil
}

Commit the coherent bottom layer:

git add internal/requestid
git commit -m "Introduce RequestID"

At this point, request-id is an ordinary local branch based on main. The extension knows its position. GitHub has no branch or pull request to display.

Add dependent layers on top

Create the middleware branch only after the lower layer has a commit:

gh stack add request-id-middleware

The command creates request-id-middleware at the current HEAD, adds it above request-id, and checks it out. Implement the middleware and commit it:

git add internal/http/requestid.go
git commit -m "Add request-ID middleware"

Repeat the operation for logging:

gh stack add request-id-logging
git add internal/logging
git commit -m "Log request IDs"

The local graph now has a simple order:

request-id-logging       → logging changes
request-id-middleware    → middleware changes
request-id               → RequestID type
main

Run gh stack view before you publish. It shows which branch is at the bottom, which one is at the top, and how the extension understands their parent relationship.

gh stack view

For a script or a bug report, ask the extension for machine-readable state:

gh stack view --json

Capture the view at three points: after initialization, after adding the last branch, and after a partial merge. The snapshots show the local order, GitHub’s pull request links, and the remaining branch’s base.

Branch names serve as labels. A branch called request-id-middleware can point anywhere after a manual rebase. Use gh stack view and the pull request base to confirm the relationship the tooling will operate on.

Keep those snapshots with the pull request URLs when you try the preview in a repository. They make a changed base or unexpected rebase visible before a review becomes hard to reconstruct.

Record the GitHub CLI and extension versions beside the snapshots. Preview behavior can change before this post’s scheduled publication date.

Pause before submitting. Compare the graph with the code dependencies. The logging branch must descend from middleware. The middleware branch must descend from the type. If middleware and logging form one review decision, squash their commits or use one branch.

Submit the stack

Push the branch chain and open the pull requests:

gh stack submit

gh stack submit pushes the branches, creates or updates one pull request per branch, and links them into a GitHub stack. The base branches become:

request-id-logging PR       base: request-id-middleware
request-id-middleware PR    base: request-id
request-id PR               base: main

In an interactive terminal, gh stack submit opens an editor for the new pull requests. Use it to set layer-specific titles and descriptions. In automation, gh stack submit --auto --open skips that editor and creates pull requests ready for review.

The top branch contains the code from every layer beneath it. GitHub compares it with request-id-middleware, so the logging pull request shows logging changes only.

That comparison is why a stack helps. The first reviewer can approve the type without reading HTTP code. The middleware reviewer sees request setup without logger details. The logging work can continue while the lower pull requests collect feedback.

Use gh stack push when the branches need publishing but the pull request set already exists. Use gh stack submit when you want GitHub to create or update the pull requests. Use gh stack sync to fetch, reconcile remote state, rebase if the trunk moved, push, and sync pull request state.

What I verified in the preview

I ran this workflow in a public three-layer Go repository on 2026-08-06 with GitHub CLI 2.97.0 and gh stack 0.1.0. gh stack submit --auto --open created three pull requests and reported one GitHub stack. A bottom-layer correction followed by gh stack rebase --upstack --no-trunk rebased the middleware and logging branches; gh stack push pushed all three changed tips.

I also squash-merged the bottom pull request, then ran gh stack sync. The extension fetched main, skipped the merged branch, rebased the two remaining branches, pushed them, and kept the two upper pull requests open. The command output is the behavior described in the partial-merge section: the stack gets smaller from the bottom.

One setup detail is easy to miss. gh stack init request-id request-id-middleware request-id-logging can create all three empty branches at once, but commits added later to the bottom branch do not appear in the higher branches until you rebase them. Create and commit one layer at a time with gh stack add, as this walkthrough does, or run gh stack rebase --no-trunk before attempting to compile a dependent branch.

The evidence repository’s GitHub Actions run for the middle pull request remained queued during the observation window. It proves that a pull request can trigger its configured workflow; it does not establish a CI duration or success claim. Verify that behavior, including your repository’s required checks and merge queue, before using a stack to reduce CI cost.

Review one layer at a time

Start at the bottom when you need the full story. request-id establishes the value’s representation, validation, and tests. Once that layer is clear, request-id-middleware can be judged against a stable API. The logging layer comes last because it relies on the context boundary already established below it.

Each pull request needs a description that names its local claim:

Pull requestDescription that helps
request-id“Adds a validated request-ID value type. No HTTP behavior changes.”
request-id-middleware“Creates and attaches a request ID for each inbound request.”
request-id-logging“Includes the request ID in structured logs without changing response handling.”

Those descriptions stop a common stack failure: asking reviewers to approve the feature rather than the layer in front of them. The feature may require all three pull requests. A review comment belongs where the code and decision belong.

Read the changed files first, then inspect the base branch in the pull request header. A middleware pull request based on main is a normal pull request with too much diff, even if its branch name sounds stacked. A logging pull request based on request-id-middleware should not repeat the type and middleware diff.

Run checks on every layer. GitHub applies the bottom pull request’s base-branch requirements throughout the stack, but each pull request can still fail for a different reason. The type test may pass while the middleware test exposes an invalid-header path. The logger may compile while the structured field name violates an observability convention.

Review the bottom pull request while the dependent implementation changes. The lower layer can collect feedback before the whole feature settles.

Know when one pull request is smaller

A stack adds branch bases, pull request navigation, and CI work for each layer. Use a normal pull request when the change has one review question.

Keep a small bug fix, dependency bump, revert, or tightly coupled refactor in one branch. Use separate pull requests against main for independent work. A stack encodes a required order, so inventing one for unrelated changes makes review and merge more constrained without reducing complexity.

The request-ID example earns a stack because the type can merge before middleware, and middleware can merge before logging. Use one pull request when those boundaries cannot merge safely or stand as separate reviews.

GitHub merges contiguous layers from the bottom. You can merge the bottom layer alone, the bottom two together, or the entire stack from the top.

After any partial merge, treat the remaining pull request as a fresh review point. Its diff can stay small while its claim changes beneath it.

Check the merge boundary first

GitHub treats the middleware pull request as a request to merge the two lowest layers. Before you press merge, inspect the stack map and the merge box from that pull request.

The selected pull request and every unmerged pull request below it need approval and passing checks. GitHub also requires a linear stack history and applies the base branch’s protection requirements. A green middleware check does not compensate for a failing request-id check below it.

Use this review pass:

  1. Read the request-id diff and confirm that its type and generator can merge without middleware.
  2. Read the request-id-middleware diff with its base set to request-id.
  3. Confirm the required checks and approvals on both pull requests.
  4. Check that request-id-logging remains open and still bases on the middleware branch.

If someone pushed a lower branch after GitHub created the stack, the history may no longer be linear. GitHub shows a Rebase stack action in that case. Rebase the stack before merging so each branch contains the current tip of the layer below it.

Merge a partial stack only after its lower layers pass. A middleware change needs the type it imports on main.

Land a partial stack

Suppose the type and middleware pull requests are approved. The logging layer still needs a decision about whether to expose request IDs in every log line.

Open the middleware pull request on GitHub and merge it. It is the highest layer you want to land. GitHub merges request-id first, then request-id-middleware. It leaves request-id-logging open.

before

logging PR        → middleware branch
middleware PR     → request-id branch
request-id PR     → main

after merging middleware PR

logging PR        → main
main              ← RequestID + middleware

GitHub rebases and retargets the remaining pull request onto main. The logging pull request becomes the next review boundary instead of a branch pointing at a merged feature branch.

Back in the repository, synchronize before editing the remaining layer:

gh stack sync

Inspect the resulting branch and its diff. A partial merge changes the base under the logging work. Read the diff, run the relevant checks, and resolve any conflict caused by a changed API assumption.

Partial merging lets the team land a lower layer when it reduces risk while the higher layer remains open.

Repair the stack without archaeology

Review often changes the bottom layer. Perhaps a reviewer asks for RequestID to reject a reserved value. Edit the branch where the concern belongs, commit the fix, then synchronize:

git switch request-id
# edit and test
git commit -am "Reject reserved request IDs"
gh stack rebase --upstack
gh stack sync

gh stack rebase --upstack rebases branches above the revised bottom layer. gh stack sync pushes the rebased branches and refreshes pull request state. Inspect every affected diff and decide whether the upper layers still state the same review claim.

Recover when a rebase finds a conflict

Assume the middleware branch also validates request IDs. The new reserved-value rule may overlap with that validation when gh stack rebase --upstack rewrites the branch.

The extension pauses the cascading rebase and reports the conflicted files. Resolve the conflict in the middleware layer, stage the resolution, then continue:

git add internal/http/requestid.go
gh stack rebase --continue

Continue through any later conflicts in the same order. The logging branch may need a separate resolution because it relies on the middleware API rather than on the type directly.

Abort only when the lower-layer change itself was wrong:

gh stack rebase --abort

--abort restores the stack to the state before the rebase. It keeps the commit you made on request-id. Amend or revert that commit, then start the rebase again. Make the lower change, rebase descendants, inspect each diff, resolve concrete conflicts, then synchronize the pull requests.

CI follows the branches. A push to the type branch can run its tests. A middleware push can run request-path tests, and a logging push can run logging checks. Your repository chooses which workflows trigger, so check a middle pull request before treating a stack as a way to reduce CI cost. GitHub can enforce the bottom pull request’s base-branch rules across the stack. Three review layers can still produce three workflow runs.

Treat each result as a layer-specific signal. A failing middleware test may expose a request-boundary error while the type remains sound. A logging failure may show that structured fields changed, not that request creation broke. Put the fix in the branch that owns the failed behavior, then rebase and re-run the checks above it. That preserves the reason for each pull request instead of turning the stack into one large repair branch.

Reviewers can trace each correction to its purpose.

Finish the remaining layer

After the team decides what belongs in logs, finish request-id-logging as an ordinary branch. Run its checks, read the pull request diff against main, and merge it from GitHub. You can also merge from the extension:

gh stack merge

The command prompts for the pull requests to merge and a merge method. To merge without the prompt, choose the method explicitly:

gh stack merge --yes --squash

GitHub evaluates repository rules when the merge runs. A blocked approval or failed check stops the operation. The extension cannot bypass those requirements.

After the final merge, prune local branches:

gh stack sync --prune

gh stack sync --prune fetches the remote state, synchronizes the stack, and removes local branches for merged pull requests. Run it from a clean working tree. GitHub adds a stack to a merge queue in order. When the stack exceeds the queue’s 50 percent merge-group buffer, GitHub splits it across consecutive groups. Test that path in the target repository before making a stack part of a release process.

For a single unpushed experiment, a normal git rebase may be enough. Use the stack workflow when the branches already have reviewers, checks, and pull request bases that need to remain aligned.

Use one merge decision at a time

A review decision is the stack’s useful unit.

Before you add a layer, ask:

  1. Can its code depend only on main and the layers below it?
  2. Can a reviewer understand its diff without opening the layers above it?
  3. Can the team merge it while the next layer remains unfinished?

If all three answers are yes, create the branch with gh stack add. If not, keep writing the current layer.

Try this on a small change first: a shared type, one consumer, and one optional follow-up. Submit the stack. Merge the consumer while the follow-up remains open. Then run gh stack sync and inspect the rebased branch. That exercise shows whether your repository’s required checks, ownership rules, and review habits fit the workflow before you split a release-critical change.

The next article, Stacked Changes with Jujutsu, starts from the same dependency chain and keeps it local until a layer needs an audience. GitHub stacks make the published branch graph easier to operate. Jujutsu changes where you decide that graph should begin.

Sources

Explore this subject

More on Systems & tooling