Skip to content
GhostbranchdocsGitHub

Stacked features

Split a big change into a series of pull requests, each built on the one before.

This site’s landing page went public as three pull requests, not one: site-scaffold (the project setup), then landing-top built on it, then landing-rest built on that. Each was reviewed on its own, and each showed only its own changes. That’s a stack. The examples below use a smaller one, add-search with search-filters on top.

Start the first feature as usual. Start each feature above it --from the one below:

Terminal
ghostbranch feature start search-filters --from add-search
Started feature search-filters on add-search, at its tip 09278daf2c36.
Check it out with: git fetch shadow && git switch search-filters
Next:
ghostbranch gate add-search --repo you/project
add-search, below search-filters in its stack, has no fresh green gate; gate it first (then each branch above it, bottom up), as search-filters's gate builds on it
ghostbranch gate search-filters --repo you/project
check out with `git fetch shadow && git switch search-filters`, commit on search-filters, push it with `git push shadow search-filters` (the shadow repo, never origin), then gate it

The new feature records add-search as its parent. status and feature list draw the stack as a tree:

Terminal
ghostbranch feature list
NAME STATE DRIFT GATE PR
add-search gate_passed clean passed -
└─ search-filters gate_passed clean passed -

Gate from the bottom up: add-search, then search-filters. A feature’s gate builds on the branches below it and scans only its own commits, the ones it will publish.

You can gate a higher feature first. Its gate replays the features below it too:

Terminal
ghostbranch gate search-filters --detach
progress, on stderr
Replaying add-search (lower in the stack)
Replaying search-filters's own commits onto add-search
Scanning what search-filters would publish for leaks (preserve)
result
Gate for search-filters: CI running on candidate 03890ab7a053 (shadow PR #8); job gate/search-filters/03890ab7a053.
1 commit(s), scanned as preserve
then Next: wait for the job

The progress lines about reading the repos and pushing the candidate, and the Next block, are left out above.

But the stack can’t be promoted until each level has its own green gate, and next walks you there. When a lower gate passes, next points at the level above:

Terminal
ghostbranch wait gate/add-search/74adada8d4b1
Gate for add-search: checks passed, leak scan clean.
2 commit(s), scanned as preserve
candidate 74adada8d4b1 on shadow PR #9
Next:
ghostbranch gate search-filters --repo you/project
the gate passed; gate search-filters next: it is stacked on this one, and its gate builds on this gate

A gate goes stale when its own branch, or any branch below it, moves. Gating a lower level again with nothing changed doesn’t make the levels above stale: levels are linked by their files, not by commit IDs.

List the features bottom first. One plan covers them all, and one approval publishes every pull request:

Terminal
ghostbranch promote add-search search-filters --mode preserve --title-for search-filters="Filter search results" --plan
Plan 20261014T140613Z-50630c saved to /home/you/.local/state/ghostbranch/workspaces/default/plans/20261014T140613Z-50630c.json: 2 PRs, bottom first.
add-search (preserve): 1 public commit(s), PR on main
search-filters (preserve): 1 public commit(s), 1 dropped as empty, PR on add-search
Publish it with: ghostbranch apply 20261014T140613Z-50630c
Next:
ghostbranch apply 20261014T140613Z-50630c --yes
a person allowed agents to publish here; publish the plan

The plan lists each pull request, bottom first, with its own commits. search-filters’s gate replayed add-search’s commit below its own. In search-filters’s pull request that commit changes nothing, since it’s already on the branch below, so it’s dropped as empty. Each pull request is based on the branch below it: add-search’s on main, search-filters’s on add-search. So each shows only its own commits.

apply publishes them bottom first, then registers them as a stack. Here the stack was applied under a grant, with --keep, so both features stay private after publishing:

Terminal
ghostbranch apply 20261014T140613Z-50630c --yes --keep
two progress lines, then:
· commit 620592da8229 → you/project:add-search
✓ Published add-search to you/project (branch add-search) 2s
· commit f2217c2cbe41 → you/project:search-filters
✓ Published search-filters to you/project (branch search-filters) 2s
✓ Registered PRs #36, #37 as a native stack
Published add-search as PR #36: https://github.com/you/project/pull/36
Published search-filters as PR #37: https://github.com/you/project/pull/37
Next:
ghostbranch sync
A maintainer merges the stack on GitHub: on the top PR's page (#37), the merge button merges that PR and every PR below it. Merging a lower PR lands only part of the stack. From a terminal, `gh pr merge` refuses a stacked PR; use merge-async on the top PR: `gh api -X PUT repos/you/project/pulls/37/merge-async -f merge_method=merge`. Then sync closes the merged features and moves any branch above onto main.

The result names every pull request it published, bottom first, and next says how to merge the stack: from its top pull request.

A stack gets one Archive or Keep choice for all its levels. With --yes and no --keep, apply would have archived both: see After publishing.

  • --title-for <feature>=<title> sets one level’s pull request title. A level with more than one commit needs one, and promote says which: the PR(s) of add-search need a title (each has more than one commit and no one message names it): pass --title-for add-search=<title> [MISSING_INPUT].
  • --mode-for <feature>=<mode> sets one level’s history mode, such as --mode-for search-filters=squash.

If the list isn’t a stack that can be published together, promote stops with NOT_A_STACK and says which feature and why. Here the bottom level had no gate yet:

Terminal
ghostbranch promote add-search search-filters --plan
error: add-search's gate is missing, not green, or stale (add-search or a branch below it moved since): gate add-search, then the branches above it, then promote again [NOT_A_STACK]
Next:
ghostbranch gate add-search --repo you/project
its gate is missing or stale; gate it, then the branches above it from the bottom up (search-filters), each gate on the one below, then promote again: the plan updates them all

The other reasons are in the error list below: features out of order (order), one left out (gap), a level not restacked after the one below moved (parent_stale), and a few more.

Where GitHub offers native stacked pull requests, Ghostbranch registers the pull requests as one stack, and GitHub shows them together. status adds the stack’s number to each pull request, such as #2 open stack #4. Where it doesn’t, they’re plain chained pull requests: each is still based on the one below, but GitHub treats them as separate.

This site’s landing pages went out as native stack #4: pull requests #1, #2 and #3.

When a top feature turns out too small for its own pull request, fold it into its parent before it’s published. Its parent fast-forwards to it, and it closes:

Terminal
ghostbranch feature fold search-filters --plan
Dry run; nothing was changed. This would:
- delete refs/ghostbranch/features/search-filters from you/project-shadow (at 6f071141a6a5)
- delete refs/heads/ghostbranch-gate/search-filters from you/project-shadow (at 03890ab7a053)
- delete refs/heads/search-filters from you/project-shadow (at 40b38a96bcac)
- move refs/heads/add-search in you/project-shadow from 09278daf2c36 to 40b38a96bcac
- close PR #8 in you/project-shadow
Next:
ghostbranch feature fold search-filters --repo you/project
the preview lists what folding changes; run it to fold

Every commit ends up on the parent, so nothing is lost and no code is asked for. Fold only fast-forwards: if the parent has new commits since the feature started, it refuses with FOLD_NEEDS_REBASE and the git steps to rebase first.

When a lower feature moves, say after a review fix, the features above it are left on its old tip. feature restack replays each one onto its parent’s new tip, in order, and pushes each with a lease. Name the feature that moved, not the ones above it:

Terminal
ghostbranch feature restack add-search
Nothing to restack: every feature above add-search is on its parent's tip.

After a real restack, it lists each branch it moved, and your checkouts of those branches need git fetch, then git reset --keep <remote>/<branch>. Then gate them again, bottom up.

--onto main moves a feature off its parent: its own commits are replayed onto main, and the features above it follow. --onto <feature> moves it onto another feature the same way. Ghostbranch never resolves a conflict. When a replay conflicts, it stops, changes nothing more, and gives you the git steps:

Terminal
ghostbranch feature restack search-filters --onto main --plan
error: search-filters does not restack cleanly onto main: commit 40b38a96bcac stopped on conflicts in src/pages/search.astro. The branches restacked before it stay restacked, and search-filters is unchanged; Ghostbranch never resolves conflicts: rebase it yourself (details.steps), push it, then run ghostbranch feature restack search-filters --onto main [REBASE_CONFLICT]
Next:
git fetch shadow
git switch search-filters
git rebase --onto shadow/main 09278daf2c364e1770327ce8df39e1e8d47f3e73 search-filters
git push --force-with-lease shadow search-filters
ghostbranch feature restack search-filters --onto main --repo you/project
each step's reason left out

In this run the conflicting file had another name, and a Dry run; nothing was changed header printed before the error is left out.

A feature with features stacked on it can’t be abandoned or closed, because they’d be left on a deleted parent:

Terminal
ghostbranch feature abandon add-search
error: feature add-search has 1 feature(s) stacked on it (search-filters), so it can't be abandoned; first, for each of them: fold it into add-search (ghostbranch feature fold <child>), abandon it, or move it onto main (ghostbranch feature restack <child> --onto main) [HAS_CHILDREN]
Next:
ghostbranch feature fold search-filters --repo you/project
fold search-filters into its parent: the parent fast-forwards to it and search-filters closes
ghostbranch feature abandon search-filters --repo you/project
or abandon search-filters too
ghostbranch feature restack search-filters --onto main --repo you/project
or move search-filters onto main, dropping its parent's commits

Deal with each feature above first. For close, an abandoned feature above still counts: close it too.

On a native stack, a pull request’s merge button merges that pull request and every one below it. So merge a stack from its top pull request, and the whole stack goes in at once. A merge on a lower pull request lands only part of it. From a terminal, use merge-async on the top pull request, below.

This site’s landing stack went in that way. Its top pull request, #3 (landing-rest), was merged with the merge method merge, and GitHub merged #1, #2 and #3 together as one merge commit on main, 815a84b.

That merge went through GitHub’s asynchronous merge API. GitHub’s ordinary merge refuses a pull request that is part of a stack. Here gh pr merge tried the top pull request of another native stack:

Terminal
gh pr merge 21 --merge
GraphQL: This pull request is part of a stack and must be merged using the asynchronous merge REST API. For more information, see https://docs.github.com/rest/pulls/pulls#merge-a-pull-request-asynchronously (mergePullRequest)

The asynchronous merge takes the same merge method, merge:

Terminal window
gh api -X PUT repos/you/project/pulls/3/merge-async -f merge_method=merge

It answers at once with "status": "pending" and an ID in details.uuid, which doesn’t mean it failed: the merge finishes moments later. gh api repos/you/project/pulls/3/merge-async/<uuid> then says merged, with the merge commit’s SHA. Or reload the pull request.

A stack can also be merged from GitHub’s web page: open the top pull request and use its merge button, as this site’s docs stack shows below.

To keep the history the levels were published with in preserve mode, merge with “Create a merge commit”. “Squash and merge” also merges a stack, but it flattens each pull request to one commit. See History modes.

What “Squash and merge” did to this site’s docs stack

Section titled “What “Squash and merge” did to this site’s docs stack”

On 2026-10-13, this site’s 7-PR native stack merged in one action from its top pull request, #11, with “Squash and merge”. GitHub made one squash commit per pull request, and sync closed all seven features.

  • The seven squash commits are c144326 to 4d66c2d, merged bottom first within a few seconds. Squash kept the boundaries between the pull requests, but each pull request’s own commits were lost.
  • sync recognised the features as merged even after the squash.

So squashing a stack works. It just flattens each pull request to one commit, which a preserve publish was meant to avoid.

Chained pull requests merge one at a time, bottom first, each with a merge commit. Before you merge the next one, check that its base is main. Its base is the branch below it, and Ghostbranch never changes a public pull request’s base. If it isn’t main, change it on the pull request page first.

If a pull request is merged while its base is still the branch below, its changes land in that branch, not in main. Sync then leaves the feature open and warns FEATURE_NOT_CLOSED with the reason merged_into_parent_branch, and a person checks that branch and closes the feature.

Run ghostbranch sync between merges, so each merged feature closes and the next one moves onto main.

Run ghostbranch sync. It closes the merged features from the bottom up. This is the sync after the landing stack merged:

Terminal
ghostbranch sync
Shadow main fast-forwarded c18f2424919a -> 815a84b30f4a.
closed merged feature site-scaffold (PR #1)
closed merged feature landing-top (PR #2)
closed merged feature landing-rest (PR #3)

That sync’s output wasn’t saved. The lines above are the format Ghostbranch prints, with that sync’s commits and pull requests.

When only part of a stack merged, sync closes the merged features and moves the next feature up onto main. If that feature’s pull request branch was rewritten on GitHub, sync accepts the rewrite only when the changes are the same. Otherwise it warns PUBLIC_BRANCH_CHANGED and changes nothing.

If a feature’s open pull request still carries the commits of a merged one below it (public_pr_needs_update, after a squash merge for example), restack it --onto main, gate it, and promote it: the update replaces its pull request’s branch.

NOT_A_STACKexit 2The features given to promote aren't a stack that can be published together; error.details.reason says why and error.details.feature names where.
Causes
  • duplicate: a feature is listed twice
  • order: the features aren't listed bottom first
  • gap: a listed feature's parent is neither the feature listed just below it nor already public (for example promote a c, leaving out b)
  • parent_not_public: the bottom feature's parent is neither main nor already published
  • parent_merged: the bottom's published parent merged; run sync
  • parent_moved: the bottom's published parent changed since it was published
  • stale_gate: a listed feature has no fresh, green gate; a gate goes stale when its own branch or any branch below it moves. With error.details.update_mode, the bottom's gate built its open PR's update in another history mode (gate builds in promote.default_mode), so gating again won't help: promote that feature on its own with --mode <update_mode>, which gates it in that mode, then the branches above it. error.details.updating is true when the stack's PRs are open: the new plan updates them all.
  • open_above: two or more lines of open PRs stack on one published feature, and one plan updates one line: close or merge every other line's PRs on that feature on GitHub, then promote it again
  • parent_stale: a branch above wasn't restacked after the branch below moved; restack, gate up, promote
What to do

Run ghostbranch status to see the stack. List the features bottom first, starting from one whose parent is main or already published, with none left out. Gate each one that needs it (ghostbranch gate <feature>, bottom up), then run promote again.

HAS_CHILDRENexit 4The feature has children stacked on it (error.details.children), so it was not abandoned or closed: they would be left on a deleted parent.
What to do

Deal with each child in error.details.children first: fold it into this feature (ghostbranch feature fold <child>), abandon it (for abandon: ghostbranch feature abandon <child>) or close it (for close: ghostbranch feature close <child>), or move it onto main (ghostbranch feature restack <child> --onto main). An abandoned child blocks close, not abandon. Then run the command again.

FOLD_NEEDS_REBASEexit 4feature fold only fast-forwards, and the parent has moved on since the feature was branched from it, so nothing was folded.
Causes
  • commits were pushed to the parent after the feature was started from it or last restacked onto it
  • the parent was rewritten (rebased, amended or force-pushed)
What to do

Bring the feature up to its parent's tip, then fold again. ghostbranch feature restack <parent> does it, and stops with git steps on a conflict. Or rebase it yourself (error.details.steps): git fetch shadow && git switch <feature> && git rebase --onto shadow/<parent> <forked_at> <feature>, resolve any conflicts, then git push --force-with-lease shadow <feature>. error.details.parent and error.details.forked_at name them. Ghostbranch never resolves conflicts.

PARENT_REWRITTENexit 4The feature's parent no longer contains the commit the feature was branched from (forked_at): the parent was rebased, amended or force-pushed.
What to do

Run ghostbranch feature restack <parent> to replay the feature's own commits onto the parent's new tip (it stops with git steps on a conflict), or ghostbranch feature restack <feature> --onto main to move it off the parent. Then run the command again. error.details.parent and error.details.forked_at name them.

REBASE_CONFLICTexit 4The feature does not rebase cleanly onto public main.
What to do

Rebase the feature branch onto public main yourself, resolve the files in details.files (the commit in details.commit stopped), push, and gate again.

PUBLIC_BRANCH_CHANGEDexit 4The open PR's public branch holds commits Ghostbranch didn't publish (error.details.foreign), so the PR was not updated: the update would drop them. error.details.reason, when set, names a special case.
Causes
  • someone pushed a commit to the PR's branch
  • a review suggestion was committed on GitHub
  • Update branch on GitHub merged main into the PR's branch
  • the branch was force-pushed outside Ghostbranch
  • unrecorded_update: the branch is at an update Ghostbranch pushed whose record was never written (error.details.plan); nothing is foreign
  • adopted_missing: a preserve update would drop commits feature pull brought in from GitHub that the feature no longer has (error.details.foreign)
What to do

Bring them into the feature with ghostbranch feature pull <feature>, then gate and promote again. They are already public, so the gate doesn't scan them again; it scans your commits as always. If feature pull can't replay them cleanly, it stops with the git steps to merge them in yourself. For unrecorded_update, run ghostbranch apply <error.details.plan> on the machine that made the plan: it finds the push landed and writes the record (never feature pull, which would copy Ghostbranch's own commits into the feature). For adopted_missing, cherry-pick the listed commits back onto the feature, push it to the shadow repo, then gate and promote again. Ghostbranch never resolves conflicts, and never overwrites commits made on GitHub. error.details.foreign lists each commit's sha, author and subject.