A drop-in replacement for git push that also opens a pull request the
first time you push a branch, and stacks the PR correctly when your
branch descends from another open PR. Uses the official
gh CLI (must be installed).
One subcommand: push.
stackymcstackface push figures out which of three things you meant:
- Branch already has an open PR: just push the new commits.
- New branch on top of an open PR: push and open a stacked PR with the parent branch as its base.
- New branch off the default branch: push and open a regular PR.
The first case is the boring one, and that's the point: replace git push
with stackymcstackface push in your muscle memory and you never have to think about
which case applies.
You can make a shell alias like alias sms=stackymcstackface or whatever
you like for daily use. See Install.
The manual stacked-PR workflow on GitHub is not complicated:
- push your branch to the merge-target repo (not your fork, if you are on a fork)
- open a PR with the parent branch (a separate PR) as the base (not
main) - when the parent PR is merged, and that branch is deleted, GitHub automatically retargets the next PR so its base becomes
main
You can create a chain of these, which means you can keep working, doing new fixes and features in new PRs stacked on top of previous PRs, without waiting for the earlier ones to merge.
But the process has two fiddly bits that are easy to get wrong:
- The branches must live on the same remote as the PRs themselves (otherwise stacking silently breaks, which is particularly painful when working from a fork).
- When you create the PR, you have to pick the right base branch by hand, in the UI:
the parent branch in the stack, not
main.
stackymcstackface automates exactly those two steps. Reviewing and
rebasing stay the normal GitHub workflow you already know. So does
merging, with one prerequisite: when a stack PR merges, GitHub only
auto-retargets the next PR if the merged head branch is deleted through
GitHub's own merge flow. That means the post-merge "Delete branch"
button on the PR page, or the repo-level "Automatically delete head
branches" setting. Manual git push --delete origin <branch> (or the
equivalent low-level API call) bypasses the retarget logic and closes
the next PR instead. See Repo setup and
Merging a stack.
The same command in three situations:
1. First push of a new branch:
git checkout -b feat/parser
# ...commit...
sms pushOutput:
stackymcstackface
PLAN
octocat/widgets · default main · remote origin
push feat/parser and open a PR with base main
EXECUTE
✔ pushed feat/parser to origin
✔ opened pull request
https://github.com/octocat/widgets/pull/421
Pushed, PR opened. The fetch, PR scan, and push run behind spinners; pass
-v to stream git's raw output instead (handy for troubleshooting).
If your local clone is a fork of the merge target, sms push sends the branch to the parent repo (e.g. upstream), not your fork; this is handled automatically. See Caveats.
2. First push of a branch off that one:
git checkout -b feat/parser-tests # off feat/parser
# ...commit...
sms pushOutput:
stackymcstackface
PLAN
octocat/widgets · default main · remote origin
push feat/parser-tests and open a stacked PR with base feat/parser
parent: #421 https://github.com/octocat/widgets/pull/421
EXECUTE
✔ pushed feat/parser-tests to origin
✔ opened pull request
✔ noted parent #421 in description
https://github.com/octocat/widgets/pull/422
Pushed, stacked PR opened: base is feat/parser (not main), and a Stacked on #421 line is appended to the PR body.
3. Any later push to either branch:
# ...more commits...
sms pushstackymcstackface
PLAN
octocat/widgets · default main · remote origin
push new commits to feat/parser-tests
PR #422 already open
EXECUTE
✔ pushed feat/parser-tests to origin
https://github.com/octocat/widgets/pull/422
base on GitHub: feat/parser
Just pushes. No prompts, no PR mutations.
Stacking requires push access to the merge-target repo. GitHub treats a PR as stacked only when its head and base branches both live on the same repository. Your stack branches therefore have to be pushable to the repo you're merging into. Three setups:
- WORKS: Direct clone of the merge target:
originis the merge target; stacking works. This is the most common setup. - WORKS: Fork of the merge target, with push access to the parent.
sms pushautomatically detects the fork, finds the parent's remote (typicallyupstream), and pushes your branches there instead of to your fork. Stacking works. For example, an internal fork of an employer repo where the team uses the OSS-style PR-from-fork flow but contributors retain push access to the parent. In this case you would no longer use your fork withsms push. - DOES NOT WORK: Fork of the merge target, without push access to the parent. This is the standard open-source contributor flow: fork the project, push topic branches to your fork, open PRs from
you/projagainstupstream/proj. Single PRs work fine; stacking does not, and cannot. GitHub does not support cross-repo stacked PRs, so no tool,stackymcstackfaceincluded, can make this work.sms pushwill surface it as agit pushpermission error against the parent remote.
If you're in bucket (3) and want a stack, the only options are out-of-band: get push access to the parent, or land your "stack" as independent, single-parent PRs in dependency order.
For the auto-retarget to work end to end:
-
GitHub setting
delete_branch_on_mergemust be enabled.sms pushoffers to do this for you when you have admin rights; to run it by hand, once per repo:gh api -X PATCH /repos/<owner>/<repo> -F delete_branch_on_merge=true
-
Use merge commits or rebase merges, not squashes. GitHub retargets dependents for all three merge methods, but squash creates a new commit on
mainwhose content overlaps the original commits still living in the next PR's branch, so the retargeted PR comes back with conflicts you have to rebase out. See Repo setup.
stackymcstackface checks (1) at the top of every run, so you find out
before merging rather than after. If it is off and you have admin rights
on the repo, sms push offers to enable it for you (and just does so
under --yes); without admin rights it prints the manual command to hand
to someone who has them. (2) is per-merge and not detectable from the
CLI; pick the right button in the GitHub UI or restrict the repo defaults
under Settings → General → Pull Requests.
See Repo setup and Merging a stack for the details.
Whichever way you install, you also need git and a working,
authenticated gh (gh auth status should be green).
The easiest install is with
cargo binstall: it
downloads a precompiled binary from the GitHub releases, so there is no
Rust toolchain or compile step. This crate is not published to crates.io,
so point binstall at the repository instead of a crate name:
cargo binstall --git https://github.com/cjrh/stackymcstackface stackymcstackfacebinstall reads the version from the repo, finds the matching release,
and drops the stackymcstackface binary on your PATH.
No cargo binstall? Grab a precompiled binary by hand from the
releases page and
put it on your PATH, or build from source.
If you have a Rust toolchain and want to compile it yourself (for example to hack on the tool), install from the working tree:
cargo install --path .This builds and installs the binary as stackymcstackface on your PATH.
Two repository-level settings make stacking work end-to-end. Without them, the tool will still open stacked PRs, but the after-merge part of the workflow that GitHub handles will not work cleanly.
-
Enable "Automatically delete head branches". When a stack PR merges, GitHub deletes its head branch through the merge flow, which is the only deletion path that retargets dependent PRs to the merged PR's base.
gh api -X PATCH /repos/<owner>/<repo> -F delete_branch_on_merge=true
sms pushoffers to do this for you when you have admin rights on the repo. Web UI equivalent: Settings → General → Pull Requests → "Automatically delete head branches". -
Use merge commits or rebase merges, not squash, for stack PRs. GitHub auto-retargets dependent PRs the same way for all three merge methods; squash does not break the retarget itself. The problem is what lands on
main: squash collapses the parent branch's commits into one new commit whose content is the same diff that the next PR's branch also still contains as its original, un-squashed commits. After retarget, the next PR's diff againstmainre-litigates those changes against the squash commit, so already-reviewed hunks come back as merge conflicts and you have to rebase to clear them. Merge commits and rebase merges leave the original commits intact onmain, so the dependent branch lines up cleanly with no rework. Pick per-merge in the GitHub UI, or restrict the repo defaults under Settings → General → Pull Requests. -
Optional but recommended: set
git config stack.remote upstreamto disambiguate the merge-target remote name. The tool can usually figure it out by matching URLs, but this makes it explicit and avoids the fallback logic that otherwise tries to pick between multiple remotes. -
Optional but recommended: In your GitHub settings, set "Allow merge commits" and for the "Default commit message" choose "Pull request title and description". This way the merge commit gets a useful message by default, and you don't have to edit it every time, and using
git log --first-parentonmainstill shows the PR titles.
If you have admin access to the repo, settings (1) and (4) can be applied in a single PATCH:
gh api -X PATCH /repos/<owner>/<repo> \
-F delete_branch_on_merge=true \
-F allow_merge_commit=true \
-f merge_commit_title=PR_TITLE \
-f merge_commit_message=PR_BODY-F sends typed values (booleans here); -f sends strings. If gh
returns 403, you don't have admin permission on the repo; ask whoever
does, or apply the settings through Settings → General → Pull Requests
in the web UI.
The binary name is intentionally absurd. Pick a short alias for daily use.
sms is the obvious one:
# bash / zsh
alias sms='stackymcstackface'
# fish
alias --save sms='stackymcstackface'From here on the examples use sms.
sms doctor runs a read-only check of the prerequisites for push:
git and gh are on PATH, gh is authenticated, the working
directory is a git clone of a GitHub repo, the merge-target remote is
unambiguous, delete_branch_on_merge is enabled, and the current branch
is in a state push would accept. Output is colour-coded
(✔ / ⚠ / ✗); the command exits non-zero if any check failed.
Honours NO_COLOR and disables colour automatically when stdout is not
a terminal. It never fetches, pushes, or mutates anything on GitHub.
These are the design constraints behind the tool. If you do not share them, this tool is probably not for you.
-
No local state. Other stacking tools maintain a sidecar file describing the stack and its state. That file rots. Once it disagrees with reality on GitHub (which happens most once you have five or six PRs in a stack), the tooling becomes harder to fix than the manual workflow it replaced. Every invocation of
pushreconstructs the picture from authoritative sources only:git fetch,git merge-base --is-ancestor, andgh pr list. Nothing to keep in sync because there is nothing to sync. -
Push to the merge-target remote, never anywhere else. For non-fork repos that means
origin. For forks, it almost always meansupstream(or whatever you call the remote pointing at the parent). The tool figures this out by asking GitHub forisFork/parentand matching against your configured git remotes. -
Detect a wrong-remote push and offer to fix it. If you have already pushed your branch to your fork's
origin, the tool notices and prompts before re-pushing to the correct remote and switching the upstream tracking ref. -
Do as little as possible. One subcommand. Push the branch, open the PR, print the URL. That is the whole tool. There is no
submit-stack, norestack, noland, and no merge orchestration; GitHub already does those. -
Refuse to act on a dirty repo state. If the repo is mid-rebase, mid-merge, mid-cherry-pick, mid-revert, mid-bisect, or mid-
am,pushbails and tells you what it found. Uncommitted changes are fine. A common workflow is to peel one PR at a time off a large set of local changes.
Optional. By default the tool picks the merge-target remote automatically:
origin if you are not on a fork, otherwise the local remote whose URL
matches the parent repo on GitHub.
If automatic detection picks wrong, or you want to be explicit:
git config stack.remote upstreamPer-repo (the default above) or --global if you want the same name
everywhere.
git checkout -b feat/parser-cleanup
# ... edit, commit ...
sms pushstackymcstackface
PLAN
octocat/widgets · default main · remote origin
push feat/parser-cleanup and open a PR with base main
EXECUTE
✔ pushed feat/parser-cleanup to origin
✔ opened pull request
https://github.com/octocat/widgets/pull/421
# while still on feat/parser-cleanup, with PR #421 open:
git checkout -b feat/parser-cleanup-tests
# ... edit, commit ...
sms pushstackymcstackface
PLAN
octocat/widgets · default main · remote origin
push feat/parser-cleanup-tests and open a stacked PR with base feat/parser-cleanup
parent: #421 https://github.com/octocat/widgets/pull/421
EXECUTE
✔ pushed feat/parser-cleanup-tests to origin
✔ opened pull request
✔ noted parent #421 in description
https://github.com/octocat/widgets/pull/422
The parent is found by walking your branch's ancestry and picking the
closest open PR whose head SHA is an ancestor of your HEAD. The same
logic applies at any level of the stack.
You forked octocat/widgets to you/widgets and have:
origin git@github.com:you/widgets.git (fetch / push)
upstream git@github.com:octocat/widgets.git (fetch / push)
sms push will detect the fork, identify upstream as the merge-target
remote, and push your branches there, not to your fork's origin. That
is the only configuration that lets stacking work in a fork: both PR head
and base must live on the same repo.
You forgot and ran git push -u origin my-branch first. Then:
sms push ⚠ current branch tracks `origin/...` but the merge target is `upstream`.
Stacked PRs only work when the branch lives on the merge-target remote.
Re-push to `upstream` and switch tracking? (Y/n)
Answer yes and the tool re-pushes to upstream with --set-upstream.
(-y skips the prompt.)
Rebasing a stacked branch on top of its (also-rebased) parent is normal.
Re-run sms push with --force-with-lease:
sms push --force-with-leaseIf a PR for the branch already exists, the tool just refreshes the push and prints the existing PR URL. It will not try to recreate it.
Once a branch has an open PR on the merge target, sms push is just
git push: it pushes any new commits to the existing remote branch and
reports the existing PR. No prompts, no PR mutations, no extra work.
This is what makes sms push safe to use as a blanket replacement for
git push.
EXECUTE
✔ pushed feat/parser-cleanup to origin
https://github.com/octocat/widgets/pull/421
base on GitHub: main
It does not rewrite the PR's base. If a previously parent-less PR should
now be stacked under a new parent, change the base in the GitHub UI (or
delete the old PR). Keeping push from silently mutating PR bases is
deliberate.
Merge from the bottom up, one PR at a time:
gh pr merge 1 --merge # or use the web UI; do NOT use --squashWith "Automatically delete head branches" enabled (see
Repo setup), GitHub deletes #1's head branch as
part of the merge and retargets the next PR (say, #2) so its base
becomes main. Repeat for #2, then #3, until the stack is empty.
If you forgot to enable the setting, click the "Delete branch" button on the merged PR's page on GitHub. That deletion path also retargets dependents.
What you must not do: clean up merged head branches with
git push --delete origin <branch> or low-level gh api ref deletes.
Those bypass GitHub's retarget logic; the next PR in the stack will be
closed, not retargeted, and you will have to restore the deleted
branch and reopen the PR to recover.
push refuses to run, with a clear message, in any of these states:
- mid-rebase, mid-merge, mid-cherry-pick, mid-revert, mid-bisect, mid-
am - detached
HEAD - current branch is the default branch (
main/master/whatever) - repository has no commits yet
- no GitHub remote, or
ghnot authenticated - multiple local remotes match the merge target and none are named
originorupstream(setgit config stack.remoteto disambiguate) - current branch shares no history with the default branch (so there is no sensible base to pick)
sms push [OPTIONS]
-t, --title <TITLE> PR title. If omitted, `gh pr create --fill`
populates from commits.
-b, --body <BODY> PR body. Same fallback as --title.
--draft Open as a draft PR.
--web Open the new PR in a browser instead of just
printing its URL.
--force-with-lease Push with --force-with-lease. Use after a local
rebase.
-y, --yes Skip interactive prompts (assume "yes" for the
wrong-remote rescue).
-v, --verbose Show raw git output for fetch/push instead of
hiding it behind a spinner. Repeat (-vv) to also
pass git its own --verbose.
By default push groups its work into a PLAN section (what it will do)
and an EXECUTE section (pushing and opening the PR), with spinners over
the git/gh calls and the PR URL printed last. Output is colourised on
a terminal and plain when piped; NO_COLOR is honoured.
- Read the working tree state via
git. Bail on dirty operations or detachedHEAD. - Ask
gh repo viewwhether this is a fork and what the default branch is. If a fork, look up the parent repo's info too. - Pick the merge-target remote:
git config stack.remoteoverrides; otherwise match local remote URLs against the merge-target repo (handleshttps,git@, andssh://URLs, with or without.git). git fetch <merge-target-remote>.gh pr list --repo <merge-target> --state openand consider only PRs whose head lives on the merge-target repo.- For each such PR, ask
git merge-base --is-ancestor <pr-head> HEAD. The closest ancestor (smallestgit rev-list --count) is the parent. No ancestor means the base is the default branch. - Push the current branch to the merge-target remote with
--set-upstream. gh pr create --base <parent> --head <branch> --repo <merge-target>.- If the new PR is stacked on an existing PR, append a
Stacked on #<parent>footer to its body viagh pr edit, separated by a---rule. Skipped under--web(the body is finalised in the browser there).
You can read the same algorithm directly in
src/stack.rs.
These are explicitly out of scope and unlikely to ever be added:
- A stack overview or visualisation command.
gh pr listalready shows it. - A "submit the whole stack" command.
sms pushper branch is plenty. - Auto-merging, auto-rebasing, conflict resolution, or any other workflow orchestration.
- A local stack-state file of any kind. See "Design requirements".
Background on the GitHub mechanics this tool relies on:
- Pull request retargeting — GitHub's 2020 changelog announcing the auto-retarget behaviour that makes stacking work after a merge. Triggers on merged + deleted, regardless of merge method.
- About pull request merges — official docs on merge commits, squash, and rebase, including the separate "indirect merge" feature (which is merge-method-specific and is what most "squash breaks stacks" claims actually conflate).
- My workflow for stacked PRs on GitHub — Dave Pacheco walks through stacked-PR mechanics end to end and explains, with worked examples, why squash merges produce spurious conflicts in the next PR even though retargeting itself succeeds.
- Stacked pull requests with squash merge — a complementary take on the same squash-vs-stacks problem and how to recover when you can't avoid squash.
GPL-3.0-or-later. See LICENSE for the full text.
I thought I was very clever choosing a unique name, but it turns out other projects have already used it:
- https://github.com/RickCarlino/stacky_mcstackface - A stack-based VM in js
- https://github.com/mp4096/smsf - Another stack-based VM