One read-only clone per repository, and a git worktree, branch and Claude Code session for every task. Built for working on several tickets in parallel while reviewing each one in your IDE.
~/git/acme/api/ the "container": a plain folder, not a git repo
├── main/ the clone: stays on main, never edited
├── worktrees/
│ ├── fix-login-bug/ branch PROJ-123-fix-login-bug
│ └── add-csv-export/ branch feat/add-csv-export
└── wt.yml per-repository settings: which IDE to open, what to symlink
from main/ into every worktree (nothing by default)
make install # go install ./cmd/wt
make install-skill # symlink skills/wt into ~/.claude/skills (the /wt skill)Shell completion (slugs complete for rm, path, claude, open): wt completion zsh --help.
# New: clone into ./api (or a directory you name, like git clone), choosing the IDE and which
# gitignored files to share
wt clone git@github.com:acme/api.git \
--ide goland \
--link 'config/local*.yaml' --link .env --link '.env.*' --link .idea/runConfigurations/ \
--seed ~/git/old/api # copy those files from an existing clone
# A repository that needs nothing shared and opens in VS Code
wt clone git@github.com:acme/web.git ~/git/acme/web --ide vscode
# Existing clone with the repository directly in it (~/git/acme/api/.git): convert it in place
wt convert ~/git/acme/api --link .env
# Existing <container>/main clone
cd <container>/main && wt initwt convert [<directory>] (default: the current directory) moves the clone to <directory>/main, so the
directory becomes the container and the repository keeps its name and place. Nothing inside the
clone changes: uncommitted work, stashes and gitignored local config come along. It then does what
wt init does, and takes --link and --ide like wt clone. Your shell stays at the old path,
now the container; cd main to get back into the clone. wt expects main/ to stay on the default
branch and clean, so convert says so when it is not, but changes neither. A clone with linked
worktrees is refused, because they would lose track of it: remove them first.
Nothing is shared between worktrees unless you ask for it: wt.yml links nothing to start with, and
--link (repeatable) is how a repository opts in. --seed copies exactly the --link paths.
wt init is safe to repeat. It writes a wt.yml that links nothing if there is none, names the main clone's
JetBrains project after the repository (so its window is not called "main"), and installs a
pre-commit hook that refuses commits in main/ (linked worktrees are unaffected). An existing,
different pre-commit hook is left alone and reported.
wt new login-bug --prefix PROJ-1234 # worktree "login-bug", branch PROJ-1234-login-bug, from origin/main
wt new login-bug --prefix fix/ # branch fix/login-bug (also chore/, feat/, ...)
wt new spike # no prefix needed: worktree "spike" on branch "spike"
wt new --branch feat/login # work on an existing branch, e.g. to review a PR
wt claude fix-login-bug # Claude session "api: fix-login-bug"
wt claude fix-login-bug -c # continue that worktree's last session
wt open fix-login-bug # open in this repository's IDE (see "IDE per repository")
wt ui # interactive mode, see below
wt ls # slug, branch, commits ahead, dirty?, merged?
wt ls --pr # ... and each branch's pull request (asks GitHub, slower)
wt rm fix-login-bug # remove; also deletes the branch if no commits are lostwt new takes a slug and accepts --prefix, --branch, --base <ref>, --no-fetch and
--claude (start the session right away).
--prefixgoes in front of the branch name, never the directory. It is used as typed (fix/,chore/,PROJ-1234-), except that a bare ticket ID such asPROJ-1234gets its dash. Nothing is ever guessed: without a prefix, the branch is named after the slug.--branch <name>works on that branch instead, which is what you want to review a PR in your IDE or with Claude. It is checked out if it exists locally, tracked fromoriginif it exists there (after a fetch), and created from--baseotherwise. The slug is optional and derived from the branch, sowt new --branch feat/PROJ-1234-loginmakes the worktreefeat-proj-1234-login. It cannot be combined with--prefix, and git refuses a branch that is already checked out in another worktree. Only branches onoriginare found, not ones from forks.
wt rm refuses a worktree with uncommitted or untracked changes unless you pass --force. It
always removes the worktree, but deletes the branch only when that loses nothing: the branch has
no commits beyond origin/main, or its remote branch is gone (a merged PR whose branch was
deleted, which also covers squash merges). Otherwise it keeps the branch and says why.
wt ls --pr and wt ui show, for every worktree, its PR and whether its work is MERGED
into the default branch (plain wt ls leaves out the PR column):
SLUG BRANCH AHEAD STATE PR MERGED
fix-login-bug PROJ-123-fix-login-bug 3 dirty #812 open no
add-csv-export PROJ-120-add-csv-export 2 clean #790 merged yes
old-spike old-spike 1 clean none remote gone
fresh fresh 0 clean none no commits
- PR comes from the GitHub CLI (
gh pr list --head <branch>):open,draft,approved,changes requested,mergedorclosed;nonewhen the branch has no pull request,?when the lookup failed (nogh, not logged in, offline). The lookup asks GitHub, so it is opt-in forwt ls(--pr);wt uishows the list first and fills the column in. - MERGED is
yeswhen every commit is in the default branch, also after a rebase merge, or when the pull request is merged and the branch has no commits made after it.no commitsis a branch with nothing of its own (fresh, or merged by fast-forward: git cannot tell).remote goneis a pushed branch whose remote branch was deleted, which is what a squash-merged PR looks like whenghis unavailable. - Merge status is as fresh as the last fetch.
wt newandwt rmfetch; so doesgit fetch.
A full-screen list of the repository's worktrees: slug, branch, commits ahead, clean or dirty, pull request and merge status (the pull requests load in the background).
| Key | Does |
|---|---|
↑ ↓ / j k |
move |
enter |
start a Claude session in the worktree, named <repo>: <slug> |
c |
continue the worktree's last Claude session |
o |
open it in the repository's IDE |
n |
new worktree: type [slug] [--prefix P] [--branch B], e.g. login-bug --prefix fix/ or just spike |
d |
remove it, after a yes/no; a dirty worktree asks explicitly before discarding changes |
l |
shared files: a add, d remove, s sync into every worktree |
r / q |
refresh / quit |
A Claude session takes over the terminal and brings you back to the list when it ends. wt ui
needs a terminal; every action is also available as a plain command.
wt open and the o key use the IDE chosen for the repository, so Go repositories can open in
GoLand and Node ones in VS Code. It is stored in wt.yml next to main/:
wt ide # show the IDE and where the choice comes from
wt ide set vscode # goland, webstorm, idea, pycharm, vscode, cursor ... or any macOS app name
wt ide set "Sublime Text" # not a preset: runs `open -a "Sublime Text" <worktree>`
wt ide unset # back to $WT_IDE_APP, then the global IDE, then GoLandPrecedence: wt.yml, then $WT_IDE_APP (a preset name or an app name), then the global IDE
(below), then GoLand. Only
JetBrains IDEs get a project name (.idea/.name); a VS Code repository is left without .idea/.
Switching IDE does not touch worktrees that already exist.
Settings for every repository live in ~/.config/wt/config.yml ($XDG_CONFIG_HOME/wt/config.yml
when that is set), for example ide: vscode. A repository's own wt.yml wins over them. wt global edits the file; editing it by hand works too:
wt global # the file and the current settings
wt global set ide vscode # the IDE for repositories that do not choose one
wt global unset ideide is the only setting so far.
Gitignored files that worktrees need (local config, .env, GoLand run configurations) live once,
in main/, and each worktree symlinks to them with a relative link. Editing config/local.yaml
anywhere edits it everywhere. Which files are worth sharing depends on the repository, so
nothing is shared until you list it, at wt clone --link or later:
wt link ls # what is shared, and how many files each path selects in main/
wt link add 'config/local*.yaml' .env # list it AND link it into every existing worktree
wt link rm .env # unlist it AND remove the links from every existing worktree
wt link sync # after editing wt.yml by hand: link everything into every worktreeThe paths are the links list in wt.yml (wt new reads it too):
ide: goland
links:
- config/local*.yaml
- .env
- .env.*
- .idea/runConfigurations/wt keeps the comments and any other keys when it rewrites the file.
- Globs are allowed. Paths that don't exist in
main/yet are listed anyway, and linked once they exist (wt link sync). - A trailing
/marks a directory. It is created inmain/if missing, and the whole directory is linked, so run configurations saved from inside a worktree land inmain/too. - Files a worktree already has (tracked ones such as
.env.example) are never replaced. wt link rmremoves only symlinks that point intomain/, never the files inmain/, and leaves everything else in a worktree alone. A path must be listed to be removed.- Linked paths that
.gitignoredoesn't cover are added to.git/info/exclude, so they cannot be committed by accident. Those lines stay afterwt link rm; they are harmless.
GoLand only writes run configurations to .idea/runConfigurations/*.xml when they are marked
Store as project file in the run configuration dialog. Otherwise they stay in workspace.xml,
which is per project and not shared.
-
The
pre-commithook installed bywt initblocks commits inmain/. -
wt guardis a Claude CodePreToolUsehook that denies Edit, Write and NotebookEdit calls targeting amain/clone. It only acts inside containers that have awt.yml, whichwt cloneandwt initalways create. Register it in~/.claude/settings.json:"hooks": { "PreToolUse": [ { "matcher": "Edit|Write|NotebookEdit", "hooks": [{ "type": "command", "command": "wt guard" }] } ] }
Paths are compared lexically, so a symlinked config file edited through a worktree path counts as a worktree edit. Bash commands are not inspected.
- Permission deny rules and symlinks. A rule like
Read(./config/local.yaml)is relative to the project root. A session in a worktree can still reach the real file by its absolute path inmain/. Use**/config/local*.yamlstyle patterns for anything sensitive. - Claude memory is per directory. Every worktree starts with its own, empty auto-memory.
- Shared infrastructure. Worktrees share one set of local config, hence one database and one set of emulators. Run integration tests in one worktree at a time.
- JetBrains IDEs name projects after the directory. For repositories that use one,
wtwrites.idea/.nameas<repo> › <slug>. If your version ignores it, prefix the directory instead. VS Code and other editors show the directory name, i.e. the slug.
make test # race-enabled tests, real temporary git repositories
make lintLayout: cmd/wt (entry point), internal/cli (cobra and rendering only), internal/tui
(bubbletea interactive mode, tested against a fake backend), internal/manager (the operations,
returning plain data), internal/layout, internal/links, internal/config
(wt.yml and the global config.yml), internal/ide, internal/session, internal/guard, internal/gitx.