Every branch of your repo, running side by side — each git worktree with its own server, port, Redis slot, and database.
Features · Why · Install · Quick start · Commands · Setup · Config · Stack guides · Caveats
wt turns git worktrees into complete, isolated development environments,
from one POSIX-sh file. Create a branch and its worktree in one step. Start
its server and it gets a stable port and its own database without being
asked. Run the test suite in five worktrees at once without them clobbering
each other. When the branch is done, one command squashes, merges, and
reclaims everything it used: server, database, Redis slot, worktree, and
branch.
It exists so that many branches, and many coding agents, can work on one
repo at the same time without stepping on each other. The core is
stack-agnostic and only runs commands your repo declares in a
worktree-kit.yml; templates for Rails, Laravel, Django, Node, and Go, on
Docker Compose or the bare host, get you there with one wt init.
$ wt new feat/refund-flow --server
wt: feat_refund_flow [isolated] -> http://localhost:3412 (logs: wt logs feat_refund_flow)
$ wt run --isolated bundle exec rspec spec/ # this branch, in its own test database
$ wt merge # squash → rebase → fast-forward trunk → tear down
wt: backup: refs/wt/premerge/feat_refund_flow -> 9f1c2ab
wt: merged feat/refund-flow into master
wt: undo: git -C /Users/you/code/myapp reset --hard 4e0d7c1 (branch backup: refs/wt/premerge/feat_refund_flow)|
🌳 The whole worktree lifecycle
|
🚀 A server per branch, no port juggling
|
|
🔒 Three isolation levels, chosen for you
|
🧪 Test suites in parallel Every worktree gets its own test database at |
|
🐳 Docker Compose or bare host, same config The compose runner mounts the worktree over your service's checkout in a
one-off container, so |
🧩 Stack templates, stack-agnostic core
|
|
📎 Personal files follow you into every worktree
|
🛡️ Safe by default
|
|
⚡ Zero dependencies, millisecond startup One POSIX |
🐚 Shell integration that actually changes directory
|
Parallel work on one repo — several branches, several coding agents, or both — runs into the same four walls:
- Docker runs the wrong code. Compose mounts only the primary checkout,
so
docker execin a worktree silently runs the primary's code. - Servers fight over ports. Every branch wants
:3000. - Branches share one everything. One database, one Redis, one job queue. A migration on one branch breaks every other; a spec run on one branch reloads the schema under another.
- Worktrees are a chore.
git worktree add,cd, install deps, find a free port, remember which database belongs to which branch, and later remove the worktree, delete the branch, and drop the database you forgot.
wt takes down all four: per-worktree containers or processes running the
right code, stable auto-assigned ports, opt-in isolation with its own Redis
DB and database, and a lifecycle that creates and reclaims all of it with
one command each.
# macOS or Linux, with Homebrew
brew install VSN2015/tap/worktree-kit
# with npm (or try it one-off: npx worktree-kit doctor)
npm install -g worktree-kit
# any Linux (or macOS) without Homebrew or npm — downloads the latest release
curl -fsSL https://raw.githubusercontent.com/VSN2015/worktree-kit/master/install.sh | shor from source:
git clone https://github.com/VSN2015/worktree-kit && cd worktree-kit && ./install.shwt is POSIX sh and runs on macOS and Linux (tested on glibc/Debian and
musl/Alpine). There are usually no dependencies to install: it reads its
YAML config with whichever of yq, ruby, or python3 + PyYAML is already on
your PATH (macOS ships ruby; wt doctor shows which one is in use), and
checks ports with lsof, ss, or a ruby/python bind probe — whichever exists.
The YAML reader only runs when the config changes — wt caches the parsed
config under .git/wt-state/ and sources it, so everyday commands start
in milliseconds with no interpreter spawns. Compose repos additionally
need docker.
# 1. once per repo: detect the stack, write worktree-kit.yml, check the setup
cd ~/code/myapp
wt init && wt doctor
# 2. once per machine: let wt cd your shell (bash, zsh, or fish)
echo 'eval "$(wt shell-init zsh)"' >> ~/.zshrc && exec zsh
# 3. a branch, its worktree, and its server in one step
wt new feat/login --server
# -> ~/code/myapp-worktrees/feat_login http://localhost:3xxx [isolated]
# 4. work on it: the suite on this branch, in this branch's own test database
wt run --isolated bin/rails db:test:prepare # once per worktree
wt run --isolated bundle exec rspec spec/
# 5. see everything, hop around
wt list
wt switch # fzf picker over every worktree
# 6. done: squash into one commit, rebase, fast-forward master, reclaim everything
wt mergeNote
The one manual step per stack: your app has to read the env vars wt exports
(a one-line change in database.yml, settings.py, or the like). The
stack guides show it for each
framework, and the template wt init writes names the variables.
| command | what it does |
|---|---|
wt new <branch> [--from <base>] [--server] |
create a branch + worktree, optionally start its server |
wt switch [<branch>] |
cd to a worktree; no argument opens a picker |
wt list [--all] |
every worktree: branch, slug, status, server, isolation |
wt rm [<branch>] [--keep-branch] [--force] |
tear down and remove a worktree |
wt merge [<branch>] [--into <trunk>] [-m <msg>] [--no-remove] [--force] |
squash, rebase, fast-forward trunk, then tear down |
wt run [flags] [--] <cmd...> |
one-off command in this worktree |
wt server [flags] [port] |
start this worktree's server, detached |
wt up [flags] [slug...] |
start servers for all (or the named) worktrees |
wt down [slug...] |
stop servers — all of them, or the named ones |
wt ps |
list running worktree servers |
wt logs [slug] |
follow a server's logs |
wt localize <file...> |
snapshot a personal overlay (--list / --remove) |
wt link [--all | slug...] |
symlink links: paths from the primary into worktrees |
wt reset [slug] |
clear the own-db bootstrap marker |
wt init |
write worktree-kit.yml from a stack template |
wt doctor |
environment + config checks |
wt shell-init [bash|zsh|fish] |
emit the shell function that makes switch/new/rm/merge cd |
The isolation flags --shared / --isolated / --own-db work on run,
server, and up; the isolation section covers what each
level exports. wt --help prints this summary and wt --version the
version; both work outside a git repo.
cd your-repo && wt init # detects the stack, writes worktree-kit.ymlwt init picks the stack by marker file (checked in this order) and the
runner by whether a compose file (docker-compose.yml / compose.yml, or
the .yaml spellings) sits at the repo root. Every stack ships in both
flavors — templates/compose/<stack>.yml and templates/host/<stack>.yml:
| marker file | stack |
|---|---|
Gemfile |
rails |
artisan |
laravel |
package.json |
node |
manage.py |
django |
go.mod |
go |
(artisan is checked before package.json because Laravel repos also carry
one.) The template is a starting point: read the reference below, fix the few
values that don't match your repo, make your app config read the isolation
env vars (the one manual step — see the stack guides), then run wt doctor.
Tip
Commit worktree-kit.yml — it holds no secrets.
The files in templates/ are references, not the limit — wt is
stack-agnostic and only ever runs the commands your config declares, so any
stack (PHP without Laravel, Elixir, Rust, a static-site build, …) works:
copy the closest template to worktree-kit.yml at your repo root and swap in
your own commands. When wt init can't detect a stack, it says exactly that.
A template for a new stack is also all it takes to extend detection —
PRs welcome.
The compose Rails template (templates/compose/rails.yml), annotated. The
other templates are subsets of the same schema — host variants drop the
compose/volumes/mounts keys entirely. Every key has a default or can
be omitted; wt server needs hooks.server, and everything else degrades
gracefully.
version: 1 # schema version; reserved, not read today
runner: compose # compose | host — how commands run (default: compose)
worktrees:
path: "{parent}/{repo}-worktrees/{branch}" # last segment becomes the slug
# trunk: master # optional; what `wt merge` targets
compose: # read only when runner: compose
service: app # docker-compose.yml service whose image runs your code
workdir: /app # where that image expects the checkout
volumes: # named docker volumes added to every wt container
- name: "{project}_wt_cache"
path: /usr/local/bundle
mounts:
read_only: [node_modules] # dirs mounted read-only from the primary checkout
links: [.claude] # gitignored paths symlinked from the primary into every worktree
hooks:
prepare: "bundle check >/dev/null 2>&1 || bundle install --jobs=4 --retry=3"
build: "yarn build"
server: "rm -f tmp/pids/server.pid && bundle exec rails s -b 0.0.0.0 -p {container_port}"
container_port: 3000
isolation:
isolated_env: # env exported at --isolated and --own-db
REDIS_URL: "redis://redis:6379/{n}"
TEST_DATABASE: "wt_{slug}_test"
own_db_env: # env exported only at --own-db
DEV_DATABASE: "wt_{slug}"
db_check: "bundle exec rails runner \"ActiveRecord::Base.connection.execute('SELECT 1 FROM schema_migrations LIMIT 1')\""
db_bootstrap: "SKIP_TEST_DATABASE=1 bundle exec rails db:create db:schema:load"
migration_paths: [db/migrate]
db_drop: "SKIP_TEST_DATABASE=1 bundle exec rails db:drop"
redis_flush: "redis-cli -h redis -n {n} flushdb"compose— everywt run/wt serveris a one-offdocker compose run --rmcontainer on your existing compose project, with the worktree mounted overcompose.workdirin place of the primary checkout. That mount is the whole trick: the container runs the worktree's code. Your normaldocker compose upstack should already be running so services like the database and Redis are reachable; wt never touches it.host— plain processes on your machine, started in the worktree directory with the isolation env vars exported.wt serverdaemonizes with nohup and tracks a pidfile under.git/wt-state/.
path(default{parent}/{repo}-worktrees/{branch}) — wherewt new <branch>creates the worktree. The last path segment becomes the slug, and the slug names the port, the Redis{n}slot, and thewt_{slug}database — so keep{branch}(or{branch_raw}) last.{branch}folds every character outside[A-Za-z0-9._]to_, hyphens included:wt new feat/refund-flowgives directory (and slug)feat_refund_flow, notfeat_refund-flow. That is on purpose — it makes the directory name and the slug identical, so what you see inwt listis exactly what names your database. (.is the one survivor: branchfeat.xgives directoryfeat.xbut slugfeat_x.){branch_raw}keeps the branch name verbatim, slashes and all — which nests directories and makes only the final segment the slug, so use it in a middle segment, not the last one. A template whose last segment isn't branch-unique (a fixed literal, say) makes every worktree share one slug, and with it one port, one Redis DB, and one database;wt doctorwarns when it detects this. The expanded path also can't contain a space —wt's compose args and host env lists are built by word-splitting, sowt newrefuses such a path andwt doctorwarns about it too.worktrees.pathis the natural key to override per user inworktree-kit.local.yml— everyone shares the repo's stack config but can keep worktrees under their own preferred directory.trunk(default:origin/HEAD, thenmain, thenmaster) — the branchwt mergemerges into, and the branchwt rmchecks a branch is fully merged against before removing it without--force.
service (default app) names the service in your docker-compose.yml
whose image contains your runtime. workdir (default /app) is where that
image expects the code to be mounted — copy it from the service's volumes:
entry in your compose file.
Named docker volumes mounted into every wt container. Use them for state
baked into the image that a one-off container would otherwise reset — the
classic case is the Rails gem dir /usr/local/bundle: without a volume,
every wt container regresses to the gems baked into the image, ignoring your
branch's Gemfile.lock. On first use docker seeds the empty named volume
from the image's content at that path; after that the prepare hook keeps it
current per branch. Template variables work in name, so {project}_wt_cache
gives one shared cache per repo.
Directories served read-only from the primary checkout into each
worktree's container — dependency dirs you don't want to reinstall per
worktree (e.g. node_modules). Read-only means yarn build works but
yarn install fails loudly instead of corrupting the shared copy; install
new deps from the primary checkout.
Paths in the primary checkout symlinked into every worktree — for
personal folders that git ignores and that therefore exist only in the
primary, such as a repo-local .claude/ holding Claude Code skills, rules
and hooks. wt new creates the links, wt link creates or repairs them in
existing worktrees, and wt doctor reports a worktree that is missing one.
Nothing already at the destination is ever removed or replaced.
A gitignore rule with a trailing slash (.claude/) matches directories
only, so git shows the symlink as untracked (?? .claude). When that
happens wt appends the anchored path (/.claude) to .git/info/exclude —
shared by every worktree of the repo — and says so; the link then shows as
ignored (!! .claude) everywhere.
prepare— runs before everywt runandwt server, on both runners. Make it an idempotent self-heal that is near-instant when there is nothing to do (bundle check || bundle install).build— runs once before the server starts (asset builds). Omit it if you have none.server— the long-running server command; the only required hook forwt server. With the compose runner it must bind0.0.0.0and listen on{container_port}; with the host runner it should listen on{port}.container_port(default 3000) — the in-container port;wt serverpublishes{port}:{container_port}. Ignored by the host runner.
Three levels, each a superset of the last:
| level | what wt exports | typical meaning |
|---|---|---|
shared |
nothing | primary's DB, Redis, queues |
isolated |
isolated_env |
own Redis DB {n} + own test DB |
own_db |
isolated_env + own_db_env |
own dev database wt_{slug} too |
isolated_env— env for anything cheap to segregate. The usual entries are a Redis DB number via{n}(a stable per-worktree hash in 1–15; db 0 is deliberately left to your main stack) and a per-worktree test database name (TEST_DATABASE: "wt_{slug}_test"). The test DB lives here rather than atown_dbbecause it needs no data bootstrap — the framework loads it from the schema — and it is what makes concurrent spec runs across worktrees safe.own_db_env— env that points the app at a per-worktree database, usually named with{slug}.db_bootstrap— creates and loads that database. It runs once per worktree (a marker file under.git/wt-state/remembers);wt resetclears the marker to force a re-bootstrap.db_check(optional) — a command that exits 0 when the worktree's DB already exists and is usable, letting wt skip the bootstrap and just write the marker.migration_paths— dirs compared file-by-file against the primary checkout. If the worktree has files the primary lacks (i.e. new migrations),wt serverauto-escalates that worktree toown_db, because migrating the shared DB would break every other branch. Entries may be globs — the Django templates use*/migrationsto cover every app.db_drop(optional) — destroys the per-worktree database.wt rmandwt mergerun it only when the worktree's database carries a.dbownedmarker — i.e.db_bootstrapcreated it, as opposed todb_checkmerely finding one that already existed, andwt resethasn't cleared that marker since. Withoutdb_dropconfigured (or when the database was only adopted), wt prints the database name and leaves it in place. Two cases leave a database wt did create without that marker, and both are deliberate — wt errs toward keeping data: (a) databases bootstrapped before wt 0.2.0, when.dbowneddid not exist yet. On your firstwt rmafter upgrading, those report "no ownership marker" and are left alone. To reclaim one, drop the database by hand and let the next--own-dbrun rebuild it: only thedb_bootstrappath writes.dbowned, and it runs only when the database is missing.wt resetalone will not do it — it clears both markers, but the next run then finds the database still there viadb_checkand re-adopts it, which is case (b). (b) a database re-adopted afterwt reset:db_check(which the rails templates configure) only ever writes.dbready, so the database thatdb_checkfinds on the next run is never re-marked as owned.redis_flush(optional) — empties this worktree's Redis DB{n}onwt rm/wt merge. Without it, the DB is left as-is.
Important
wt only exports env vars — your app config must read them. Nothing happens for a var your framework ignores; see the stack guides below for the one-line config change each stack needs.
How a level is chosen: CLI flag (--shared / --isolated / --own-db)
beats a worktree-kit.local.yml pin, which beats the automatic choice.
wt server auto-chooses at least isolated (escalating per
migration_paths); wt run defaults to shared — fine for one-off
commands, but a shared spec run uses the primary's test database, so
running suites in several worktrees at once will clobber each other (schema
reloads across branches, committed test data). Run concurrent suites at
--isolated, where each worktree gets its own test DB.
Usable in every hook command and env value:
| variable | value |
|---|---|
{slug} |
worktree dir name, lowercased, ≤ 40 chars (phase02) |
{n} |
stable per-slug number 1–15 (Redis DB slot) |
{port} |
the host port (set for wt server; empty in wt run) |
{container_port} |
hooks.container_port |
{project} |
primary checkout dir name, lowercased |
Values containing spaces are not supported (the shell glue word-splits); keep hooks with complex quoting in a script file and call that instead.
Ports are auto-assigned: a stable hash of the slug in 3000–3899, bumped
upward until free — so each worktree keeps a predictable URL across restarts.
To pin a port or an isolation level for yourself, drop a
worktree-kit.local.yml next to the main config (gitignore it):
servers:
my-task: { port: 3005, isolation: own-db }A pinned port is checked but never auto-bumped: wt server refuses to start
if it is busy.
wt init(aGemfileplus a compose file selectcompose/rails.yml).- Set
compose.serviceandcompose.workdirto match yourdocker-compose.yml. - Make
config/database.ymlread the own-db var, with your normal dev DB as the fallback:development: database: <%= ENV.fetch('DEV_DATABASE', 'myapp_development') %>
- Same for the test section, so concurrent rspec runs across worktrees don't
share one test DB (rspec's
maintain_test_schema!reloads the schema per branch — on a shared DB that clobbers whoever else is mid-run):Create it once per worktree (test: database: <%= ENV.fetch('TEST_DATABASE', 'myapp_test') %>
wt run --isolated bin/rails db:test:prepare), then run suites withwt run --isolated bundle exec rspec .... - Check the Redis var name: the template exports
REDIS_URL; if your app configures Redis/Resque/Sidekiq some other way, export whatever it actually reads inisolated_env. - Keep
SKIP_TEST_DATABASE=1indb_bootstrap: in Rails 6.x a baredb:schema:loadin development also force-reloads the test database — without the guard, bootstrapping a worktree DB can drop tables out of the shared test DB. Prefer per-database tasks (db:schema:load:primary) if you have multiple databases. - Keep the gem-cache volume and the
prepareself-heal — gems bake into the image at/usr/local/bundle, and this pair is what lets each branch'sGemfile.lockwork in one-off containers. - Smoke-test:
wt doctor, thenwt run bin/rails runner 'puts Rails.env', thenwt serverand open the printed URL.
Background jobs: a worker only polls the Redis DB it was started against, so
an isolated worktree needs its own worker started with the same flag —
wt run --isolated bundle exec rake environment resque:work.
- Laravel — exported env beats
.env, soREDIS_DB/REDIS_CACHE_DB/DB_DATABASEwork out of the box. One trap:php artisan config:cachefreezes config and the exported env is silently ignored — don't cache config in development. Adjustdb_bootstrap'smysql -uroot ...line for your engine (postgres:createdb wt_{slug}). Tests:phpunit.xmlpins one sharedDB_DATABASEfor every worktree — makeconfig/database.phpprefer the exportedTEST_DB_DATABASEwhenAPP_ENV=testing(snippet in the template), create the DB once, and run suites withwt run --isolated php artisan test. - Django —
settings.pymust read the vars:NAME: os.environ.get("DATABASE_NAME", "myapp"), same idea for the Redis URL used by your cache/queue. Tests: point theTESTname at the exported var —"TEST": {"NAME": os.environ.get("TEST_DATABASE_NAME", "test_myapp")}— and runwt run --isolated python manage.py test; Django creates and destroys the DB itself. - Node — the template exports
DATABASE_URL/REDIS_URLand assumes prisma fordb_bootstrap; swap in your ORM's migrate command and make sure your config reads those URLs rather than hardcoding. Tests: point your test setup at the exportedTEST_DATABASE_URLand have it create and migrate the DB, thenwt run --isolated npm test. - Go — same idea: read
DATABASE_URL/REDIS_URLfrom the environment, and pointdb_bootstrapat your migration tool. Tests: read the exportedTEST_DATABASE_URLin your test helper andwt run --isolated go test ./....
Every stack ships both runner variants under templates/compose/ and
templates/host/ — if wt init picks the wrong one (say, a compose file
that isn't your dev stack), copy the other variant over worktree-kit.yml.
wt takes its context from git, not from the worktree: it resolves the
primary checkout through the git common dir and reads worktree-kit.yml
from there. A worktree therefore needs zero setup, whether wt new made it
or you ran git worktree add yourself.
The wt way, start to finish:
wt new feat/login --from origin/main # branch + worktree at worktrees.path, prepare hook run
wt server # own port, auto isolation
wt run --isolated bundle exec rspec spec/ # own test DB
wt merge # squash, rebase, fast-forward trunk, reclaim everythingPlain git works just as well; wt joins in whenever you call it:
cd ~/code/myapp # primary checkout (has worktree-kit.yml)
git worktree add ../myapp-fix-login fix-login # plain git — wt is not involved yet
cd ../myapp-fix-login
wt run --isolated bundle exec rspec spec/ # specs on this branch, own test DB
wt server # own port + auto isolation for this branch
wt rm # done: server, DB, Redis slot, worktree, branchwt rm stops the server, drops the wt_<slug> database when wt created it
(and db_drop is configured), flushes the Redis slot when redis_flush is,
then removes the worktree and its branch; --keep-branch keeps the branch.
The manual equivalent is wt down <slug>, git worktree remove, and
wt reset <slug>. A database wt merely adopted rather than created is yours
to drop, and wt says so instead of touching it.
Two consequences of how slugs work:
- The worktree directory name becomes the slug (lowercased,
non-alphanumeric →
_), and the slug drives the port hash and the Redis{n}slot — so name worktree dirs distinctly (../myapp-phase02, not../wt2).wt newdoes this for you throughworktrees.path. Names longer than 40 chars are cut to a prefix plus a checksum of the full name, sowt_{slug}_test-style database names always fit inside the 63/64-char identifier limits of postgres/mysql. - The worktree itself carries no config; running
wtfrom the primary checkout also works and is always treated asshared.
wt switch, wt new, wt rm, and wt merge can all change which worktree
you're standing in — but a subprocess can never cd its parent shell, so wt
prints the target path on stdout (the "cd channel") and leaves the actual
cd to your shell. Wire that up once:
eval "$(wt shell-init zsh)" # or: bash, fishAdd that line to ~/.zshrc (or ~/.bashrc, or fish's config.fish) and
open a new shell. It defines a wt shell function that intercepts switch,
new, rm, and merge — running the real binary with
WT_SHELL_INTEGRATION=1 set (so wt doctor can report it as active) and
cd-ing to whatever path it printed. Every other subcommand passes straight
through to the real wt.
Without it, those four commands still do everything else they normally do — they just print the path instead of changing directory:
$ wt switch feat/login
/Users/you/code/myapp-worktrees/feat_login
wt: not cd'd — install shell integration: eval "$(wt shell-init zsh)"so cd "$(wt switch feat/login)" works too, if you'd rather not install the
function.
Every command takes its context from the directory you run it in: wt
resolves the primary checkout through git, reads worktree-kit.yml from
there, and derives this worktree's slug from its directory name
(lowercased, non-alphanumeric → _, so ../myapp-fix-login becomes
myapp_fix_login). Commands that name a worktree (up, down, logs,
reset, link) take that slug, not a path — wt ps shows the slugs of
everything running. The lifecycle commands (new, switch, rm, merge)
take a branch name instead, since that is what you think in.
The table above lists every command; the sections below give the details.
wt run [--shared|--isolated|--own-db] [--] <command...>Runs one command in this worktree and exits. The prepare hook runs first
(both runners — with the same template variables and isolation env as the
command itself), then the command — in a fresh docker compose run --rm
container with the worktree mounted over compose.workdir (compose runner),
or as a plain process in the worktree directory (host runner). When attached
to a terminal the compose runner allocates a TTY, so interactive commands
like a Rails console work.
The default level is --shared (nothing exported) — fine for one-offs, but
concurrent test suites need --isolated (see Caveats).
--own-db bootstraps this worktree's database on first use. Flags come
before the command — parsing stops at the first word that isn't an isolation
flag (or at a literal --, for the rare command that itself starts with one).
wt run bundle exec rspec spec/models/foo_spec.rb # shared: hits the primary's test DB
wt run --isolated bundle exec rspec spec/ # own test DB — safe in many worktrees at once
wt run --own-db bin/rails db:migrate # against this worktree's own dev DB
wt run bin/rails console # interactive — the TTY passes through
wt run --isolated bundle exec rake resque:work # a worker polling this worktree's Redis DBwt server [--shared|--isolated|--own-db] [port]Starts the hooks.server command detached (after prepare and build) and
prints the URL. Without a flag the level is chosen automatically: a
worktree-kit.local.yml pin wins; the primary checkout is always shared;
a worktree whose migration_paths contain files the primary lacks escalates
to own_db; everything else runs isolated.
The port likewise: an explicit positional port beats a local.yml pin,
which beats the stable slug hash in 3000–3899 (bumped upward until free).
Explicit and pinned ports are checked but never bumped — wt server refuses
to start if one is busy, or if the port is not a number in 1–65535. It also
refuses if this slug already has a running server (wt down <slug> first).
Compose runner: a detached container named wt-<project>-<slug> publishing
<port>:<container_port>. Host runner: a nohup'd process with a pidfile
under .git/wt-state/, logging to .git/wt-state/logs/<slug>.log.
wt server # auto port + auto isolation
wt server 3050 # this exact port (refuses if busy)
wt server --own-db # force own dev database (bootstraps on first use)wt up [--shared|--isolated|--own-db] [slug...]Runs wt server in every worktree of the repo (the primary checkout is
skipped), each with its own auto-assigned port and auto-detected isolation.
Pass slugs to start only those; an isolation flag applies to every worktree
being started (it's passed through to each wt server, so it beats
worktree-kit.local.yml pins, like any CLI flag). One worktree failing to
start doesn't stop the others — a note is printed and wt up moves on.
Works from anywhere in the repo, primary or worktree.
wt up # a server for every worktree, auto isolation
wt up myapp_phase02 # just this one
wt up myapp_phase02 myapp_fix_login # these two
wt up --own-db # every worktree on its own dev database
wt up --own-db myapp_phase02 # just this one, forced to own-dbWithout a flag wt up uses the automatic choices, and per-worktree pins in
worktree-kit.local.yml apply. Ports can't be set from wt up — pin one in
local.yml, or cd into that worktree and run wt server <port>.
wt down [slug...]With no arguments stops every running worktree server of this repo; with
slugs, just those. Compose containers are removed on stop (they run with
--rm); host processes are killed and their pidfiles cleaned up.
wt down # stop them all
wt down myapp_phase02 # stop oneLists this repo's running worktree servers: slug, isolation level, URL, and
status. On the compose runner this reads container labels; on the host
runner it reads the pidfiles and reports running or dead.
myapp_phase02 isolated http://localhost:3247 Up 2 hours
myapp_fix_login own_db http://localhost:3105 Up 20 minutes
wt logs [slug]Follows a server's output (like tail -f); Ctrl-C stops following, not the
server. The slug defaults to the current worktree, so a bare wt logs
inside a worktree does the right thing. One runner difference: compose logs
live with the container, so they're gone once that server is stopped; host
logs persist in .git/wt-state/logs/<slug>.log.
wt localize config/database.yml # snapshot (re-run after editing the source)
wt localize --list # what's overlaid
wt localize --remove config/database.yml # drop an overlaySnapshots a tracked file into .git/local/ to be mounted read-only over the
container's copy — details in Overlays below.
wt link # link this worktree (run inside it)
wt link --all # every worktree except the primary
wt link feat_x # only the named slugsSymlinks each links: path from the primary checkout into the
worktree, so gitignored personal folders like .claude/ exist there too.
localize snapshots a file for containers; link symlinks a path for the
host and auto-runs on wt new. An existing file or directory at the
destination is reported and left alone, and a second run just reports ok.
wt reset [slug] # defaults to the current worktreeClears the marker that records "this worktree's database was bootstrapped",
so the next --own-db run bootstraps again. It only clears the marker — the
wt_<slug> database itself is never dropped; that's yours.
wt new <branch> [--from <base>] [--server]Creates the branch (or adopts an existing one of the same name) and a
git worktree add at worktrees.path, runs hooks.prepare once, and prints
the new worktree's path on the cd channel (see
Shell integration). --from <base> branches off
<base> instead of HEAD, and is refused for a branch that already exists
(adopting it would silently ignore the base); --server starts the server
immediately instead (it runs prepare itself, so plain wt new doesn't also
run it, to avoid running the hook twice).
wt new feat/login # new branch + worktree off HEAD
wt new feat/login --from origin/main # off a specific base
wt new feat/login --server # + start its server right awaywt switch [<branch>]Prints the worktree path for <branch> on the cd channel — or, with no
argument, opens an interactive picker (fzf if installed, else a numbered
menu) over every worktree, with a preview pane showing recent log and
status. Needs shell integration to actually cd;
without it, wt switch just prints the path.
wt rm [<branch>] [--keep-branch] [--force]Stops the worktree's server, drops its database via isolation.db_drop —
but only when the database carries a .dbowned marker, meaning wt itself
bootstrapped it rather than merely adopting one db_check found already
there (wt reset clears that marker, so an adopted-after-reset database is
left in place with a notice instead) — flushes its Redis slot if
isolation.redis_flush is configured, then removes the git worktree and
deletes the branch. Defaults to the branch checked out in the current
worktree. Prompts for confirmation, and refuses when the worktree is dirty
or has commits not yet in the trunk;
--force waives both the safety checks and the confirmation prompt.
--keep-branch removes the worktree but leaves the branch.
Warning
git worktree remove deletes the entire worktree directory, not just
git's registration of it — including files git never tracked: .env, local
SQLite databases, uploads, tmp/, everything .gitignore covers. The
dirty-worktree refusal does not protect these; it only looks at tracked and
untracked files, and ignored files are invisible to it. Copy anything you
want to keep out first.
wt merge [<branch>] [--into <trunk>] [-m <msg>] [--no-remove] [--force]Squashes every commit on <branch> since it diverged from the
trunk into one commit, rebases that commit
onto the trunk, fast-forwards the trunk to it in the primary checkout, then
tears the worktree down like wt rm --force does — git worktree remove --force plus git branch -D, and without prompting. That deletes the
whole worktree directory, gitignored files included (.env, local databases,
uploads, tmp/), with no confirmation step in between. A backup ref
(refs/wt/premerge/<slug>) captures the branch's pre-squash HEAD before
anything is rewritten; a squash failure, a rebase conflict, or a
non-fast-forward trunk all restore the branch to that commit and leave the
worktree in place, with the recovery command printed. --into <trunk>
overrides worktrees.trunk for this run; -m <msg> sets the squash commit
message (default: the first commit's subject, remaining subjects as the
body); --no-remove merges without tearing down.
Important
wt merge never prompts — there is nothing for --force to skip there.
Its --force only waives the one guard that exists: refusing to squash a
branch that is published — one a remote holds under its own name, like
origin/feat/login (squashing would rewrite already-published history).
A branch that merely tracks origin/main because it was created with
wt new --from origin/main is not published and merges without --force.
This is different from wt rm --force, which waives both the
safety refusals and the confirmation prompt. Note that the teardown is
the forceful kind either way — passing --force does not make it any more
destructive, and omitting it does not make it any less. Use --no-remove
if you want the worktree directory kept.
wt list [--all]A table of every worktree: branch, slug, git status (clean/dirty, or
missing for a deleted, not-yet-pruned directory), running server (if any),
isolation level, and path. --all also lists branches that have no worktree.
wt init detects the stack and writes worktree-kit.yml (see
Per-repo setup). wt doctor prints the version, primary
and worktree paths with slug and {n}, the config and runner in use, the
resolved worktrees.path template (warning if its last segment isn't
branch-unique, or if it expands to a path containing a space), which YAML
backend was auto-detected, whether docker is up (compose repos), whether
fzf is installed, whether shell integration is active, and flags stale
overlays. Run it after any config change.
Personal versions of tracked files (a tweaked database.yml, a local
development.rb) live under <repo>/.git/local/ — git can never commit
anything under .git/, and they survive branch switches — and are mounted
read-only over the container's copy in every compose run. Two things to know:
- Overlays are snapshots: re-run
wt localize <file>after editing the source;wt doctorflags stale ones. A stale overlay referencing removed code can crash boots. - Overlays apply to the compose runner only — the host runner runs against the worktree's files as-is.
Warning
wt run defaults to shared, where every worktree's suite hits the
primary's test database — concurrent runs clobber each other. The templates
export a per-worktree test DB name at --isolated (TEST_DATABASE /
TEST_DB_DATABASE / TEST_DATABASE_NAME / TEST_DATABASE_URL); wire your
test config to it and run concurrent suites with wt run --isolated.
- Jobs enqueued under
--isolatedneed a worker started with the same flag; the main stack's worker only sees its own Redis DB. {n}has 15 slots, so two worktrees can land on the same Redis DB — both print theirnat start; pin one inworktree-kit.local.ymlif they meet.- Rails ≤ 7: a bare
db:schema:loadin development also reloads the TEST database — keepSKIP_TEST_DATABASE=1indb_bootstrap(templates do).
./test/lifecycle.shruns the lifecycle suite against throwaway repos:wt new,wt switch,wt list,wt rm,wt merge,wt link,wt doctor, and theshell-initwrappers. Git only — no docker, no YAML backend needed — so it runs anywhere in seconds../test/linux.sh [debian|alpine]runs the Linux suite in containers (requires docker): install (clone symlink on Debian,curl | shremote mode on Alpine),wt initstack detection, the ruby and python3 + PyYAML YAML backends, isolation env export, the server lifecycle including busy-port detection via bind probe andss, and then the lifecycle suite above. GitHub Actions runs both distros on every push.
MIT © Nguyen Van Sang — issues and template PRs for new stacks welcome.