A tiny Graphite (gt) clone for local stacked branches, wired to GitHub Stacked PRs (gh stack link) for the stack UI and cascade merge.
gt c -am "add feature" # create stacked branch + commit
gt m -a # amend + restack upstack
gt ss --ai # push, link stack, Claude-write PR titles/bodies
gt sync # fetch trunk, drop merged/closed, restack
gt co # interactive checkout (Graphite-style picker)curl -fsSL https://raw.githubusercontent.com/bryanwonggy/git-stack/main/install.sh | bashgit clone https://github.com/bryanwonggy/git-stack
cd git-stack && ./install.shThe installer will:
- Install into
~/.local/share/git-stack - Symlink
~/.local/bin/gt→ the installedgit-stack.zsh(the executable, for scripts and agents) - Add a sourced block to
~/.zshrc - Check deps (
zsh,git,gh,jq,fzf,claude) - Offer to install
fzfand thegh stackextension - Prompt you to log in to Claude (
claude auth login) sogt ss --aiworks
Then:
source ~/.zshrc
gt helpOverride the executable's location with GIT_STACK_BIN=/some/dir. If something
else already owns gt there (Graphite, say), the installer leaves it alone and
tells you.
~/.local/share/git-stack/uninstall.shFriends don't get new commits automatically — the install is a local copy. To pull the latest from GitHub:
gt updateThat fetches git-stack.zsh (and friends) from bryanwonggy/git-stack
main, replaces the install, and reloads it in the current shell.
Override the repo with GIT_STACK_REPO=owner/name.
On interactive use, gt also checks about once a day and prints:
⬆ git-stack update available — run: gt update
Disable with GT_NO_UPDATE_CHECK=1, or change the interval with
GT_UPDATE_CHECK_HOURS=6.
Alternatively re-run the installer:
curl -fsSL https://raw.githubusercontent.com/bryanwonggy/git-stack/main/install.sh | bash| Tool | Required | Notes |
|---|---|---|
| zsh | yes | sourced as shell functions, and run as the gt executable |
| git | yes | |
| gh | yes | submit / sync / PR state |
| jq | for --ai |
|
| fzf | recommended | fuzzy gt co picker; without it you get a numbered list |
| Claude Code | for --ai |
run claude auth login |
| gh-stack | optional | GitHub Stack UI (org preview) |
If Graphite’s gt binary is still on your PATH, uninstall it first (brew uninstall graphite) so this gt function wins.
| Graphite | git-stack |
|---|---|
gt create / gt c |
gt c |
gt modify / gt m |
gt m |
gt submit / gt ss |
gt submit / gt ss |
gt sync |
gt sync (also drops closed PRs) |
gt squash / gt sq |
gt squash |
gt fold |
gt fold |
gt move |
gt move (interactive --onto picker) |
gt checkout / gt co |
gt co |
gt restack |
gt restack |
gt up / gt down |
gt up / gt down |
Unknown gt … commands fall through to git (e.g. gt status → git status).
gt ss --ai # whole stack
gt submit --ai # current + downstack
GT_AI_MODEL=sonnet gt ss --ai # override default (haiku)Uses the Claude CLI. If login fails later:
claude auth login
claude auth statusInteractively, gt is a zsh function — it has to be, so gt co can change
your shell's branch. But functions don't exist for anything that doesn't source
your ~/.zshrc, which is why a non-interactive shell used to just say
gt: command not found:
zsh -ic 'command -v gt' # ok — function
zsh -c 'command -v gt' # used to failSo git-stack.zsh is now both a sourceable library and an executable, and
install.sh puts it on PATH as gt. Same file, same dispatcher — nothing to
keep in sync. The shell function still shadows the executable interactively, so
nothing about the human experience changes.
zsh -c 'gt ls' # works
~/.local/bin/gt restack # worksinstall.sh checks that gt really resolves to its new link, not just that
~/.local/bin is on PATH. Both ways the link can end up unreachable are
reported the same: if something earlier on PATH wins — Graphite's own gt, say
— it names that path and says it will win; if ~/.local/bin isn't on PATH at
all, it names the directory and how to add it. Either way it tells you how to fix
it and exits non-zero, so a scripted install can't mistake an unreachable gt
for success. It still finishes every other install step first.
Where gt would have asked a human, it now refuses and says what to pass
instead, rather than hanging or guessing:
| Command | With a terminal | Without one |
|---|---|---|
gt co (no argument) |
fzf / numbered picker | lists branches, exits 2 — pass gt co <branch> |
gt up / gt top at a fork |
fzf picker | lists candidates, exits 3 — pass gt co <branch> |
gt c with changes to commit, no -m |
opens your editor | exits 2 — pass -m "msg" |
gt m -c without -m |
opens your editor | exits 2 — pass -m "msg" |
gt up/gt top at a fork previously picked the first child silently and
exited 0, which landed you on the wrong branch and called it success.
A refusal changes nothing: gt c -a and gt m -c -a without -m bail out
before git add -A, so your index and worktree are exactly as you left them
and a retry with -m behaves the same as the first attempt would have.
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | error — usage, refusal, or a failed git/gh command |
| 2 | needs explicit input — no TTY, so we can't prompt |
| 3 | ambiguous — several branches upstack; name one |
| 4 | conflict — restack stopped mid-rebase; resolve, then gt continue |
| 5 | incomplete — restack ran but skipped branches with broken metadata |
Diagnostics go to stderr, so $(gt ...) stays parseable.
Two cases that used to warn and still exit 0:
gt c -m "msg"that ends up committing nothing still creates the branch, still writes its stack metadata, and still prints the same(no commit — …)note — but exits 1. Passing a message says you meant to commit, so no commit is a failed intent.gt c <name>with no-mis unchanged and still exits 0: that one means "branch now, commit later".gt syncwarns and carries on whenmainwon't fast-forward ontoorigin/main— it still drops merged/closed branches, still restacks, and still puts you back on the branch you started from — then exits 1 so a script can tell the sync was not complete.git fetchfailing is still fatal. A restack conflict (4) is the one case that leaves you where the conflict is, forgt continue.
./test/gt-test.zsh # add -v to see each case's outputSelf-contained: it builds a throwaway git init repo and a throwaway install
prefix under one mktemp -d, and never touches your real installation. Each case
runs through the executable, a non-interactive zsh -c, an interactive-shaped
zsh -ic with the library sourced, and — via test/pty_run.py — a real pty,
which is the only way to exercise the fzf and numbered pickers and prove they
still behave for a human.
Run it outside a git worktree of a clone you care about. Stack state lives in git config, and worktrees of one clone share
.git/configunlessextensions.worktreeConfigis set — so two agents stacking in different worktrees of the same clone will collide inbranch.<name>.gtparent.
Per branch, two git-config keys:
branch.<name>.gtparent— parent branch namebranch.<name>.gtbase— parent tip SHA at last (re)stack
No separate database. Safe to inspect with git config --get-regexp '^branch\..*\.gt'.
Defaults to origin/HEAD or main. Override:
export GT_TRUNK=mastergit clone https://github.com/bryanwonggy/git-stack
cd git-stack
git checkout -b your-change
# edit git-stack.zsh / install.sh / …
./test/gt-test.zsh # must stay green
git add -A && git commit -m "describe the change"
git push -u origin HEAD
gh pr create --fillAfter merge, reinstall locally (or gt update) so others pick up the change:
./install.sh
source ~/.zshrcMIT — see LICENSE.