Skip to content

Repository files navigation

git-trees logo

git-trees

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 main

init 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 worktree

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

Why

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 -b silently inherits the base ref's upstream, so a new branch ends up tracking main and 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.

Concepts

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 worktree lets 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 init creates and what git trees root prints. It is not itself a checkout.
  • Gitdir pointer (.git) — a one-line file reading gitdir: ./trees-bare.git. Without it, plain git commands run from the container root would walk up and find some unrelated parent repo, or nothing. With it, git fetch and 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 the git trees add section under Commands.

git-trees is a thin layer that keeps this layout consistent. Everything it does, you could do with git worktree by hand.

Layout

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 containing gitdir: ./trees-bare.git
  • AGENTS.md — seeded from a template if one is configured; container root only
  • feature-x/ — one directory per branch, created by add, never by init

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.

Prerequisites

  • Git.
  • Bash — not POSIX sh; the script uses process substitution. macOS's built-in /bin/bash 3.2 is fine, as long as that is what env bash resolves to. No Homebrew bash needed.
  • Network access for init against a remote host, and forge credentials for the clone. add and track also push by default, which needs push rights — see the git trees add section under Commands.
  • Optionally, an agents template at ~/.config/git-trees/AGENTS.md, which ./install.sh puts there for you.

Install

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 else

Convenience — 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 | bash

A 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 | bash

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

Homebrew 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 ;; esac

install.sh warns if it isn't — either way you run it. Anything on PATH named git-trees becomes git trees.

Shell completions

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

zsh — source the same bash file from ~/.zshrc (after oh-my-zsh / bashcompinit if you use them):

source~/.config/git-trees/completions/git-trees.bash

Homebrew'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"done

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

Configuration

Every variable in Environment is optional. Add to ~/.zshrc (or ~/.bashrc):

export TREES_ORG=your-org

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

Commands

git trees init <org/repo|repo|url> [--host h] [--dir d]

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:

FormExample
org/repobrightdigit/git-treeshttps://github.com/…
reporequires TREES_ORG
HTTPS / SSH URLhttps://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).

git trees root [dir] [--agents]

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

git trees add <branch> [base] [--print-path] [--no-push]

Creates a worktree, handling three cases:

SituationBehavior
Branch exists locallyAttach worktree to it
Branch exists on originFetch, create with --track
Branch is newCreate 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.

add writes to the remote by default. When pushing is enabled, upstream is set afterward via track. If the branch does not exist on origin, that runs git push -u origin HEADwhich 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-push to 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.

git trees track [path] [--no-push]

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.

git trees list [--json] (alias ls)

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.

git trees rm <branch|path> [--apply]

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_CMD removes git's safety net.git worktree remove refuses a worktree that has uncommitted changes or untracked files. A custom command such as rm -rf receives only the path and makes no such check, so rm --apply and clean --apply will destroy uncommitted work without warning. Set it only if you want git worktree remove --force semantics deliberately.

git trees sync [worktree] [--pull] [--ff-only|--rebase]

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.

StrategyBehavior
--ff-only (default)git merge --ff-only @{upstream}; refuses to touch a diverged branch
--rebasegit 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:

SituationBehavior
Detached HEADReported on stderr, not counted as a failure — detaching is deliberate
No upstreamReported, with git trees track named as the remedy; counted as a failure
Uncommitted changesReported and skipped; counted as a failure
Diverged under --ff-onlyReported, with --rebase named as the remedy; counted as a failure
Rebase conflictReported; 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 upstream

git trees clean [--merged|--gone] [--apply]

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

git trees prune [--dry-run]

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 rm and clean, prune acts 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 them

Removing worktrees

git-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/worktrees

Both 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

Environment

VariableDefaultPurpose
TREES_HOSTgithub.comHost for init URLs
TREES_ORG(unset)Default org; if unset, bare repo names are rejected
TREES_AGENTS_TEMPLATE~/.config/git-trees/AGENTS.mdSeeded 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/binInstall destination for install.sh; the only way to choose one when piping the installer

Shell wrapper (optional)

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
}

Known limitations

  • list spawns several processes per branch — fine for dozens, slow for hundreds.

  • add ignores base when the branch already exists rather than failing.

  • Branch names beginning with - are unsupported: add parses them as options and reports unknown option. There is no -- end-of-options marker.

  • Bash-only (uses process substitution); not POSIX sh.

Prior art

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.

About

A `git` subcommand for managing a **bare repo + worktrees** layout. Pure git — no external CLI dependencies, no forge integration.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages