Skip to content

Repository files navigation

stacked

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 sync fetches and st submit pushes your branches; you open PRs yourself (st open just 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 .git directory.

How the local metadata works

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.

Install / build

One-line install (no Go toolchain needed):

curl -fsSL https://raw.githubusercontent.com/andyrewlee/stacked/main/install.sh | sh

The 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 --help

The 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.

For scripts and agents

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).

Contributing

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.

Commands

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.

Command details and examples

st init [--trunk <name>]

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 main

st create <name> [-m <msg>] [-a|--all] [--worktree]

Creates <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 unmoved

st log (ls)

Renders 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.

st status (stat)

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).

st commits [<branch>]

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).

st checkout [name] (co)

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.

st worktree <branch> | --all | ls|list | rm|remove <branch> | rm --all (wt)

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.

st shell install [bash|zsh|fish]

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.

st up [n] (u) / st down [n] (d)

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.

st top (t) / st bottom (b)

Jump to the leaf of the current stack (top) or to the bottom branch just above trunk (bottom).

st track [name] [--parent <branch>] [--all] [--dry-run] / st untrack [name]

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).

st modify [-m <msg>] [-a|--all] [--commit] (amend, m)

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.

st restack [--all] [--dry-run] (r)

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.

st continue

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.

st delete <name> [-f|--force] [--dry-run] (rm)

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.

st sync [--no-delete] [--no-fetch] [--remote <name>] [--dry-run] (s)

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>).

st prune [--remote <name>] [--dry-run]

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.

st submit [--all] [--remote <name>] [--dry-run] (ss)

--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.

st open [--all] [--remote <name>] [--dry-run]

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.

st abort

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.

st fold [--dry-run]

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.

st squash [-m <msg>] [--dry-run]

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.

st onto <target> [--dry-run] (move)

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.

st rename [old] <new> (mv)

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.

st undo [<n>] [--list | --dry-run]

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.

st repair

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.

st completion <bash|zsh|fish>

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.

st validate (doctor)

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.

A real stacked-diff workflow

# 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-run

Notes & limitations

  • stacked shells out to your system git; 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 track branches.
  • Mutating commands — plus HEAD-moving navigation (checkout <branch>, up/down/top/bottom) and init — take an advisory lock so two st invocations 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 add the files, and run st continue (or st abort to back out). stacked records progress as each branch lands, then restacks the rest of the stack.
  • st undo reverts the last mutating command's metadata and branch positions but does not modify your working tree.
  • stacked deliberately opens no pull requests. After st submit, st open launches the PR compare URLs in your browser (or open them on your host yourself).
  • st absorb is deliberately strict. st absorb --dry-run maps staged hunks to the stack commits that own their lines; bare st absorb applies any zero-refusal plan — each owning branch tip is amended with its own hunks, then one cascade restacks everything above (one st undo reverts 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.

About

stacked diffs for your agent

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages