One flat root. Bare checkouts. Disposable worktrees.
A tiny worktree-first clone helper in a single Python file - no daemon, no config, no lock-in.
gra clone git@github.com:martinus/oans.git # into ~/gra/oans, with a worktree ready
gra cd warmhare # jump into that worktree, from anywhere
gra work OA-2345 # another worktree, on that branch
gra work warmhare # back to that worktree's tmux window, attaching if needed
gra done # throw it away once the work is merged
gra ls # what do I have, and where is it?Every repository lives once, as a bare checkout, and all work happens in short-named worktrees next to it:
~/gra
├── oans
│ ├── .bare <- the repository itself
│ ├── warmhare <- a worktree, on main
│ └── goldfish <- another one, on feature/search
└── nanobench
├── .bare
└── snowwolf
- No default checkout. No branch is ever pinned by a checkout you do not
use, so any worktree can be on
main, rebase onto it, or switch away. - Names, not paths. Worktree names are word pairs -
warmhare,goldfish,snowwolf- always eight letters and unique across all repositories, so one name identifies one worktree on the whole machine. - The same names everywhere. They are derived from the repository rather
than drawn at random, so cloning
oanson your laptop and on your desktop gives you the same name in both places. - Worktrees are cheap. A worktree is just a workspace, unrelated to the branch it has checked out: make one, switch it around, throw it away.
python3 -c "$(curl -fsLS https://raw.githubusercontent.com/martinus/gra/refs/heads/main/gra)" installThat is the whole installation, and re-running it is how you upgrade. Restart your shell afterwards.
Note
Needs Git and Python 3.10+. fzf
powers the branch picker of gra work, gh lets
gra spot that a clone is a fork, and tmux gets a window per worktree -
which gra work <worktree> attaches to from a plain terminal.
What it writes, and where
The script goes to ~/.local/bin/gra, and this block is appended to
~/.bashrc:
# gra
export PATH="$HOME/.local/bin:$PATH"
eval "$(gra shell bash)"The PATH line is added only when ~/.local/bin is not on your PATH
already, and the block is added only once - re-running touches nothing else.
When there is no ~/.bashrc (gra shell only speaks bash) nothing is written
and the lines to add are printed instead.
That one eval is what makes gra cd change directories, gra done leave
the removed directory, and <TAB> complete worktree names and branches.
Choosing where repositories live
The default root is ~/gra. Configure a different one in ~/.gitconfig:
git config --global gra.root ~/develop| Command | What it does |
|---|---|
gra clone <url> |
Clone into ~/gra/<repo> as a bare checkout, with a worktree ready to use |
gra work [target] |
Create a worktree for a branch - or open a worktree's tmux window |
gra work --path |
The same, printing only the new worktree's path |
gra done [name] |
Remove a worktree once its work is in origin |
gra cd <name> |
Jump to a worktree by name |
gra ls [--fetch] |
One table of every repository and worktree |
gra each [--wt] <command> |
Run a command once in every repository or worktree |
gra install |
Install or upgrade gra itself |
Everything runs from anywhere. The exceptions are the commands that act on
where you are standing: a bare gra work or gra done.
Clones one repository as a bare checkout into ~/gra/<repo>/.bare and opens a
worktree next to it:
gra clone git@github.com:martinus/oans.git~/gra/oans
├── .bare
└── warmhare <- checked out main
The worktree is ready to work in: it checks out origin's default branch as a
local branch tracking origin/<branch>, and gets a tmux
window just like gra work gives one.
| Flag | Effect |
|---|---|
--name <name> |
Use a different local directory name |
--no-work |
Bare checkout only, no worktree |
--no-tmux |
Create the worktree, open no window |
--no-submodules |
Never initialize submodules in this repository |
--no-upstream |
Skip the fork lookup |
Tip
The local name is the repository name (oans above). If it is taken, gra
stops and suggests a distinct one - for two owners of the same repository,
disambiguating by owner works well:
gra clone git@github.com:andreas/oans.git --name oans-andreasForks get an upstream remote
Cloning a GitHub fork also adds an upstream remote pointing at the
repository it was forked from:
added remote 'upstream' -> git@github.com:upstream/oans.git
run 'gra ls --fetch' for its branches
A fork is a GitHub concept rather than a Git one, so gra asks gh, which
carries its own login - private forks work and gra never handles a token.
Without gh installed it says so and carries on; for a remote that is not on
github.com it says nothing, because there is nothing it could ask. The
remote is added but not fetched, so cloning stays as quick as it was.
--no-upstream skips the lookup once, and git config --global gra.upstream false skips it for good. Only gra clone does this, so a repository you
cloned earlier keeps whatever remotes it has; git remote add upstream <url>
is the one-off.
Submodules are a decision about the repository
Pass --no-submodules for a repository whose submodules you never want. It
records gra.submodules = false in the bare checkout, so every later
gra work in that repository skips them too - the flag is a decision about
the repository, not about one clone. It is ordinary Git config, so you can
change your mind:
git -C ~/gra/oans/.bare config gra.submodules true # this repository
git config --global gra.submodules false # everywhereHow the bare checkout is set up
It is made to behave like a normal clone:
- the fetch refspec tracks all of origin's branches as
origin/<branch>remote-tracking refs, and never mirrors them into local branches, origin/HEADpoints at origin's default branch,- reflogs are enabled (bare repositories disable them by default),
.claude/worktrees/is added to the shared local Git exclude file so tool-managed paths do not show up as untracked files in any worktree.
A remote without commits has no default branch to check out; gra says so and
keeps the clone.
The argument is either a worktree name or a branch:
gra work warmhare # go to that worktree's tmux window, from anywhere
gra work feature/search # a new worktree with that branch checked out
gra work OA-2345 # part of a branch name is enough
gra work # pick a branch with fzf
gra work --path feature # print only the path, and open no windowA worktree name is looked up first, because a name identifies one worktree on the whole machine, and it opens that worktree's tmux window - or switches to it when it is already open. Nothing is created. Anything else is a branch.
Without an argument, what it does depends on where you run it:
- in the repository folder (next to
.bare): create a new worktree, - inside a worktree: open that worktree's window, creating nothing,
- anywhere else: fail, and say exactly this.
~/gra/oans
├── .bare
└── warmhare <- checked out feature/search
A branch always means a new worktree, even from inside another one, so a
second task never needs a cd .. first. If the branch only exists as
origin/<branch>, a local tracking branch is created. If it does not exist at
all, gra asks whether to create it from origin's default branch; on
confirmation the new branch is pushed to origin and set up to track
origin/<branch>. gra -y work <branch> answers that question with yes, for
a script or an agent with nobody to ask.
Without a branch, gra work opens an fzf picker over all branches, the ones
with the newest commits first - the branch you are here for is almost always
near the top. Branches already checked out in a worktree are not offered,
because Git allows a branch in only one worktree at a time. The first entry
starts the worktree detached at origin's default branch instead - instantly
usable for looking around or running builds.
If the branch you name is already checked out somewhere, gra names that
worktree instead of leaving you with Git's message:
ERROR: 'main' is already checked out in 'calmpuma'; work there with 'gra cd calmpuma'
--path makes the command something a script can use: the worktree's path is
all that reaches stdout, everything else goes to stderr, and no window is
opened.
cd "$(gra work --path feature/search)"It works for a worktree that already exists too, where it prints the path instead of attaching. This is what lets Claude Code make its worktrees through gra.
Submodules are initialized in the new worktree unless the repository has
gra.submodules = false. Then, inside tmux, the worktree gets a window
named <repo>/<worktree> - for example oans/warmhare.
Finding a branch you did not name
BRANCH can be part of a branch name rather than all of it, so a ticket key finds the branch someone named after it:
gra work OA-2345 # checks out mla/OA-2345-per-processor-propertiesAn exact branch always wins, so gra work feature takes feature even when
feature-extended exists. Matching is case-insensitive and covers branches on
origin you have fetched but never checked out. Several matches are listed
rather than guessed between:
ERROR: 'OA-7777' matches several branches; name one:
mla/OA-7777-first
mla/OA-7777-second
Nothing matching means the old behaviour: gra offers to create the branch
from origin's default branch.
Opening a worktree again
A worktree outlives the window it was opened in: close the window, or come back the next day to a machine that has been rebooted, and the worktree is still there with nothing around it. Name it from anywhere to get the window back:
gra work snowwolfA bare gra work inside the worktree does the same. Either way nothing is
created, and an open window is switched to rather than duplicated.
From a plain terminal it attaches instead of switching, so the name takes you to the worktree wherever you type it.
How names are chosen
A name is a descriptor and a noun run together, each from its own list of
four-letter words - warmhare, goldfish, snowwolf. A hash of the
repository's remote - owner/repo, so an SSH clone and an HTTPS clone agree -
shuffles both lists into an order private to that repository, and the shuffled
lists are paired so that every combination appears exactly once, with
consecutive names sharing neither word. gra work takes the first name in
that order which no worktree anywhere under the gra root is using.
Every choice starts at the front of the list, so a repository's second worktree
gets its second name simply because the first one is occupied. A name held by
another repository is skipped the same way, and gra done frees a name for the
next gra work to reclaim. Machines only disagree when one of them let another
repository claim a contested name first, and then only that repository shifts.
Run it inside a worktree when the work in it is finished, or name one from anywhere:
gra done # the worktree you are in
gra done snowwolf # from anywhere
gra done --force # dirty or unmerged, remove it without askingOn removal the worktree's tmux window is closed, the
directory is removed, and the local branch is deleted - but only when its
changes were verified to be merged. With --force on an unmerged branch, the
branch is kept.
With the bash shell integration, gra done also moves your shell out of the
removed directory into the repository folder.
Before removing anything it fetches, works out where the branch stands, and asks when something is not in order:
'/home/me/gra/oans/snowwolf':
✔ commits pushed to branch origin/snow-tier2
✔ no local modifications
✘ not yet merged to origin/main
✘ pull request #4711 open, changes requested
branch 'snow-tier2' will be kept
continue? [y/N]
The dim lines under the checks are what removal costs: modifications that will be lost, commits a detached HEAD leaves to the reflog, and whether the branch is deleted or kept.
The fetch is the point of the whole thing: every check reads a remote-tracking ref, so without it a branch that was squash-merged an hour ago still looks unmerged. Squash and cherry-pick merges are recognized via patch equivalence, and a branch the remote deleted after merging counts as finished, so the usual PR workflow ends in ticks and no question at all.
The pull request line needs gh and a GitHub remote; without either - or without a network - it simply does not appear. A half-finished rebase, merge, cherry-pick or bisect gets a line of its own, since that is the one state removal cannot give back.
Anything but y keeps the worktree, so a plain Enter is always the safe
answer, and an unanswered question counts as no - which is what a script
gets. Two ways to say yes in advance: --force skips the fetch and the
checks, while gra -y done fetches and runs them, and answers the question
they raise with yes.
gra cd snowwolf # jump to the worktree named snowwolfBecause a name identifies one worktree on the whole machine, that is all gra
needs - and <TAB> completes the names, so gra ls followed by gra cd and
Tab is the whole navigation. The command prints the worktree's path; the shell
integration is what turns that into an actual cd. gra install adds it for
you, or add it yourself:
eval "$(gra shell bash)"One table of every repository under the gra root and every worktree Git knows about:
gra ls
gra ls --fetch # refresh every repository firstRoot: /home/me/gra
Repositories: 2 Worktrees: 3
REPOSITORY WORKTREE BRANCH SYNC STATUS REMOTE
gra snowwolf main ↑2 ✓ clean git@github.com:martinus/gra
▶ oans warmhare feature/search ↓1 ● dirty git@github.com:martinus/oans
goldfish main ✓ clean
▶ is the worktree you are standing in. Because worktree names carry no
meaning, BRANCH is the primary information - the name is just an address.
SYNC is how far the branch is from its upstream: ↑2 is two commits to
push, ↓1 is one to pull, blank is in sync or has no upstream, and - means
the question does not apply (detached, or no upstream). It reads local refs
only - gra ls never goes to the network - so it is as fresh as your last
fetch.
--fetch first runs git fetch --prune --all in every repository, several at
a time. Every remote, not just origin: a fork's upstream is exactly the
remote that goes stale. Only remote-tracking refs move - no worktree, branch,
or uncommitted change is touched - so it is safe to run at any time.
A repository that cannot be reached is named with the reason, and the others are still fetched:
Fetching 12 repositories
oans: fatal: could not read from remote repository.
fetched 11 of 12 repositories
Runs a command of your own once in every repository under the gra root:
gra each git fetch --all --tags
gra each git gc
gra each du -sh .Everything after each - apart from its own --wt - is the command; gra
passes it through untouched, flags and all. It runs in each repository's
.bare directory, so Git commands act on the repository itself, not on one
worktree. The repositories run one at a time, each announced by name, with
the command's output shown as it runs:
gra: git fetch --all --tags
Fetching origin
oans: git fetch --all --tags
Fetching origin
Fetching upstream
With --wt the command runs in every worktree instead, one run per
worktree, labelled <repo>/<worktree>:
gra each --wt git merge --ff-only # fast-forward every worktree
gra each --wt git status --shortA repository where the command fails does not stop the others. The failed
ones are listed at the end, and gra exits with an error:
ERROR: failed in: oans
gra installWrites gra to ~/.local/bin/gra and makes it executable, replacing whatever
is there - including a symlink left by an older installation. See
Install for the ~/.bashrc block it adds.
Upgrading, and installing a specific version
gra install compares itself against the version on main and installs
whichever is newer, so re-running it is how you upgrade:
downloading https://raw.githubusercontent.com/martinus/gra/main/gra
upgrading gra 1.2.0 -> 1.3.0
installed gra 1.3.0 to '/home/me/.local/bin/gra'
Ties go to the local script, so ./gra install from a checkout still installs
that checkout. Run through python3 -c there is nothing local, so the
download is what gets installed - that is the one-liner above.
If the lookup fails - no network, GitHub down - gra says so and installs the
local script anyway; a failed check never costs you a working install.
Run from a file, --no-check installs that file without looking online, which
is what you want offline or when installing an older branch on purpose.
Through python3 -c it changes nothing: with no local script the download is
the only thing there is to install.
Inside tmux, a worktree gets one window, named <repo>/<worktree>:
| Command | What happens to the window |
|---|---|
gra clone, gra work <branch> |
opened for the new worktree |
gra work <worktree>, bare gra work inside one |
opened, or switched to when already open - attached to from outside tmux |
gra done |
closed |
The name is the whole mechanism. There is no config file, nothing to install,
and no state kept anywhere: gra asks tmux for the window of that name and
acts on the answer. Two consequences follow.
A window is never duplicated. A worktree outlives its window, and every
command that leaves you in a worktree looks the name up before opening
anything - so a second gra work, and reopening a window you closed
yesterday, land in the same place.
The window need not be in this session. gra looks at every session, so
gra done snowwolf closes the window even when it lives in a session you are
not attached to, and even when you run it from a plain terminal.
Naming a worktree - gra work snowwolf, or a bare gra work inside one - is
how you say "take me there", and outside tmux that means attaching:
attaching to tmux window 'oans/snowwolf'
gra finds a session to attach to, in this order:
- the session that already holds the window, whichever one that is,
- the running server's current session, where the window is opened first,
- a new session, when no tmux server is running at all.
Then tmux takes the terminal over, and leaving it puts you back in the shell
you started from. Creating a worktree is deliberately not part of this:
gra clone and gra work <branch> open a window when you are in tmux and
otherwise leave your terminal alone, because they are about making a
workspace rather than going to one.
tmux does not have to be installed. Every tmux call is best-effort: if it is
missing, or a window went away between the lookup and the command, gra
carries on and the git work still happens. An attach that cannot happen -
no tmux, or output that is not a terminal - is said out loud rather than
failing the command:
tmux is not installed; nothing to set up for 'snowwolf'
Closing the window gra is running in
gra done is normally run from the very window it has to close, and closing
that window would kill gra before it removes anything. So that one window is
handed to the tmux server, which outlives it:
tmux run-shell -b "while kill -0 $PID 2>/dev/null; do sleep 0.2; done; \
[ -d '$WORKTREE' ] || tmux kill-window -t $WINDOW"The wait is what keeps gra alive to finish the removal, and the -d test is
what makes it safe: a removal that failed leaves the worktree there, and then
the window stays open too. Every other window is closed immediately.
Coming from the work.sh / done.sh hooks
Up to gra 5.3 the tmux windows came from two shell hooks, work.sh and
done.sh, written into each repository next to .bare, and gra hooks wrote
the missing ones. gra no longer reads either file and the gra hooks
command is gone; the behaviour they had by default is now gra's own, so
nothing has to be written per repository and repositories cloned by an older
gra behave like the rest.
The files are left where they are - gra will not touch or delete them. If
you never edited yours, they are dead weight:
rm ~/gra/*/work.sh ~/gra/*/done.shIf you did edit yours, there is no hook to port them into. Two shapes replace what they were used for:
- pane layout - a
claudepane, an editor, a monitor - belongs in a shell function you call yourself, or in a tmux session/window config. - preparing the worktree - symlinking a
compile_commands.json, copying a.env- has no hook to run in any more. Put it in a target the build already depends on, or run it in the worktree once you are there.
The eval line installs Bash completion too, so there is nothing else to set
up:
gra <TAB> install clone ls each work done cd shell
gra cd <TAB> warmhare goldfish snowwolf worktree names
gra done <TAB> warmhare goldfish snowwolf --force
gra work <TAB> worktree names, plus branches inside a repository
gra each <TAB> commands on your PATH
gra done -<TAB> --force
gra each -<TAB> --wt
What it offers follows the same rule the commands do, so the completion and
the command never disagree about what an argument means. Flags are offered
once you type a -, so a single worktree name still completes on its own.
Completion never runs gra - it reads the gra root and asks git for
branches, which keeps <TAB> instant instead of paying for a Python start
every time.
Claude Code
A worktree per task is what Claude Code does
for parallel sessions, and what gra is for. Two things join them up.
Teach it the workflow. The repository carries a skill at
.claude/skills/gra/SKILL.md: gra work for a
branch, git switch to reuse the worktree you are in, gra done to finish.
It costs nothing until a session needs it. Put it where every repository sees
it:
mkdir -p ~/.claude/skills/gra
curl -fsSL https://raw.githubusercontent.com/martinus/gra/main/.claude/skills/gra/SKILL.md \
-o ~/.claude/skills/gra/SKILL.mdLet it make gra worktrees. By default claude --worktree, "work in a
worktree", and subagents with isolation: worktree put their checkouts in
.claude/worktrees/ inside your repository. A WorktreeCreate hook replaces
that: Claude Code hands the hook a name and takes the directory the hook
prints. Save this as ~/.claude/hooks/gra-worktree-create.sh:
#!/bin/sh
# Claude Code wants a worktree; gra makes it, in the gra layout.
set -e
input=$(cat)
cd "$(printf '%s' "$input" | jq -r .cwd)"
branch="claude/$(printf '%s' "$input" | jq -r .name)"
# gra offers to create a missing branch, and a hook cannot answer. 'gra -y'
# would answer yes, but gra's yes also pushes the branch, and a worktree per
# session is not worth a remote branch each. So make it here, locally, from
# origin's default branch - as Claude Code's own worktrees do.
if ! git show-ref --verify --quiet "refs/heads/$branch"; then
base=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD) || base=HEAD
git branch "$branch" "$base" >&2
fi
gra work --path "$branch"And ~/.claude/hooks/gra-worktree-remove.sh, which hands the decision to
gra done rather than deleting anything itself:
#!/bin/sh
# The session is over; let gra decide whether the worktree can go.
path=$(cat | jq -r .path)
[ -d "$path" ] || exit 0
gra done "$(basename "$path")" >&2Make both executable, and name them in ~/.claude/settings.json:
{
"hooks": {
"WorktreeCreate": [
{ "hooks": [{ "type": "command", "command": "$HOME/.claude/hooks/gra-worktree-create.sh" }] }
],
"WorktreeRemove": [
{ "hooks": [{ "type": "command", "command": "$HOME/.claude/hooks/gra-worktree-remove.sh" }] }
]
}
}Now claude --worktree search gets a worktree next to the bare checkout, with
a gra name and a claude/search branch, and gra ls shows it beside
everything else. Worth knowing before you turn it on:
- the worktree gets gra's name, not the one you passed - that name becomes the branch instead,
- removal goes through
gra done, so an unfinished worktree is kept rather than deleted, and it fetches first, which takes a moment at session exit; remove it yourself later withgra done <name>, - a hook replaces Claude Code's own creation, so
.worktreeincludeis not copied for you - copy.envand friends in the hook if you need them, jqis needed for both hooks.
A headless session is a different case: claude -p running gra work <branch>
itself has nobody to answer the branch question, so give it gra -y work <branch>, which also pushes the branch the way a confirmed one is pushed.
python3 -m pip install -r requirements-dev.txt
python3 -m pytest -qTo use your working copy, run ./gra install --no-check from the checkout.
Without the flag a checkout behind main installs main instead.