← osss tools

wt worktree v0.5.4

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

Download v0.5.4 →

Prebuilt binaries are attached to each release. On macOS — a universal2 binary. With Homebrew:
brew tap osss/tools https://gitlab.osss.net/distribution/homebrew-tools.git
brew install osss/tools/wt
…or with MacPorts — add the osss ports tree once, then install:
sudo curl -fsSL https://macports.osss.net/osss-ports.pub -o /opt/local/share/macports/keys/ports/osss-ports.pub
echo 'https://macports.osss.net/ports.tar.gz' | sudo tee -a /opt/local/etc/macports/sources.conf
sudo port sync && sudo 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)

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, and whether it holds work you haven't pushed:

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)

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

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 .wt/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
# .wt/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.

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.

Where to go next