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 a stack
Section titled “Start a stack”Start the first feature as usual. Start each feature above it --from the one below:
The new feature records add-search as its parent. status and feature list draw the stack as a tree:
Gate up the stack
Section titled “Gate up the stack”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:
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:
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.
Promote the stack
Section titled “Promote the stack”List the features bottom first. One plan covers them all, and one approval publishes every pull request:
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:
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:
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.
Native stacks and chained pull requests
Section titled “Native stacks and chained pull requests”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:
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.
Restack
Section titled “Restack”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:
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:
In this run the conflicting file had another name, and a Dry run; nothing was changed header printed before the error is left out.
Abandoning or closing a lower feature
Section titled “Abandoning or closing a lower feature”A feature with features stacked on it can’t be abandoned or closed, because they’d be left on a deleted parent:
Deal with each feature above first. For close, an abandoned feature above still counts: close it too.
Merging a stack
Section titled “Merging a stack”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:
The asynchronous merge takes the same merge method, merge:
gh api -X PUT repos/you/project/pulls/3/merge-async -f merge_method=mergeIt 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
c144326to4d66c2d, merged bottom first within a few seconds. Squash kept the boundaries between the pull requests, but each pull request’s own commits were lost. syncrecognised 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
Section titled “Chained pull requests”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.
After the merge
Section titled “After the merge”Run ghostbranch sync. It closes the merged features from the bottom up. This is the sync after the landing stack merged:
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.
Errors you may see
Section titled “Errors you may see”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.
- 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
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.
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.
- 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)
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.
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.
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.
- 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)
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.