← osss tools

wt Worktree v0.12.0

Manage the per-branch git worktree checkouts of the ~/code workspace

Download v0.12.0 →

Prebuilt binaries are attached to each release.

Linux

Debian/Ubuntu — add the osss apt repository once, then install as root (sudo, doas, whatever you use) — provides the wt command:

apt install worktree

Fedora/RHEL — add the osss RPM repository once, then install as root (sudo, doas, whatever you use) — provides the wt command:

dnf install worktree

macOS

A universal2 binary. With Homebrew:

brew tap osss/tools https://gitlab.osss.net/distribution/homebrew-tools.git
brew install osss/tools/worktree

…or with MacPorts — add the osss ports tree once, then install as root (sudo, doas, whatever you use):

port install worktree

A guided tour for someone new to wt: what problem it solves, the one idea you need to hold in your head, the day-to-day loop, and how the hook system turns it from a checkout manager into a real part of your workflow. For the exhaustive flag-by-flag reference, see the README; this guide is the "why" and the "how it fits together."

A note on command names. Each command's canonical name — what wt --help, the man page, and completions show — is its full word: new, fetch, rename, list, remove, execute. Most also have a short abbreviation and a single letter (mk/n, get/g, rm/r, …). The forms are interchangeable; this guide uses the short ones, the way you'd actually type them. The full mapping is the alias table in the README.

The problem it solves

A plain git checkout has one working tree. Switching branches means switching that one directory out from under yourself: you stash (or commit) whatever you're mid-thought on, git switch, and your editor, your running dev server, your build cache, and your open files all churn to the new branch. Come back an hour later and you reverse all of it. Reviewing a colleague's branch while your own work sits half-done is the same dance, with more stashing.

Git's own answer is git worktree — multiple working trees from one repository, each on its own branch, side by side. No stashing, no churn: the feature you're building, the bug you're reviewing, and the hotfix you're cutting each get their own directory, all live at once. But raw git worktree leaves you to invent and police a directory layout by hand, and it knows nothing about the per-checkout setup your project needs (an .env, a database, installed dependencies).

wt is the missing manager on top of git worktree. It fixes one layout — one checkout per branch, each directory named after its branch — and runs your project's setup/teardown automatically as checkouts come and go. You get the parallelism without the bookkeeping.

The one idea: one checkout per branch

Under each repo directory, every branch lives in its own subdirectory, named after the branch:

myproject/
  main/                 # the canonical clone — the repo's real .git lives here
  casey/feature-x/      # branch casey/feature-x (slashes nest as dirs)
  4821-hash-cache/      # branch 4821-hash-cache

Two rules follow from this, and everything wt does depends on them:

  1. A checkout's directory name always equals its branch. casey/feature-x/ is on casey/feature-x, always. wt infers which repo and checkout you mean from the directory you're standing in, so this mapping has to hold.
  2. The repo directory itself is never a checkout. It holds the canonical clone (at main/, or whatever the default branch is) plus one linked worktree per branch.

The practical consequence: never git switch or git checkout a checkout onto a different branch — that silently breaks rule 1. To work on another branch, you give it its own checkout (that's what wt mk / wt get are for). Likewise, use wt rather than raw git worktree add / remove, so the layout stays consistent and your hooks actually run.

Getting started

wt init git@host:group/myproject.git   # clone into the workspace layout → myproject/main/
cd myproject
wt mk casey/feature-x                   # branch off main into a new checkout, and provision it

wt init is the one workspace-level step: it clones the repo and lays down the canonical main/ checkout. After that you live in wt mk / wt ls / wt rm.

By default wt roots the workspace at $HOME/code; set CODE_ROOT to put it elsewhere. It is otherwise root-agnostic — nothing assumes a particular path.

The everyday loop

Start a piece of work — branch off the up-to-date default branch into a fresh checkout:

wt mk casey/feature-x          # new branch off main (fast-forwarded first)
wt mk hotfix v1.2.0            # …off a tag or commit instead
wt mk casey/part2 casey/part1  # …stacked on another branch
wt mk --cd casey/feature-x     # …and cd straight into it (needs the shell wrapper, below)

"Up-to-date" is enforced, not assumed. Branching off a branch that tracks a remotewt mk 42-thing main in a repo whose canonical checkout sits on some other branch — refreshes that branch first and refuses if it has fallen behind, rather than quietly rooting the work at a stale base and leaving you to find out when a merge request reports itself commits behind. Tags, commits, and purely local branches are fixed points, so they branch as-is.

Review someone else's branch without disturbing your own — fetch it into its own throwaway checkout:

wt get someones/bugfix         # check out origin/someones/bugfix for review
wt get -r fork their-feature   # …from a fork instead of origin

See what you have — every checkout, its branch, how stale it is, whether it holds work you haven't pushed, and whether it has fallen behind its upstream:

wt ls                          # all checkouts here
wt ls -u                       # only those with unpushed or uncommitted work
wt ls --merged                 # only branches that have landed (an autoclean preview)

A checkout whose upstream has moved on reads 3 behind (or diverged (1 ahead, 3 behind) if both moved). That is the state wt mk refuses to branch off, so wt sync && wt ls tells you what needs refreshing before you start — the counts are read from the last fetch, so sync first.

Move between checkouts by name (see "Navigation" below for the one-time setup):

wt cd casey/feature-x          # cd into a checkout
wt cd                          # …or back to the canonical main

Finish up — when a branch has landed, tear its checkout down. wt refuses to discard unpushed or uncommitted work unless you force it, and never touches the canonical checkout:

wt rm casey/feature-x          # remove the checkout and its local branch
wt destroy casey/feature-x     # …and delete the branch on the remote too
wt autoclean                   # list every merged, clean checkout that could go…
wt autoclean --yes             # …and remove them in one sweep

A freshly wt mk'd checkout with no commits yet is excluded from autoclean --yes by default — its branch hasn't diverged from default, which looks identical to "already landed" but usually means "about to be worked on." Pass --include-unstarted to sweep those too.

Rename a checkout and its branch together (the directory moves with it, so the dir==branch rule still holds):

wt mv casey/feature-x 4821-hash-cache

Hooks: where it becomes your workflow

The layout is half the value. The other half is hooks — the thing raw git worktree can't do. A fresh checkout usually isn't ready to work in: it needs an .env, a database, a node_modules, a target symlink — whatever your project's "set up a working copy" ritual is. wt runs that ritual for you, at the right moments:

HookRunsUse it to
worktree-setupafter wt mk / wt get creates a checkoutprovision: copy/generate .env, create a per-checkout database, install deps, link caches
worktree-teardownbefore wt rm / wt destroy / wt autoclean removes oneclean up: drop that database, remove external state the checkout owned
worktree-renameafter wt mv renames onemigrate per-checkout state keyed on the old name to the new one

A hook is just an executable file the repo commits at .worktree/hooks/<name>. A repo opts in simply by committing it — no flag, no registration. So your project-specific setup travels with the project: anyone who clones it with wt init and runs wt mk gets the same provisioned checkout, automatically.

Each hook is invoked with the checkout's identity in environment variables — REPO_DIR, CANONICAL_DIR, WORKTREE_DIR, WORKTREE_NAME, BRANCH, and WORKTREE_KIND (new, fetched, or renamed); the rename hook also gets the OLD_* trio. A minimal worktree-setup that gives every checkout its own env file:

#!/bin/sh
# .worktree/hooks/worktree-setup — committed in the repo
set -e
cp "$CANONICAL_DIR/.env.example" "$WORKTREE_DIR/.env"
echo "DB_NAME=myapp_${WORKTREE_NAME}" >> "$WORKTREE_DIR/.env"

The teardown counterpart drops what setup created, so a removed checkout leaves nothing behind. Because the hook reads from the canonical checkout's committed copy (never the freshly-fetched branch's tree), fetching an untrusted branch with wt get can never run code from its tree — the hook is always the reviewed one on the branch you control.

Two commands help you work with hooks:

wt hooks                       # show which hook resolves for this repo, and from where
wt hooks run worktree-setup    # re-run a hook by hand (e.g. after fixing it)

If a setup hook is broken and blocking you, wt mk --skip-hooks creates the checkout unprovisioned so you can get in and repair it; wt rm --skip-hooks does the same on the way out.

Recipe: seed the build cache

A fresh checkout holds only tracked files, so everything gitignored — target/, node_modules/, .venv/ — starts empty and the first build in a new checkout is a cold one. Copying those artifacts from the canonical checkout is far cheaper than recreating them: for a Rust project of ~100 dependencies, a cold cargo build --release takes tens of seconds while copying the whole accumulated target/ takes one or two, even on a filesystem with no copy-on-write support at all.

Seeding is a worktree-setup job, and each platform's own cp already picks the best mechanism available — a reflink where the destination filesystem offers one, a full copy where it doesn't:

#!/bin/sh
# .worktree/hooks/worktree-setup — seed build caches into a new checkout.
set -e

for path in target; do
	src="$CANONICAL_DIR/$path"
	dst="$WORKTREE_DIR/$path"
	[ -e "$src" ] || continue          # nothing built yet in the canonical checkout
	[ -e "$dst" ] && continue          # already present; never clobber

	case "$(uname -s)" in
	Linux)   cp -a --reflink=auto "$src" "$dst" ;;
	Darwin)  cp -Rpc "$src" "$dst" ;;
	*)       cp -a "$src" "$dst" ;;    # FreeBSD: copy_file_range clones transparently
	esac
done

The two guards matter as much as the copy. Skipping an absent source keeps wt mk working in a repo whose canonical checkout has never been built — a failing setup hook rolls the whole checkout back. Skipping an existing destination makes the hook safe to re-run with wt hooks run worktree-setup.

On the mechanism. cp --reflink=auto (Linux) and cp -c (macOS) request a copy-on-write clone, and FreeBSD's cp gets one through copy_file_range(2) without a flag; each falls back to a full copy when the filesystem can't oblige, which is why the recipe needs no fallback of its own. On macOS that fallback is silent — copying onto a non-APFS volume exits zero and lands the whole file — so a guard around cp -c would never fire. Whether it can is not something the filesystem's name answers — XFS clones only when it was made with reflink=1, and ZFS only when block cloning is enabled (vfs.zfs.bclone_enabled on FreeBSD, zfs_bclone_enabled on Linux), which OpenZFS still ships off by default. The kernel decides per file, so the hook needs no detection of its own; a clone saves the disk space, and where you don't get one the copy is still far cheaper than rebuilding.

Seed only what survives the move. List the paths deliberately rather than sweeping everything gitignored: a Python virtualenv bakes in absolute paths and breaks when copied, and stale artifacts are worth less than the space they take. Build caches, dependency trees, and generated assets too large for git are the paths that pay.

Because hooks receive CODE_ROOT, several repos can share one copy of this instead of each carrying its own — keep the body in the workspace meta-repo and have each repo's hook source it:

#!/bin/sh
. "$CODE_ROOT/.worktree/lib/seed.sh"
seed target

Navigation (wt cd)

A program can't change its parent shell's directory, so wt cd needs a tiny shell wrapper. Add it once:

# bash / zsh — in ~/.bashrc or ~/.zshrc:
eval "$(wt shell-init bash)"
# fish — in ~/.config/fish/config.fish:
wt shell-init fish | source

With that in place, wt cd <checkout> changes directory, and wt mk --cd / wt get --cd drop you into the new checkout as they create it. wt shell-init covers bash, zsh, fish, elvish, powershell, and nushell; wt completions <shell> adds tab-completion (including live checkout names).

Keeping the workspace healthy

A few commands keep a multi-checkout, multi-repo workspace tidy:

wt sync                        # fetch every repo here (refresh the wt ls staleness/ahead-behind counts)
wt doctor                      # check the invariants — flags any dir!=branch drift, orphaned worktrees, etc.
wt doctor --fix                # …and auto-remediate the safe findings
wt exec -- git fetch           # run a command in every checkout (a multi-worktree fan-out)

wt doctor is worth running if something feels off: it's the tool that catches a checkout whose directory no longer matches its branch (the one way to break the core invariant), and --fix cleans up the safe cases.

Beyond one repo: agent_workspace_template

Everything above manages worktrees within one repo. If you're juggling a dozen-plus repos side by side — the common shape for an agent-driven, multi-project workspace — wt alone doesn't give the top-level directory holding them any structure of its own; each session still opens cold, with no sense of what the repos are or how they relate.

agent_workspace_template is a meta-repo skeleton that sits above your checkouts and fills that gap: a workspace overview, one short self-contained brief per project, and a mechanism that auto-loads only the brief a coding-agent session is actually touching — without turning into a stale second copy of each repo's own conventions. It uses wt as its per-project checkout manager, so every project directory underneath already follows the one-checkout-per-branch layout this guide describes; the template just gives the directory above them a spine. If you've outgrown a single AGENTS.md for your whole workspace, it's worth a look.

Where to go next