Skip to content
 
 

Latest commit

 

History

19 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

git-stack

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)

Install

One-liner

curl -fsSL https://raw.githubusercontent.com/bryanwonggy/git-stack/main/install.sh | bash

From a clone

git clone https://github.com/bryanwonggy/git-stack
cd git-stack && ./install.sh

The installer will:

  1. Install into ~/.local/share/git-stack
  2. Symlink ~/.local/bin/gt → the installed git-stack.zsh (the executable, for scripts and agents)
  3. Add a sourced block to ~/.zshrc
  4. Check deps (zsh, git, gh, jq, fzf, claude)
  5. Offer to install fzf and the gh stack extension
  6. Prompt you to log in to Claude (claude auth login) so gt ss --ai works

Then:

source ~/.zshrc
gt help

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

Uninstall

~/.local/share/git-stack/uninstall.sh

Update

Friends don't get new commits automatically — the install is a local copy. To pull the latest from GitHub:

gt update

That 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

Requirements

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

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

AI PR copy

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 status

Scripts, CI and coding agents

Interactively, 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 fail

So 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   # works

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

Nothing prompts without a terminal

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.

Exit codes

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 -m is unchanged and still exits 0: that one means "branch now, commit later".
  • gt sync warns and carries on when main won't fast-forward onto origin/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 fetch failing is still fatal. A restack conflict (4) is the one case that leaves you where the conflict is, for gt continue.

Tests

./test/gt-test.zsh        # add -v to see each case's output

Self-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/config unless extensions.worktreeConfig is set — so two agents stacking in different worktrees of the same clone will collide in branch.<name>.gtparent.

How state is stored

Per branch, two git-config keys:

  • branch.<name>.gtparent — parent branch name
  • branch.<name>.gtbase — parent tip SHA at last (re)stack

No separate database. Safe to inspect with git config --get-regexp '^branch\..*\.gt'.

Trunk

Defaults to origin/HEAD or main. Override:

export GT_TRUNK=master

Contributing

git 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 --fill

After merge, reinstall locally (or gt update) so others pick up the change:

./install.sh
source ~/.zshrc

License

MIT — see LICENSE.

About

A tiny Graphite (gt) clone for local stacked branches + GitHub Stacked PRs

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages