README
ΒΆ
π₯ pancake
Stateless stacked-PR management, derived from your git graph.
No server. No account. No stored lineage. pancake figures out your whole stack
every time from the commit graph itself, so it works identically in any clone β
including throwaway worktrees / APFS workspaces β with zero setup.
You give it two things you already know: the top branch of your stack and the
trunk it targets (default origin/master). It derives the rest.
$ pancake list feature/dev-67
feature/dev-43
feature/dev-37
feature/dev-38
β¦
feature/dev-67
Why
Stacked PRs are great; managing them by hand (rebasing every branch onto master
after each merge, moving refs, force-pushing the lot) is tedious. The existing
tools each ask for something we don't want:
| Tool | Trade-off |
|---|---|
| Graphite | Proprietary; requires an account and server-side stack state. |
| git-town | OSS & good, but stores lineage in local git config that doesn't travel to fresh/ephemeral clones, and expects you to adopt its create commands. |
| pancake | Stateless. Nothing stored β the stack is read from the graph. Use it on any branch, in any clone, immediately. |
How it works
Four git plumbing calls behind a nice UX:
- discover the stack β
git for-each-ref --merged <top> --no-merged <trunk> - fork point β
git merge-base <trunk> <top> - restack β
git rebase --update-refs --onto <trunk> <fork> - submit β
git push --force-with-lease
Because sync rebases onto the latest trunk, any commit already contained in
trunk (i.e. whatever just merged) replays empty and is dropped β so you never
have to tell it which branch merged.
Squash-merges of multi-commit branches
Squash-merging a single-commit branch is trivial: it drops as an empty replay.
A multi-commit branch is the tricky case β its N commits collapse into one
commit on trunk, so naively replaying the individual N conflicts. sync finds
the boundary and starts the replay above the squashed branch automatically:
- via the merged branch's leftover local ref (git prunes only the remote), and
- failing that, via patch-id β a squash commit shares its patch-id with the range it collapsed, so it works even in a fresh clone with no local ref.
If a squash can't be matched (e.g. conflict resolution was baked into the merge),
sync tells you and you name the boundary explicitly:
pancake sync feature/top --from <the squash-merged branch's pre-merge tip>
Commands
pancake list [top] [trunk] print the stack, bottom -> top
pancake log [top] [trunk] PR-aware stack (via gh) + decorated graph
pancake sync [top] [trunk] fetch+prune, restack onto trunk, move every ref
pancake submit [top] [trunk] force-push (--force-with-lease) every branch
pancake doctor check the GitHub prerequisites for stacked PRs
pancake install symlink git-stack/git-pancake so `git stack β¦` works
Flags (before positionals): --trunk <ref> --remote <name> --dry-run --json --trace[=json]
list --json emits [{branch, sha, commitsAboveTrunk, subject}], ordered
bottom β top β for scripting and for piping into other tools.
log annotates each branch with its PR number/state/base (via gh) and flags
any PR whose base isn't the branch directly below it β the misconfiguration you
most want to catch in a stack. If gh is missing or unauthenticated it degrades
to the plain decorated graph.
git stack β the native-feeling alias
pancake is a mouthful to type. pancake install symlinks git-stack and
git-pancake next to the binary, so git picks them up as subcommands:
pancake install # one time (the binary's dir must be on PATH)
git stack sync # == pancake sync β auto-detects the top branch
git stack submit
git stack log
git runs git-stack for git stack β¦, and pancake ignores argv[0], so the
symlink is the whole trick β no wrapper, no extra process.
Per-repo defaults: .pancake
pancake defaults its trunk to origin/master, but plenty of repos default to
main, dev, or develop β and stacking onto the wrong branch is a silent,
maddening failure. Rather than pass --trunk every time, commit an optional
.pancake file at the repo root:
# .pancake β pancake defaults for this repo
trunk = origin/dev
remote = origin
- Precedence:
--trunk(flag/positional) β.pancakeβ built-inorigin/master. - It's committed and versioned, so a fresh or ephemeral clone gets the right default from the first second. It configures the default target only β the stack itself is still derived from the graph, so this doesn't touch the stateless contract.
pancake doctorshows where the trunk came from and, if you didn't configure one, warns when pancake's built-in default doesn't match the repo's default branch (the real trap). A deliberate override is reported as intentional, not an error.
Prerequisite: auto-delete head branches
Stacked PRs on GitHub depend on one repo setting pancake cannot substitute for β
Settings β General β "Automatically delete head branches" (delete_branch_on_merge).
Without it, when a base PR merges, GitHub does not retarget the PR stacked
above it: the merged branch lingers, the dependent PR keeps the wrong base, and
the stack silently rots. pancake won't flip org settings for you, but it will
tell you when it's off:
pancake doctor # β/β gh auth, delete_branch_on_merge, trunk == default branch
pancake doctor --fix # enable delete_branch_on_merge for you (via gh)
submit also warns if the setting is off.
Omit [top] and pancake infers it: the tip of your stack (the unmerged branch
nothing else is built on). With one stack that's unambiguous; with several it
picks the one containing your current branch, else asks you to name it.
Seeing where the time goes
pancake is a thin wrapper over git, so a slow run means a slow git command β
and which one is wildly repo-dependent (history depth, remote ref count, whether
a commit-graph exists, working-tree size, hooks). --trace times every git
call and prints an end-of-run summary; --trace=json emits a machine-readable
object. Set GIT_TRACE2=1 alongside it to see git's own internal phases.
pancake sync feature/dev-67 --trace # per-call timings + slowest phase
pancake sync feature/dev-67 --trace=json # structured, for collecting/diffing
PANCAKE_TRACE=1 pancake sync feature/dev-67 # same, via env
Typical loop, after the bottom PR merges
pancake sync feature/dev-67 # fetch+prune, restack the rest onto master, move all refs
pancake submit feature/dev-67 # force-push every branch β every PR updates at once
Install
go install github.com/devergehq/pancake@latest
pancake install # expose it as `git stack β¦` / `git pancake β¦`
pancake install symlinks the shims next to the binary rather than copying
them, so a later go install upgrades every entry point at once. Copies drift:
go install would replace pancake and leave git-stack on whatever it was
built from, which is how you end up running two different versions of the same
tool depending on how you invoked it.
Knowing what you're running
A stale binary is the one failure mode that looks exactly like a bug in pancake β an already-fixed problem reappears and nothing on screen says why. So pancake reports what it actually is:
$ pancake --version
pancake v0.3.0 (none) # go install β module version
pancake v0.3.0 (206edaa23e67+dirty) # local build β commit, and whether the tree was dirty
pancake v0.4.0 (abc1234) # release build β tag stamped by goreleaser
pancake doctor repeats it, and for a release build compares it against the
latest published release:
β pancake version v0.4.0 (latest release: v0.4.0)
β pancake version v0.3.0 (none) β STALE, latest release is v0.4.0
Local and dirty builds are reported as not comparable rather than implied to be
current, and doctor never claims staleness it couldn't verify (no gh, no
network β it says so and passes).
Shell prototype
contrib/git-stack is the original POSIX-shell prototype these commands grew
from β handy if you want the workflow without building the binary. Source
contrib/pr-stack.sh from your shell rc for prs-list / prs-sync / prs-submit.
Status
v0 β see the Pancake project in Linear.
License
MIT Β© Deverge
Documentation
ΒΆ
Overview ΒΆ
Command pancake is a stateless stacked-PR manager.
It manages a stack of stacked pull requests without storing any state: the stack is derived on every run from the Git commit graph (every remote branch reachable from the top branch but not from the trunk). That means it works identically in any clone β including throwaway APFS workspaces β with no config, no server, and no account.
You provide the top branch and a trunk (default origin/master); everything else is computed.