A git subcommand for managing a bare repo + worktrees layout. Pure git — no
external CLI dependencies, no forge integration.
git trees init brightdigit/some-repo # creates the containercd some-repo
git trees add main # first worktree — your files live herecd maininit creates the container: a shared object store plus a .git pointer, and
nothing else. add creates a working copy. Between the two, the container root
has no files checked out and git status there reports
fatal: this operation must be run in a work tree — that is expected, not a
broken install.
From then on, one directory per branch:
git trees add feature-x # sibling worktree, its own working copy
git trees list # every branch, with or without a worktreeadd prints the new path but cannot cd your shell — it runs in its own
process. The optional trees() wrapper below makes
trees add feature-x drop you straight into it.
Working with multiple concurrent branches (particularly with parallel coding agents, each pinned to its own worktree) is well served by git's worktree support, but the built-in porcelain leaves gaps:
git worktree add -bsilently inherits the base ref's upstream, so a new branch ends up trackingmainand pushes to the wrong place- No single view of worktrees and branches that lack one
- Bare-clone setup for this layout is a four-command incantation
git-trees covers those. It's deliberately small and readable — one bash file
you can audit in a sitting.
New to git worktree? Five terms cover everything below.
- Worktree — a checked-out working copy: files on disk, its own index and
its own
HEAD. Plain git gives you one per clone;git worktreelets one repo have several, each on a different branch, all editable at once. - Bare store (
trees-bare.git/) — the repository's objects and refs with no working copy attached. Every worktree shares this one store, which is why adding a tenth worktree costs a checkout, not a tenth copy of your history. Never modify it directly. - Container — the directory holding the bare store and all the worktrees. It
is the thing
initcreates and whatgit trees rootprints. It is not itself a checkout. - Gitdir pointer (
.git) — a one-line file readinggitdir: ./trees-bare.git. Without it, plaingitcommands run from the container root would walk up and find some unrelated parent repo, or nothing. With it,git fetchand friends work from the root even though no files are checked out there. - Upstream — the remote branch a local branch pushes to and compares
against (
origin/feature-x). Setting it correctly on new branches is most of what this tool does — see thegit trees addsection under Commands.
git-trees is a thin layer that keeps this layout consistent. Everything it
does, you could do with git worktree by hand.
init and add produce different things — the container first, working copies
after:
After `git trees init`: After `git trees add feature-x`:
some-repo/ some-repo/
├── trees-bare.git/ ├── trees-bare.git/
├── .git ├── .git
└── AGENTS.md ├── AGENTS.md
└── feature-x/ ← your working copy
no files checked out yet
trees-bare.git/— the bare store; never modified directly.git— a file containinggitdir: ./trees-bare.gitAGENTS.md— seeded from a template if one is configured; container root onlyfeature-x/— one directory per branch, created byadd, never byinit
Worktrees share a single object store but have independent working trees,
indexes, and HEADs. Every worktree is a direct child of the container root, so a
branch's / becomes a - in the directory name: feature/x checks out into
feature-x/ while the branch keeps its real name.
- Git.
- Bash — not POSIX
sh; the script uses process substitution. macOS's built-in/bin/bash3.2 is fine, as long as that is whatenv bashresolves to. No Homebrew bash needed. - Network access for
initagainst a remote host, and forge credentials for the clone.addandtrackalso push by default, which needs push rights — see thegit trees addsection under Commands. - Optionally, an agents template at
~/.config/git-trees/AGENTS.md, which./install.shputs there for you.
Recommended — clone and run the installer. This is the full install: it
places the script and seeds the agents template that
TREES_AGENTS_TEMPLATE defaults to.
git clone https://github.com/brightdigit/git-trees.git
cd git-trees && ./install.sh # → ~/.local/bin
./install.sh /usr/local/bin # or anywhere elseConvenience — one-line curl. Same installer, downloaded and run in place. It fetches the script and the agents template (skipping the template if that path is already occupied, including a broken symlink):
curl -fsSL https://raw.githubusercontent.com/brightdigit/git-trees/main/install.sh | bashA piped script receives no positional arguments, so set TREES_DEST to install
somewhere other than ~/.local/bin:
TREES_DEST=/usr/local/bin curl -fsSL \
https://raw.githubusercontent.com/brightdigit/git-trees/main/install.sh | bashThe installer uses curl or wget, whichever it finds, and verifies each
download is complete and non-empty before installing anything.
main is the stable release. A re-install from these URLs picks up the current
stable script and template.
Homebrew. Not yet published — the formula lives at
homebrew-tap/Formula/git-trees.rb (a
git subrepo of
brightdigit/homebrew-tap) and
takes effect once it is pushed to the tap (see
docs/RELEASING.md). After that:
brew tap brightdigit/tap
brew install git-treesHomebrew installs the completions for you — both files land in Homebrew's own
completion directories, so no source line is needed. The agents template is
the exception: a formula cannot write to your home directory, so brew install
bundles the template inside its prefix and prints the one line that points
TREES_AGENTS_TEMPLATE at it.
Either way, make sure the destination is on your PATH:
case":$PATH:"in*":$HOME/.local/bin:"*) ;; *)
echo'export PATH="$HOME/.local/bin:$PATH"'>>~/.zshrc ;; esacinstall.sh warns if it isn't — either way you run it. Anything on PATH
named git-trees becomes git trees.
install.sh copies both completion files to ~/.config/git-trees/completions/
and prints the source line for the bash file — the one both bash and
Homebrew's zsh git completion need. The zsh file is wired up by fpath
rather than sourced, so it has no activation line of its own. Neither copy is
overwritten if you have edited it, so a reinstall keeps your changes.
bash — source the file from ~/.bashrc, after bash-completion itself:
source~/.config/git-trees/completions/git-trees.bashzsh — source the same bash file from ~/.zshrc (after oh-my-zsh /
bashcompinit if you use them):
source~/.config/git-trees/completions/git-trees.bashHomebrew's git completion is a bash wrapper: it dispatches git trees to a
function named _git_trees, so the bash file is what git trees <TAB> needs.
Putting only completions/ on fpath wires up the standalone git-trees
binary under stock zsh _git, but is not enough for Homebrew.
Completion covers every subcommand and its own flags, and completes branch and
worktree names for rm from git itself. Outside a repository it stays silent
rather than erroring.
If you installed via the curl path, install.sh never ran, so fetch the
files yourself first:
mkdir -p ~/.config/git-trees/completions
forfin git-trees.bash _git-trees;do
curl -fsSL -o ~/.config/git-trees/completions/"$f" \
https://raw.githubusercontent.com/brightdigit/git-trees/main/completions/"$f"doneThen add the source line above.
The filenames are load-bearing. Git's completion dispatches git trees to a
function named _git_trees, and stock zsh's _git also looks for a file named
_git-trees on fpath for the standalone binary — renaming either one
silently disables completion.
Every variable in Environment is optional. Add to
~/.zshrc (or ~/.bashrc):
export TREES_ORG=your-orgThat's usually all you need — TREES_HOST defaults to github.com and
TREES_AGENTS_TEMPLATE defaults to ~/.config/git-trees/AGENTS.md, which is
where install.sh and the curl install put the template.
With TREES_ORG set, git trees init my-repo expands to your-org/my-repo.
Without it, bare repo names are rejected and you must pass org/repo.
Reload with source ~/.zshrc, then check with echo $TREES_ORG.
Note that these are shell-global. For a repo under a different org, pass
org/repo explicitly rather than changing the variable.
Creates the bare clone, writes the .git pointer, fixes the fetch refspec (a
bare clone doesn't set up remote-tracking refs by default), and resolves
origin/HEAD.
Accepts:
| Form | Example |
|---|---|
org/repo | brightdigit/git-trees → https://github.com/… |
repo | requires TREES_ORG |
| HTTPS / SSH URL | https://github.com/org/repo.git, git@github.com:org/repo.git |
Directory defaults to the repo name; override with --dir. --host applies to
the shorthand forms only (ignored when a URL is given).
Prints the project root (the directory that contains the bare store and
worktrees). If that root has no .git pointer yet but contains exactly one
bare *.git (e.g. a grove layout), writes gitdir: ./<name>.git first —
idempotent, does not rename the bare store — then prints the path on stdout.
Useful for adopting an existing worktree container without breaking other
tools that already know the *.git name.
--agents also seeds AGENTS.md at that root from TREES_AGENTS_TEMPLATE, for
containers that init did not create. Skipped if the file already exists or no
template is configured; the notice goes to stderr, so the path on stdout stays
clean for $(git trees root).
Creates a worktree, handling three cases:
| Situation | Behavior |
|---|---|
| Branch exists locally | Attach worktree to it |
Branch exists on origin | Fetch, create with --track |
| Branch is new | Create from base (default origin/<default>) with --no-track |
base is resolved to a commit before the worktree is created. A bare name that
exists only on origin resolves to origin/<name>, so add newwork v1.2.0
starts the branch where you meant and leaves no local v1.2.0 behind; a base
that resolves to nothing is an error rather than a worktree on something else.
addwrites to the remote by default. When pushing is enabled, upstream is set afterward viatrack. If the branch does not exist onorigin, that runsgit push -u origin HEAD— which creates the branch on the remote. This is a reasonable default for parallel coding agents, which need an upstream to push to, but it means a local-feeling command fires CI, sends notifications, and publishes a branch name. Pass--no-pushto skip it.
With --no-push, add sets the upstream when origin/<branch> already exists
and otherwise leaves it unset, printing the git push -u origin HEAD you can run
yourself. It still exits 0. Set TREES_NO_PUSH to any non-empty value to get
that behavior everywhere without passing the flag.
If the push or upstream setup fails, add exits nonzero (the worktree may still
exist). --print-path writes the path to stdout and everything else to stderr,
for shell wrappers.
Branch names may contain /; the directory is the branch name with each /
replaced by -, since worktrees do not nest. That makes feature/x and
feature-x compete for one directory — whichever exists first keeps it, and
add refuses the other by name rather than inventing a variant. If the target
directory already exists for any other reason, add fails rather than inventing
a new name. If the branch already exists, base is ignored with a warning.
Idempotent. Ensures the branch in path (default .) has an upstream: sets it
to origin/<branch> if that exists remotely, otherwise runs
git push -u origin HEAD, which creates the branch on origin. Returns
immediately if an upstream is already configured.
--no-push (or a non-empty TREES_NO_PUSH) suppresses that push: the upstream
is left unset and the exact command to run is printed to stderr. Exit status
stays 0 — not setting an upstream is the requested outcome, not a failure.
Useful for repairing worktrees created before this tool. Note that pushing needs
forge credentials; without push rights, track fails unless you use --no-push.
One entry per branch with upstream, ahead/behind, last commit date, clean/dirty,
and path (relative to the project root). Includes branches with no worktree,
shown with path (none). --json emits the same fields as an array.
Removes a worktree and deletes its associated local branch when one exists. Accepts either a branch name or a worktree path.
By default (without --apply), reports what would be removed without making any changes. Pass --apply to perform the removal.
Worktree directories are removed via TREES_RM_CMD if configured, or git worktree remove. Local branches are deleted using git branch -d (falling back to git branch -D if needed).
A path target must be a worktree git already knows about; rm refuses any other
directory.
TREES_RM_CMDremoves git's safety net.git worktree removerefuses a worktree that has uncommitted changes or untracked files. A custom command such asrm -rfreceives only the path and makes no such check, sorm --applyandclean --applywill destroy uncommitted work without warning. Set it only if you wantgit worktree remove --forcesemantics deliberately.
Brings the container up to date with origin. With no positional argument it
covers every worktree; passing one names a single worktree, by branch name or by
path.
The default is fetch only — it runs one git fetch --prune origin and stops.
Nothing in any working tree is touched, so there is no --apply gate: the
command acts immediately. Every worktree shares a single object store, so one
fetch updates the remote-tracking refs for all of them; fetching per worktree
would transfer nothing after the first and cost only round-trips.
--pull then updates the working trees from the refs that fetch just brought in.
| Strategy | Behavior |
|---|---|
--ff-only (default) | git merge --ff-only @{upstream}; refuses to touch a diverged branch |
--rebase | git rebase @{upstream}; replays local commits on top of the upstream |
--ff-only is the default because it is the only update that can neither discard
work nor stop half-finished. The two are mutually exclusive, and passing either
without --pull is an error rather than a silent no-op — sync --rebase that
only fetched would look like it had rebased.
There is no git pull under the hood, deliberately: pull re-fetches on every
invocation, which would undo the single-fetch design. merge --ff-only and
rebase against @{upstream} need no fetch of their own and are idempotent.
Under --pull, a worktree is skipped when:
| Situation | Behavior |
|---|---|
| Detached HEAD | Reported on stderr, not counted as a failure — detaching is deliberate |
| No upstream | Reported, with git trees track named as the remedy; counted as a failure |
| Uncommitted changes | Reported and skipped; counted as a failure |
Diverged under --ff-only | Reported, with --rebase named as the remedy; counted as a failure |
| Rebase conflict | Reported; the worktree is left mid-rebase so you can resolve it, or run git rebase --abort |
Dirtiness includes untracked files, matching the dirty column in
git trees list and git worktree remove's own
refusal — so a stray .DS_Store is enough to skip a pull.
The branch name of each successfully updated worktree goes to stdout, one per
line; every notice, warning, and error goes to stderr. sync exits nonzero if
any worktree was skipped for a reason above other than a detached HEAD, or if the
fetch itself failed — in which case nothing is pulled. The loop always runs to
completion, so a nonzero exit means partial success, not a stop.
git trees sync # fetch origin, touch nothing
git trees sync --pull # fast-forward every clean, tracked worktree
git trees sync feature-x --pull --rebase # rebase one worktree onto its upstreamReports or removes stale worktrees and branches.
Selectors:
--gone: branches whose upstream remote branch was deleted ([gone])--merged: branches merged into the default branch. Automatically detects direct merges, rebased/cherry-picked commits, and squash-merged PRs while leaving fresh 0-commit branches intact.
Passing neither selector runs both --gone and --merged.
By default (without --apply), clean operates in dry-run mode and prints matching branches/worktrees without deleting them. Pass --apply to execute removals. Worktree directories are removed via TREES_RM_CMD if set (defaulting to git worktree remove) — see the warning above — and branches are deleted using git branch -d (falling back to git branch -D for gone/squash-merged branches).
clean --apply keeps going when an individual removal fails, reporting each one
on stderr, and exits nonzero if any of them did.
Drops git's administrative entries for worktree directories that are no longer on disk.
When a worktree directory is deleted by hand (rm -rf feature-x) instead of through git trees rm, git keeps its bookkeeping under the bare store. The stale entry keeps showing up in git worktree list and holds the branch locked against a fresh checkout. prune clears those entries.
Stale worktree names are printed to stdout, one per line; git's reason for each goes to stderr. With nothing to prune it prints a notice on stderr and exits 0.
Pass --dry-run to list what would be dropped without touching anything.
Unlike
rmandclean,pruneacts immediately — there is no--apply. It only removes metadata for directories that are already gone; a worktree still on disk is never a candidate, and the branch a pruned entry held is left alone. There is no work to lose.
git trees prune --dry-run # list stale entries, change nothing
git trees prune # drop themgit-trees provides rm and clean for removing worktrees and branches:
git trees rm feature-x --apply # remove a single worktree and its branch
git trees clean --apply # remove merged and gone branches/worktreesBoth subcommands default to dry-run mode (report only) unless --apply is passed.
Both also delete unmerged work once --apply is given — -d escalates to -D.
If you have set TREES_RM_CMD, read the warning under git trees rm first: it removes git's check for
uncommitted changes.
Alternatively, you can use plain git:
git worktree remove <path>
git branch -d <branch># -d refuses unmerged work; escalate to -D yourself
git worktree prune| Variable | Default | Purpose |
|---|---|---|
TREES_HOST | github.com | Host for init URLs |
TREES_ORG | (unset) | Default org; if unset, bare repo names are rejected |
TREES_AGENTS_TEMPLATE | ~/.config/git-trees/AGENTS.md | Seeded at the container root by init (and root --agents) |
TREES_NO_PUSH | (unset) | Any non-empty value: add/track never create a branch on origin |
TREES_RM_CMD | (unset) | Custom command for worktree directory removal (defaults to git worktree remove). Bypasses git's uncommitted-work check — see git trees rm |
TREES_DEST | ~/.local/bin | Install destination for install.sh; the only way to choose one when piping the installer |
A subcommand runs in its own process and cannot cd your shell. If you want
add to drop you in the new worktree:
trees() {
case"$1"in
add) shift;cd"$(git trees add "$@" --print-path)" ;;
*) git trees "$@" ;;
esac
}listspawns several processes per branch — fine for dozens, slow for hundreds.addignoresbasewhen the branch already exists rather than failing.Branch names beginning with
-are unsupported:addparses them as options and reportsunknown option. There is no--end-of-options marker.Bash-only (uses process substitution); not POSIX sh.
grove covers similar ground with a compiled binary,
adjective-noun branch generation, .groverc bootstrap commands, and a go
subcommand that opens a subshell. Worth a look if you'd rather not maintain
shell.