Recipes
Complete setups for common projects. Each one shows the run.toml it ends with (in .git/wtm/run.toml) and the commands that use it. wtm run init writes most of this from detection; the recipes show where to take it by hand, with wtm run job add|edit or an editor. Every key is described in the run.toml reference.
- A pnpm or turbo monorepo
- A docker compose app
- One postgres, a database per worktree
- Several AI agents, each in its own worktree
- Stacked pull requests
- Run a command across worktrees
A pnpm or turbo monorepo
Section titled “A pnpm or turbo monorepo”One root script (turbo run dev, pnpm -r --parallel run dev) starts every app. Declare each app as a job with its own port, then the root script as a runner that runs them:
[[job]]name = "web"kind = "service"cmd = "pnpm dev"cwd = "apps/web" [job.ports] WEB_PORT = 3000 [job.url] port = "WEB_PORT"
[[job]]name = "api"kind = "service"cmd = "pnpm dev"cwd = "apps/api" [job.ports] API_PORT = 4000 [job.url] port = "API_PORT"
[[job]]name = "dev"kind = "service"cmd = "pnpm turbo run dev"runs = ["web", "api"]
[[profile]]name = "dev"jobs = ["dev"]default = true- The runner is started with its children's ports in its environment (
WEB_PORT=3010,API_PORT=4010in the first worktree), and their named URLs are published under it.run pslists the runner with the apps it holds beneath it. - Give each app its own variable name. A runner passes one environment to every child, so two apps both reading
PORTcannot get two values. Each app reads its own:"dev": "next dev --port ${WEB_PORT:-3000}"inapps/web/package.json. - Turborepo filters the environment by default (
envMode: "strict"), so the ports never reach the apps. Let them through inturbo.json:"globalPassThroughEnv": ["WEB_PORT", "API_PORT"]. webandapistay startable on their own:wtm run start --job apistarts one app without the runner. wtm refuses to start an app its running runner already holds.
wtm run init asks, for each root script, which declared jobs it runs. By hand:
wtm run job add dev --cmd 'pnpm turbo run dev' --runs web --runs api --yeswtm run profile add dev --jobs dev --default --yesA docker compose app
Section titled “A docker compose app”A compose file whose services each worktree runs on its own. wtm sets COMPOSE_PROJECT_NAME per worktree (acme-feat-login), so containers, networks and volumes are already separate. What is left is the host ports, which must read a variable:
services: db: image: postgres:16 ports: - "${DB_PORT:-5432}:5432" redis: image: redis:7 ports: - "${REDIS_PORT:-6379}:6379"[[job]]name = "stack"kind = "service"cmd = "docker compose up -d"stop = "docker compose down" [job.ports] DB_PORT = 5432 REDIS_PORT = 6379
[[job]]name = "api"kind = "service"cmd = "pnpm dev"cwd = "apps/api" [job.ports] PORT = 4000 [job.url] port = "PORT"
[[profile]]name = "dev"jobs = ["stack", "api"]default = true
[[env_port]]file = "apps/api/.env"key = "DATABASE_URL"job = "stack"port = "DB_PORT"- With a
stopcommand,cmdis a launcher: wtm waits fordocker compose up -dto exit and runsdocker compose downonwtm run down.run psshows the stack asdetached. - The
[[env_port]]link rewrites the port insideDATABASE_URL(postgresql://app:app@localhost:5432/appbecomes…:5442/appin the first worktree) when the worktree is created, and wheneverwtm envreconciles it. wtm run initfinds literal host ports ("5432:5432") and absolute names (container_name, a volume'sname) and offers to rewrite them;--patch-composedoes it unattended. A renamed volume starts empty.- A
docker compose uptyped by hand in the worktree is isolated too, since the.envcarries the ports andCOMPOSE_PROJECT_NAME.
See How wtm run works for compose names and the port check.
One postgres, a database per worktree
Section titled “One postgres, a database per worktree”Ten worktrees do not need ten postgres containers. A shared service runs once, in the main checkout, and each worktree gets its own database in it, created on start and dropped on wtm clean:
[[job]]name = "postgres"kind = "service"cmd = "docker compose up -d postgres"stop = "docker compose stop postgres"scope = "shared" [job.ports] POSTGRES_PORT = 5432 [job.namespace] name = "app_{worktree}" create = "scripts/db-worktree-add.sh" remove = "scripts/db-worktree-drop.sh" [job.namespace.env] PGPASSWORD = "postgres"
[[job]]name = "migrate"kind = "task"cmd = "pnpm db:migrate"cwd = "apps/api"touches = ["postgres"]
[[job]]name = "api"kind = "service"cmd = "pnpm dev"cwd = "apps/api" [job.ports] PORT = 4000 [job.url] port = "PORT"
[[profile]]name = "dev"jobs = ["postgres", "migrate", "api"]default = true
[[env]]file = "apps/api/.env"key = "DATABASE_URL"job = "postgres"value = "postgresql://postgres:postgres@localhost:{port.POSTGRES_PORT}/{namespace}"The two commands are yours; wtm runs them with $WTM_NAMESPACE (app_feat-login), the job's ports and the namespace.env variables. create runs on every start, so it must do nothing when the database exists:
#!/bin/shset -epsql="psql -h localhost -p $POSTGRES_PORT -U postgres -v ON_ERROR_STOP=1"exists=$($psql -tAc "SELECT 1 FROM pg_database WHERE datname = '$WTM_NAMESPACE'")[ "$exists" = 1 ] && exit 0$psql -c "CREATE DATABASE \"$WTM_NAMESPACE\" TEMPLATE app"#!/bin/shset -epsql -h localhost -p "$POSTGRES_PORT" -U postgres -v ON_ERROR_STOP=1 \ -c "DROP DATABASE IF EXISTS \"$WTM_NAMESPACE\" WITH (FORCE)"TEMPLATE appstarts each worktree from a copy of main's data (app, the database main's.envnames), so there is nothing to seed. It refuses while main has open connections; drop theTEMPLATEclause for an empty database.- The
[[env]]link writes the wholeDATABASE_URL, pointing each worktree at its own database.migratedeclarestouches = ["postgres"]; since every worktree has its own namespace, it runs without a question. run psshows the worktrees holding the service asjoined; it stops once none holds it.wtm clean feat/logindrops the database after removing the worktree;--keep-datakeeps it. When postgres is down,--yesdefers the drop to its next start and--drop-datastarts it to drop now.
The same fields exist as flags: wtm run job add postgres --scope shared --namespace-name 'app_{worktree}' --namespace-create … --namespace-remove … --namespace-env PGPASSWORD=postgres. See Shared services.
Several AI agents, each in its own worktree
Section titled “Several AI agents, each in its own worktree”Give each agent a branch, a directory and a running stack of its own. Every command below prompts for nothing, and --output json gives it a document to read instead of text:
wtm agents install # once: the using-wtm skill for Claude Code / Cursor
branch=agent/fix-checkoutwtm create "$branch" --if-not-exists --yes --output json # {"results": [{"branch", "path", "isolation", ...}], "failed": []}cd "$(wtm resolve "$branch")"
wtm run up "$branch" -d --yes --output json # per-job status, ports and URLsapi=$(wtm run url "$branch" --job api) # http://api.agent-fix-checkout.acme.localhost:11080curl -s "$api/health"
wtm run logs "$branch" --output json # the last 1000 lines of each jobwtm run down "$branch" --yeswtm clean "$branch" --yes # add --force once the work is pushed elsewhererun up --yesleaves the other worktrees' jobs running, so agents starting at the same time do not stop each other. Settingconcurrency = "parallel"inrun.tomlmakes that the answer for people too.- Under
--yesa missing choice is an error naming its flag, never a picker:run startneeds--job,createneeds the branch. - Exit codes are stable:
10the worktree already exists,11the branch does not exist,12the repository was never initialized with wtm,14a job or profilerun.tomldoes not declare,16norun.toml,18awtm env --checkthat found drift,19an interactive run you backed out of (sowtm create x && wtm go xstops there),20awtm eventsthat received an event of a newer schema,21not in a git repository,2a usage error. Which of themwtm eventstreats as final is in The event stream. wtm run ps --output jsonlists everything running, across repositories, andwtm list --output jsonevery worktree with its state.clean --yesstill refuses a worktree with uncommitted or unpushed work; that refusal is lifted only by--force.
wtm agents install adds a skill to .claude/ or .cursor/ that teaches the agent these commands. Re-run it after upgrading wtm.
Stacked pull requests
Section titled “Stacked pull requests”Each branch builds on the previous one, and every worktree records its parent:
wtm create feat/api --yeswtm create feat/api-client --from feat/api --yeswtm create feat/checkout-ui --from feat/api-client --yes$ wtm tree
main └─ feat/api └─ feat/api-client └─ feat/checkout-uiWhen main moves, or you amend feat/api, rebase the chain in order, parents first:
wtm sync --all --dry-run # the plan, nothing changedwtm sync feat/api feat/api-client feat/checkout-uiwtm sync --all --yes --push # unattended, then force-push with leaseOn a conflict, sync aborts that branch's rebase and skips its descendants; --keep-conflict leaves the rebase in progress to resolve by hand. --yes never pushes unless --push is given.
When feat/api is merged (squash or rebase merges included), move its child onto main and remove it:
wtm reparent feat/api-client --to main --yeswtm sync feat/api-client feat/checkout-ui --yes --pushwtm clean feat/api --yesOr in one pass once several PRs are merged, with gh installed: wtm prune --merged --reparent-children --yes. wtm tree --output mermaid prints the stack as a flowchart for a PR description.
prune only reads the branches that have a worktree: it asks GitHub for their pull requests in one query, however old the pull request, and its fetch refreshes only their remote-tracking refs, so a repository with thousands of branches costs it no more than one with ten. The remote-tracking refs of the other branches are left as they are: git fetch --prune refreshes them.
Run a command across worktrees
Section titled “Run a command across worktrees”Several branches in flight, one lockfile bump or one test suite to run on all of them. Create them in one go, run the command everywhere in parallel, remove them in one go:
$ wtm create feat/login feat/billing fix/header --yes$ wtm exec --all -- 'pnpm lint && pnpm test'
✓ main (41.2s) ✗ feat/billing (exit 1, 38.7s) ✓ feat/login (40.1s) ✓ fix/header (39.5s)
✗ pnpm lint && pnpm test · 4 worktrees: 1 failed ✗ feat/billing (exit 1, 38.7s) FAIL src/invoice.test.ts > rounds the total log /code/acme/.git/wtm/exec/feat%2Fbilling.log ✓ 3 passed
$ wtm clean feat/login fix/header --yes- Everything after
--is one/bin/sh -cline, run from each worktree's root: quote it when it holds&&or a pipe, or your own shell takes them. - Each command gets its own worktree's environment: the variables your shell carries about the worktree you stand in are removed, and the target's run variables (compose project, shifted ports) are added when it has them, as for its hooks. See The environment of
wtm exec. - Name worktrees (
wtm exec feat/login feat/billing -- pnpm test), or--allfor every one, the main checkout included. Without either,wtm execopens a wizard that also asks for the command. --jobs Ncaps how many run at once (one per CPU by default):wtm exec --all --jobs 2 -- pnpm install. stdin is closed, so an interactive command cannot wait for input.- Successes show one line; failures show the tail of their output.
--printshows every worktree's full output:wtm exec --all --yes --print -- git log -1 --oneline. Each worktree's whole output is kept in.git/wtm/exec/<branch>.log. - The run exits
1when any command failed; each worktree's own exit code is in the report, and in--output json({command, results: [{branch, path, status, exit_code, …}], failed: [branch…]}) for a script or an agent.
create and clean with several branches go one after the other, and a failure does not stop the others: the run ends with what succeeded and what failed, and exits with the first failure's code. Under --yes, clean refuses the whole batch before removing anything when one worktree is unsafe (dirty, unpushed, open PR, locked): pass --force, or name only the safe ones. Their --output json is an envelope, {"results": [...], "failed": [...]} (see Migrating to 0.29).