stacked is a minimal, login-free CLI for managing stacked diffs on top of
plain git. The CLI command is st.
A stack is a chain of small branches where each one is based on the branch below
it instead of all on main. This keeps each change small and reviewable while you
keep building on top. The hard part of stacking by hand is keeping every branch
rebased on its parent as the parent changes — stacked automates exactly that.
stacked is a thin, ergonomic layer over git:
- No host API. It never opens pull requests or calls a forge API. Remote Git
commands still contact your configured remotes:
st syncfetches andst submitpushes your branches; you open PRs yourself (st openjust launches the printed compare URLs in your browser). - No third-party dependencies. It is written in pure Go using only the standard
library and shells out to your system
git. - Local metadata only. The stack topology (which branch's parent is which) is
stored in a single JSON file inside your repo's
.gitdirectory.
stacked stores all of its state under .git/stacked/ in your repo — the
stack topology in state.json, plus the undo journal (undo.json) and the
lock files (lock, and on non-flock platforms lock.excl/lock.reclaim)
alongside it.
Because it lives inside .git, it is per-repo, never committed, and never pushed.
state.json records the trunk branch and, for each tracked branch, its parent branch
and the parent commit SHA it was last rebased onto:
{
"version": 1,
"trunk": "main",
"branches": {
"feat-a": {
"name": "feat-a",
"parent": "main",
"parentSHA": "a1b2c3d4..."
},
"feat-b": {
"name": "feat-b",
"parent": "feat-a",
"parentSHA": "e5f6a7b8..."
}
}
}parentSHA is the key to restacking. A branch B stacked on parent P remembers
the P commit it was based on. When P advances (you add or amend a commit),
B.parentSHA no longer matches P's tip, so B "needs restack". Restacking runs:
git rebase --onto <current tip of P> <B.parentSHA> <B>
and then updates B.parentSHA to P's new tip. Any operation that moves a branch
tip (modify, delete, sync) automatically restacks that branch's upstack
(all of its descendants), always going parents-before-children and reading live
tips at each step. Commands that move HEAD around restore your original branch
when they finish.
The file is written atomically (temp file + rename) so an interrupted command cannot corrupt your stack metadata.
One-line install (no Go toolchain needed):
curl -fsSL https://raw.githubusercontent.com/andyrewlee/stacked/main/install.sh | shThe installer detects OS/arch (darwin/linux, amd64/arm64), verifies the
release checksum, and installs st to /usr/local/bin (override with
INSTALL_DIR). Until release signing is provisioned it requires
ST_ALLOW_UNVERIFIED=1 — it refuses unverifiable downloads by default.
Prebuilt binaries: every release publishes
darwin/linux tarballs for amd64 and arm64 plus checksums.txt on the
releases page — download
stacked_<version>_<os>_<arch>.tar.gz, verify the checksum, and put the st
binary on your PATH.
Building from source requires Go 1.26+ and Git 2.17+ on your PATH; Git 2.31+ is
recommended for native common-dir path resolution, while older supported Git
versions use a fallback. On Git <2.26 the default rebase backend drops
commits that started empty (git commit --allow-empty) during a restack
cascade; Git 2.26+ preserves them. The full gate (make ci) additionally needs
golangci-lint v2 — an external binary, never a module dependency:
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2.
# install the latest release onto your PATH (no clone needed)
go install github.com/andyrewlee/stacked/cmd/st@latest
# ...or install from a clone
go install ./cmd/st
# ...or build locally (Makefile stamps the version from git)
make build # -> ./st
go build -o st ./cmd/st
# sanity check
st version # version, commit, build time, go version
st --helpThe main package lives at ./cmd/st, so go install produces a binary named
st. The metadata lives under the repository's common git dir, so linked
worktrees of the same repo share one stack.
st is built to be driven programmatically: it never prompts, JSON-capable
subcommands accept --json, and failures report stable exit codes (1
usage, 2 conflict, 3 not initialized, 4 dirty tree, 5 lock held,
70 internal). See
docs/AGENT.md for the full machine interface (JSON schemas,
exit codes, idempotency).
The repo closes its own loop — make ci is fmt + strict lint + race tests + e2e +
a coverage gate, and the engine you'll touch most tests in milliseconds. See
CONTRIBUTING.md for the add-a-command recipe and
CLAUDE.md for the architecture.
Run st help (or st <command> -h) at any time. Flags may be placed
before or after a positional branch name — the exception is st undo's
count positional, which parses flags only before it (st undo --json 2,
not st undo 2 --json).
Every command below except __complete and completion and shell (plus help/version) accepts --json; see docs/AGENT.md.
| Command | Aliases | Summary |
|---|---|---|
st init [--trunk <name>] |
Initialize stack tracking in this repo. | |
| `st create [-m | --message ] [-a | --all] [--worktree]` |
st log [--json] |
ls |
Show the stack as a tree (trunk at the bottom); --json for scripting. |
st status [--json] |
stat |
Show the current branch, its parent/children, and restack state. |
st commits [<branch>] [--json] |
List the commits in a branch's recorded stack range (base..tip). | |
st checkout [name] |
co |
Check out a tracked branch, or list branches when no name is given. |
st up [n] |
u |
Move up the stack to a child branch. |
st down [n] |
d |
Move down the stack toward trunk. |
st top |
t |
Jump to the top (leaf) of the current stack. |
st bottom |
b |
Jump to the bottom branch (just above trunk). |
st track [name] [--parent <branch>] [--all] [--dry-run] |
Start tracking a git branch (the current one when no name is given), or every untracked branch with --all (--dry-run previews the inferred map). |
|
st untrack [name] |
Stop tracking a branch (re-parents its children). | |
| `st modify [-m | --message ] [-a | --all] [--commit]` |
st absorb [--dry-run] |
Absorb staged hunks into the stack commits that own their lines (--dry-run previews the mapping). |
|
st restack [--all] [--dry-run] |
r |
Rebase the current branch and everything above it onto their parents (--all restacks the whole forest; --dry-run previews). |
st continue |
Resume a restack interrupted by a merge conflict. | |
st abort |
Abort an in-progress restack/rebase. | |
st fold [--dry-run] |
Fold the current branch into its parent (parent absorbs its commits; --dry-run previews). |
|
| `st squash [-m | --message ] [--dry-run]` | |
st onto <target> [--dry-run] |
move |
Move the current branch (and its upstack) onto a new parent (--dry-run previews). |
st rename [old] <new> |
mv |
Rename a branch and update the stack metadata. |
| `st delete [-f | --force] [--dry-run]` | rm |
st sync [--no-delete] [--no-fetch] [--remote <name>] [--dry-run] |
s |
Fetch trunk, fast-forward it, restack everything, prune merged branches (--no-fetch runs offline against the local trunk; --dry-run previews). |
st prune [--remote <name>] [--dry-run] |
Delete tracked branches already merged into the trunk (sync's prune step standalone; --dry-run previews). |
|
st submit [--all] [--remote <name>] [--dry-run] |
ss |
Push the stack to the remote and print the repo URL and per-branch PR compare URLs (no PRs; --all pushes the whole forest). |
st open [--all] [--remote <name>] [--dry-run] |
Open the PR compare URLs st submit prints in a browser (--all opens every tracked branch's; --dry-run prints without opening; --json emits the URLs as data). |
|
st undo [<n>] [--list | --dry-run] [--force] |
Undo the last stack-mutating command(s) — <n> rewinds the newest n journal entries (--list lists the journal, --dry-run previews, --force restores refs that moved outside st). |
|
st validate |
doctor |
Check the stack state for drift or inconsistencies. |
st repair |
Reconcile the metadata with the repository (fix drift). | |
st worktree <branch> | --all | ls|list | rm|remove <branch> | rm --all |
wt |
Materialize, list, or remove a branch's own worktree (for parallel work). |
st shell install [bash|zsh|fish] |
Print the shell integration that teleports cd into a branch's worktree. |
|
st completion <bash|zsh|fish> |
Print a shell completion script. | |
st guide |
Print the recommended workflow (handy for agents). | |
st help / st version |
-h/-v |
Show help / print the version. |
Creates .git/stacked/state.json. If --trunk is omitted, the trunk is detected
from origin/HEAD, then the current branch, then defaults to main.
st init # auto-detect trunk
st init --trunk mainCreates <name> off the current branch, switches to it, and tracks it. -a
stages all changes first; -m commits the staged changes onto the new branch
(-a requires -m — there is nothing to commit without a message).
With --worktree, the branch is created and tracked and its own linked
worktree is materialized in one command — the main worktree's HEAD does not
move (with st shell install your shell teleports into the new worktree; without
it the path is printed). --worktree cannot be combined with -m/-a: commit
inside the new worktree instead.
st create feat-a -m "add A"
st create feat-b --worktree # branch + its own worktree, main HEAD unmovedRenders the stack as a tree with the trunk at the bottom and each branch above its
parent. The current branch is marked ◉, others ○, drifted branches are tagged
(needs restack), and each branch shows its top commit subject. In a
multi-worktree repo each branch that lives in a linked worktree is also tagged
with (worktree: <path>) (and dirty when that worktree has uncommitted
changes); --json adds matching worktree/dirty fields. Single-tree output is
unchanged.
Prints the current branch's role (trunk / tracked / untracked), its parent and
children, whether it needs a restack, and whether the working tree is clean. In a
multi-worktree repo it also prints worktree path: for the current branch
(worktree in --json).
Lists the commits in a branch's stack range — sha subject per line, newest
first — from its recorded base (parentSHA, the model's claim about where the
branch sits) to its live tip; the default <branch> is the current one.
--json emits { "branch", "parentSHA", "commits": [{ "sha", "subject" }] }.
The trunk is refused (it has no stack base — it is the base).
Checks out a tracked branch (or the trunk). With no argument, lists the trunk and
all tracked branches, marking the current one with *. If the branch lives in its
own worktree (see st worktree), checkout teleports there instead of switching
in place — with the shell shim installed your shell cds into the worktree; without
it the path is printed.
Make a branch a "place you can be" on its own — useful for running multiple agents
on different branches of one stack in parallel. To create a new branch straight
into its own worktree, use st create <name> --worktree; st worktree <branch>
materializes a git worktree for an existing tracked branch under
~/.stacked/worktrees/ using
a collision-resistant repo key and encoded branch segment (outside the repo, so
runners/linters never walk into it). If .worktreeinclude exists, entries are
copied into the worktree via copy-on-write reflink when available. The file is a
newline-separated list of repo-root-relative paths or shell-glob patterns —
* and ? within a path segment, and a segment of exactly ** matching any
number of directories (this is shell globbing, not gitignore syntax: no
negation). Blank lines and # comments are ignored, and tracked or
non-ignored matches are skipped. st worktree ls lists every worktree;
st worktree rm <branch> removes a branch's worktree. st worktree --all
materializes a worktree for every tracked branch that lacks one, in one call —
the branch checked out in the main worktree is skipped, and a rerun is a
no-op. st worktree rm --all is the bulk teardown: it removes the linked worktree
of every tracked branch that has one — worktrees belonging to untracked
branches are left alone — skipping dirty ones (in-progress work is never
discarded), the branch checked out in the main worktree, and the worktree the
caller is standing in ("you are inside it" — removing it would delete the
process's own cwd). A worktree paused
mid-rebase is refused by rm and skipped by rm --all as "a rebase is in
progress there" — it lists as detached, but st still knows which branch
its rebase owns. The stack metadata is
shared across all worktrees, so every st command sees the same stack.
Prints a tiny shell function that wraps st so navigation commands can change your
shell's directory (a CLI process cannot do that to its parent). Install it with, e.g.,
eval "$(st shell install)" in your shell rc. Without it, teleporting commands print
the destination path instead.
Walk n levels up (toward leaves) or down (toward trunk) and check out the result.
up stops at a branch point with multiple children and tells you to pick one.
Jump to the leaf of the current stack (top) or to the bottom branch just above
trunk (bottom).
track starts managing an existing git branch — the current one, or the one
named — with the parent inferred from the commit graph or set with --parent.
--all adopts every untracked local branch in one pass, inferring each
branch's parent from the merge base (branches that share no history with the
trunk are skipped and reported in notes); --all --dry-run prints the
inferred parent map as would track: pairs and records nothing. untrack
stops managing a branch and re-parents its children onto that branch's parent
(the git branch is not deleted).
Amends the current branch's tip (or, with --commit, adds a new commit), then
restacks every descendant so the rest of the stack rebases onto the new tip. A
bare st modify stages all changes and amends without editing the message.
Rebases the current branch onto its parent's current tip, then restacks its entire
upstack in topological order. From the trunk — or with --all from anywhere —
restacks every tracked branch in the forest. Your
original branch is restored when done. Requires a clean working tree (commit or
stash first). If a rebase hits a conflict, resolve it, stage the files with
git add, then run st continue. When a dependent branch lives in its own
worktree (see st worktree), it is rebased inside that worktree — git won't
let it be rebased from here — as long as that worktree is clean; a dirty
dependent worktree is skipped with a note rather than clobbered. Similarly,
every mutating command refuses while a tracked branch (or the trunk) has a
rebase paused in a linked worktree — git would refuse mid-operation anyway,
so st fails upfront naming the branch and its worktree; resolve it there
with st continue or st abort. Pauses on untracked branches never block.
Resumes a restack that stopped on a merge conflict. After you resolve the conflict
and git add the files, st continue completes the in-progress rebase, records
the branch's new base, and restacks the rest of the stack — picking up exactly
where it left off. If it hits another conflict, resolve and run st continue
again.
Deletes a tracked branch, re-parents its children onto the deleted branch's parent,
and restacks them. -f force-deletes an unmerged branch. --dry-run previews the
deleted/restacked branches without changing anything.
Fetches the remote, fast-forwards the trunk, deletes branches already merged into
the trunk (re-parenting their children), restacks every remaining stack onto the
updated trunk, and restores your original branch. A branch counts as merged when
its commits are ancestors of the trunk OR when its entire diff is already in the
trunk's tree — so a PR that squash-merged on the host is detected from git data
alone (no API), even though the branch's tip is no ancestor. The check is exact
tree-content containment: a branch carrying any content the trunk lacks is never
pruned. Sync also works from inside a branch's linked worktree: the trunk
fast-forward runs in the trunk's own worktree (a dirty trunk worktree blocks
sync with an error naming its path). --no-delete keeps merged branches;
--no-fetch skips the fetch and fast-forward entirely — prune and restack run
against the local trunk, so sync can run fully offline; a branch landed only on
the remote survives until the local trunk advances. --remote still selects
and validates the remote configuration without changing the offline basis.
--dry-run previews the prune/restack plan without fetching or changing
anything (against the same local-trunk basis under --no-fetch, else the
already-cached refs/remotes/<remote>/<trunk>).
Deletes every tracked branch already merged into the trunk — the prune step of
st sync as a standalone command. It never fetches: mergedness is measured
against the local trunk, or refs/remotes/<remote>/<trunk> with --remote
(a missing tracking ref fails loudly). It never moves HEAD and needs no clean
tree; pruning the current branch is refused with "check out another branch or
run st sync". --dry-run lists the same set under "dryRun": true without
deleting anything.
--all pushes the whole tracked forest in dependency order (trunk→tips),
independent of where you are. Without it, pushes every branch on the current
stack — from the bottom branch up to the
currently checked-out branch — using --force-with-lease, setting each branch's
upstream (-u). It is login-free and never opens PRs; it prints your repository's
URL so you can open pull requests on your host by hand. For github.com,
gitlab.com, and self-hosted remotes whose hostname carries a github/gitlab
label (GitHub Enterprise, self-managed GitLab) it also prints one compare URL
per pushed branch (head -> base), so each stacked PR can be opened against its correct base;
--json carries the same data as prHints (see docs/AGENT.md).
--dry-run prints the plan without pushing. Most stack-mutating commands also accept
--json for machine-readable output.
Opens the compare URLs st submit prints — the current branch's, or every
tracked branch's under --all — in your browser (open on macOS, xdg-open
elsewhere on unix, rundll32 on Windows). It is read-only: nothing is pushed
or written, and no host API is called — a browser spawn is a local process
launch, not a network call. --dry-run prints the URLs it would open and
--json emits them as data; neither spawns anything. When the remote's host
isn't a recognized github/gitlab shape the command reports "nothing to open"
rather than guessing a URL.
Aborts an in-progress restack (git rebase --abort). Branches that already
restacked keep their new positions; the conflicted branch is rolled back and still
needs a restack.
Folds the current branch into its parent: the parent advances to include the
branch's commits, the branch is deleted, and its children are re-parented onto the
parent. The branch must be in sync first (st restack if needed). --dry-run
previews the fold and any descendant restacks without changing anything.
Collapses every commit on the current branch (since its parent) into one, then
restacks its descendants. With no -m, the message is composed from the existing
commit subjects. --dry-run previews the squash and descendant restacks without
changing anything.
Re-parents the current branch onto target (the trunk or a tracked branch) and
rebases it and its descendants there. target may not be the branch itself or one
of its descendants. On conflict, resolve and run st continue. --dry-run
previews the move and descendant restacks without changing anything.
Renames a branch (the current one by default) with git branch -m and updates the
stack metadata: the branch's record, the trunk name if applicable, and every
child's parent pointer.
Reverts the last stack-mutating command: the metadata is rolled back and each
recorded branch is reset to its prior tip. It does not touch your working
tree, so uncommitted changes are preserved (run git status to review). The
journal keeps the last several operations.
st undo <n> rewinds the newest n journal entries newest-first — the
numbering matches --list (index 1 is what a bare st undo reverts). Each
step is its own unit: a refusal mid-sequence stops with a "stopped at step k
of n" report and the already-undone prefix stays undone. A count beyond the
journal depth refuses outright.
st undo --dry-run [<n>] previews the next undo — or each of the next n
steps in order — against live refs and worktrees —
what it would restore, which created branches/worktrees it would delete, and
every blocker a real run would refuse on — changing nothing. --list previews
the recorded journal alone: entries are
listed newest-first as 1: create (created feat-a; on main), where index 1 is
what a bare st undo would revert.
Undo also guards against outside changes: if a recorded branch's tip moved
since the operation (someone committed on it with plain git, or a deleted
branch was recreated), st undo refuses rather than silently rewinding it —
--dry-run reports the branch as ref_moved_since:<name>. st undo --force
restores the recorded tips anyway and names the refs it overwrote. Entries
written by older versions — and entries kept for failed operations whose refs
st abort/st continue may legitimately have moved — restore unconditionally
with a note saying so. Undo likewise refuses when a branch it would rewrite
has a rebase paused in a linked worktree (paused_rebase:<name> in
--dry-run) — a later git rebase --continue/--abort would overwrite the
restored tip anyway. Pauses on branches the entry does not touch never block
it; --force bypasses the refusal.
Fixes the drift st validate reports: untracks branches whose git branch was
deleted outside st, re-parents branches with an invalid parent onto the trunk,
clears stale pending reparents whose rebase is gone, and breaks parent cycles.
Re-parented branches may then need st restack.
Prints a shell completion script for st's subcommands, e.g.
st completion zsh > "${fpath[1]}/_st". The scripts complete branch names
live for checkout, onto, track, worktree (and rm's owned
branches), delete, untrack, and rename, via a hidden read-only
st __complete endpoint that answers in
flat probes — no fetch, no history walk, silent-empty outside a repo. Set
ST_COMPLETE_BIN to point the hooks at a different st binary.
Checks the recorded stack against the actual repository and reports problems — a
missing trunk, tracked branches whose git branch was deleted outside stacked,
parents that are no longer the trunk or a tracked branch, tracked parents whose
own git ref is gone, stale pending reparents whose rebase no longer exists, and
parent cycles — plus warnings for branches that have drifted and need a restack.
Exits non-zero when any problem is found.
# 0. one-time setup in your repo
st init
# 1. build the first change as its own small branch
echo 'A' > a.txt && git add -A
st create feat-a -m "add A"
# 2. stack a second change on top of the first
echo 'B' > b.txt && git add -A
st create feat-b -m "add B"
# 3. see the stack (trunk at the bottom, current branch marked ◉, colorized on a TTY)
st log
# ◉ feat-b add B
# ○ feat-a add A
# ○ main
# 4. drop down to revise the first branch
st down # now on feat-a
# 5. amend feat-a; feat-b is automatically restacked onto the new feat-a
echo 'A2' >> a.txt
st modify -m "add A (revised)"
# Amended feat-a with new message
# restacked: feat-b
# 6. if anything ever drifts, fix the whole stack in one shot
st restack # no-op here: everything up to date
# 7. pull in trunk updates, fast-forward main, restack, prune merged branches
st sync
# 8. push the whole stack to the remote (no PRs are opened)
st submit # or: st submit --dry-runstackedshells out to your systemgit; it is a convenience layer, not a reimplementation of git.- The stack metadata is local to the repo (under the common git dir, so linked
worktrees share it) and is not shared via the remote. A teammate cloning the
repo starts with an empty stack until they
trackbranches. - Mutating commands — plus HEAD-moving navigation (
checkout <branch>,up/down/top/bottom) andinit— take an advisory lock so twostinvocations don't clobber each other's metadata or move HEAD mid-mutation: flock on unix-like platforms, an exclusive lock file elsewhere (e.g. Windows). - On a rebase conflict during a restack, resolve it,
git addthe files, and runst continue(orst abortto back out).stackedrecords progress as each branch lands, then restacks the rest of the stack. st undoreverts the last mutating command's metadata and branch positions but does not modify your working tree.stackeddeliberately opens no pull requests. Afterst submit,st openlaunches the PR compare URLs in your browser (or open them on your host yourself).st absorbis deliberately strict.st absorb --dry-runmaps staged hunks to the stack commits that own their lines; barest absorbapplies any zero-refusal plan — each owning branch tip is amended with its own hunks, then one cascade restacks everything above (onest undoreverts it all). Anything ambiguous is refused with the reason rather than guessed: hunks spanning several commits, pure additions, lines owned by trunk/history or by a commit tipped only by an off-path branch, non-tip targets, and binary/mode/rename records. Any refusal leaves the whole plan unapplied.