npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@schemelisp/wtb

v1.2.0

Published

Git worktree management with Docker Compose environment isolation

Downloads

37

Readme

wtb

Switch between multiple branch environments in an instant.

A CLI tool built on Git worktrees that gives every branch its own isolated working directory — with automatic .env copying, port remapping, Docker Compose isolation, Docker volume cloning so each branch starts with the same data your main worktree has, and symlinks for heavy directories like node_modules.

npm version license node

日本語 / Japanese README


Table of contents

Why wtb?

Git worktrees are powerful but awkward on their own: every new working directory needs its gitignored files copied, dependencies installed, ports remapped, and long-lived services restarted. wtb automates that glue so each branch feels like a self-contained mini-environment.

Typical use cases:

  • You're in the middle of a feature branch and an urgent hotfix lands — spin up a second working directory in seconds.
  • You want several feature branches building, testing, or serving in parallel without port collisions.
  • You need a clean checkout to review a PR without stashing, resetting, or killing your running dev server.
  • You'd like .env, local configs, or credentials automatically copied (and adjusted) to each new worktree.
  • You run Docker Compose and need each branch's services on their own ports.

Philosophy & scope

wtb is built for a particular way of working: running many changes — including ones that touch the database and backend — in true parallel, one isolated worktree per change.

  • Parallelism is the speedup. In vibe-coding workflows, doing DB- and backend-touching changes in full parallel (a worktree per change) is where the time savings come from.
  • Every worktree is fully autonomous on code. Each worktree can change and run code on its own, completely independent of the others.
  • Every worktree is fully autonomous on data. Each worktree starts from a complete copy of the DB state, so it can write migrations and mutate data freely without affecting any other worktree.
  • Conflicts are expected — and that's fine. Working this way, conflicts are the norm; the best code emerges from the collision of competing requirements. wtb deliberately does not try to resolve them for you.
  • Docker Compose only, for now. wtb currently supports Docker Compose plus its YAML and env files only. Other stacks are out of scope at this stage.
  • No coding-agent orchestration (yet). wtb does not orchestrate coding agents. A coding agent launched inside a worktree should treat its job as done once it finishes the task; if more work is needed, a human is expected to go in and pick it up. In practice the recommended pattern is to let the agent run all the way to opening a pull request.
  • The author is partial to the V6 hybrid power units used in F1.

How it works

project/                        ← main worktree (your original repo)
├── wtb.yaml
├── .env                        APP_PORT=3000
├── docker-compose.yml          3000:80
├── node_modules/
└── src/

worktree-feature-auth/          ← created by `wtb create feature/auth`
├── .env                        APP_PORT=3001   (auto-bumped, collision-free)
├── docker-compose.yml          3001:80         (auto-bumped)
├── node_modules -> ../project/node_modules     (symlinked, not copied)
└── src/                        (git worktree — shares the same .git)

When you run wtb create <branch>, the tool walks these phases in order:

  1. Worktree — git worktree add at ../worktree-<sanitized-branch>/ relative to the main worktree (or a custom -p <path>), branching from base_branch unless the branch already exists (a branch that exists only on origin becomes a local tracking branch from origin/<branch>).
  2. Copy files — copy_files (gitignored configs, secrets, etc.) are copied from the main worktree. Paths also listed in link_files are skipped here.
  3. Symlink — link_files entries are symlinked back to the main worktree (existing files/dirs/symlinks are replaced transactionally).
  4. Environment files — env.file entries are copied; if env.adjust is non-empty, port-style values are bumped to a free port reserved across Docker plus every sibling worktree's env and Compose files.
  5. Docker Compose — if docker_compose_file is configured, wtb starts with the file checked out by the target branch; only when that file does not exist does it fall back to the main-worktree copy. It then remaps host ports and atomically writes the adjusted result.
  6. Volume clone — every named (non-external) Docker volume declared in the Compose file is cloned to the new worktree's project, so e.g. PostgreSQL data carries over without re-seeding. If the source stack is running (the usual case for a live dev DB), wtb automatically stops it, clones, and restarts it so the copy is corruption-safe — no manual step. Pass --no-stop to skip in-use volumes instead, or --force-volume-copy to clone live. See Volume cloning.
  7. Start command — start_command, if configured, runs inside the new worktree with /bin/sh.

wtb remove <branch> runs in reverse: docker compose down (or down -v with --remove-volumes, unless end_command is set), then end_command, then git worktree remove.

Repository commands resolve the main worktree, current worktree, and shared Git directory as one context. Running create, remove, init, init-claude, prune, status, ports, or doctor from a linked worktree therefore uses the same main config, copy source, default destination, and repository identity. Concurrent create operations are serialized through a repository lock while the worktree and its files/ports are allocated.

Quick start

1. Install

npm install -g @schemelisp/wtb
# or one-shot
npx @schemelisp/wtb create feature/awesome

2. Scaffold a config in your repo root

wtb init          # writes a commented wtb.yaml (base_branch detected from origin/HEAD)

Then edit the generated file. A minimal config looks like:

# wtb.yaml
base_branch: main

copy_files:
  - .env
  - .env.local

link_files:
  - node_modules

env:
  file:
    - .env
  adjust:
    APP_PORT: 1       # auto-bump to the next free port
    DB_PORT: 1

3. Use it

wtb create feature/awesome
cd ../worktree-feature-awesome
# ...hack...
wtb remove feature/awesome

Preview without touching anything:

wtb create feature/awesome --dry-run

Commands

wtb init

Scaffolds a commented wtb.yaml at the repository root — the fastest way to get started. base_branch is detected from origin/HEAD (falls back to main when no remote default branch is set).

| Option | Description | |--------|-------------| | -f, --force | Overwrite an existing config file |

Fails with exit 1 if a config file already exists (use --force to overwrite it), and exit 3 outside a git repository.

wtb create <branch>

Creates a new worktree for <branch>. Branch resolution, in order:

  1. Local branch exists — it's used as-is.
  2. Branch exists only on origin — wtb creates a local tracking branch (git worktree add -b <branch> --track origin/<branch>) instead of silently shadowing it with a new branch off base_branch (message: ℹ️ Branch <branch> exists on origin — creating local tracking branch from origin/<branch>).
  3. Brand-new branch — created from base_branch. wtb first verifies that base_branch resolves (git rev-parse --verify <base>^{commit}, so tags/SHAs/remote refs are valid bases too) and fails with exit 1 and a hint to set base_branch in wtb.yaml if it doesn't (e.g. repos whose default branch is master).

If the branch's worktree already exists, create fails with exit 6 — pass --exists-ok to print its path and exit 0 instead (idempotent "ensure this worktree exists").

Pipeline (short version): worktree → copy → symlink → env → compose → start.

Default path: ../worktree-<branch-with-"/"-replaced-by-"-">. Use -p to override.

| Option | Description | |--------|-------------| | -p, --path <path> | Custom worktree location | | --no-create-branch | Use an existing branch (fails if it doesn't exist) | | --no-docker | Skip Docker Compose copy/port-remap — also skips volume cloning (the volume phase requires Docker), so the worktree starts with empty volumes | | --no-env | Skip env.file copy + env.adjust | | --no-copy | Skip copy_files | | --no-link | Skip link_files symlinks | | --no-start | Skip start_command | | --no-volume-copy | Skip cloning Docker volumes from the source project | | --force-volume-copy | Clone volumes even when the source container is running or the target volume already has data | | --no-stop | Don't auto-stop the source Compose stack before cloning; skip in-use volumes instead (the old behavior) | | --seed | Seed the data instead of cloning: skip the volume-clone phase and run volumes.seed_command in the new worktree. Never touches the source volume, so the source stack is left running. Requires volumes.seed_command in the config; mutually exclusive with --force-volume-copy. See Volume cloning | | --strict | Exit non-zero (1) if copy/link/env/Compose/start setup, a volume clone, or the seed command fails. Without it the worktree is kept and the command exits 0, but no success banner is printed and JSON has ok: false | | --exists-ok | If a worktree for the branch already exists, print its path and exit 0 instead of failing with exit 6 | | --json | Write exactly one machine-readable JSON object to stdout; human progress moves to stderr. See below | | --dry-run | Print the plan, make no changes |

Examples:

wtb create feature/quick-fix --no-docker        # skip Docker even if configured
wtb create feature/wip --no-start               # skip install/setup
wtb create release/v2 --no-create-branch        # attach to an existing branch
wtb create feature/minimal \
  --no-docker --no-env --no-copy --no-link --no-start  # bare git worktree only
wtb create feature/test --dry-run               # preview
wtb create feature/auth -p /tmp/auth-wt         # custom path

In human mode, create echoes every adjustment it applied — per-key env bumps (APP_PORT: 3000 → 3001) under the env phase, per-service Compose remaps (web: 3000 → 3001) under the Compose phase — and the final Next steps block suggests wtb ports --pretty to review the worktree's assigned ports.

JSON output (--json): exactly one pretty-printed object on stdout; all human progress goes to stderr.

{
  "branch": "feature/auth",
  "path": "/Users/me/worktree-feature-auth",
  "created": true,
  "existing": false,
  "createdBranch": true,
  "dryRun": false,
  "env": { "APP_PORT": { "from": "3000", "to": "3001" } },
  "composePorts": { "web": [{ "from": 3000, "to": 3001 }] },
  "composeIdentity": { "projectName": { "from": "app", "to": "app-feature-auth" }, "containerNames": [] },
  "composeValueChanges": [],
  "volumes": {
    "cloned": ["db_data"], "skipped": [], "failed": [],
    "sourceStack": { "stopped": true, "restarted": true }
  },
  "sourceRestartFailed": false,
  "seed": null,
  "startCommand": { "ran": true, "failed": false },
  "composeFailed": false,
  "setupWarnings": [],
  "setupFailures": [],
  "ok": true
}
  • volumes.skipped entries are { name, reason }; volumes.failed entries are { name, error }. seed is { ran, failed } when --seed was used, else null; same shape for startCommand when start_command is configured. ran:false also records a lifecycle command suppressed because an earlier setup/data-isolation failure made it unsafe.
  • volumes.sourceStack is { stopped, restarted, stopError?, restartError?, recoverCommand? } — present when wtb attempted to stop the source stack to clone. A failed/timed-out stop may be partial, so wtb always attempts recovery; stopped: false plus stopError records that case and the affected volumes are failures. If recovery also fails (restarted: false), top-level sourceRestartFailed is true, restartError/recoverCommand explain how to bring the source stack back up, and the command exits 5 (Docker error) regardless of --strict.
  • setupWarnings and setupFailures contain { phase, path?, message } entries for copy, link, env, compose, and start. A missing optional copy_files/link_files source is a warning and is skipped; an actual I/O or setup error is a failure.
  • composeFailed is true if the per-worktree compose rewrite threw (the worktree's compose is not isolated).
  • ok is false when setup/start, a volume clone, the seed command, or the compose rewrite failed, or the source stack failed to restart. --strict --json still writes the JSON first and then exits 1; a source-stack restart failure remains Docker exit 5.
  • With --exists-ok on an already-existing worktree the object is reduced to { branch, path, created: false, existing: true, createdBranch: false, dryRun, setupWarnings: [], setupFailures: [], ok: true }.

wtb remove <branch>

Removes the worktree that owns <branch>. Guards against removing the main repository.

| Option | Description | |--------|-------------| | -f, --force | Skip the dirty-worktree pre-flight check and continue removal after cleanup failure. Uncommitted changes are lost; a cleanup failure still leaves ok: false and exits non-zero | | --no-docker | Skip docker compose down in the worktree | | --no-end | Skip end_command | | --remove-volumes | Also delete this worktree's Docker volumes (docker compose down -v). Every existing non-external volume must have an exact wtb.repo/project/branch owner match; unmanaged, foreign, shared fixed-name, temporary, or uninspectable volumes make the command fail closed. Has no effect (wtb warns) when teardown is intentionally skipped | | --json | Write one machine-readable JSON object to stdout; human progress moves to stderr. See below |

Without -f, a worktree with uncommitted or untracked changes fails fast with exit 1 (Worktree for '<branch>' has uncommitted or untracked changes; commit/stash them or pass -f to force removal) — before any Docker teardown or volume deletion, so a doomed removal can't tear down services or drop volumes first. When wtb must internally force-remove its own managed rewrites, it repeats the SHA/mode/status check immediately before removal; an edit created while Docker or end_command was running is therefore preserved. Unknown skip-worktree entries and any assume-unchanged index flag are also fail-closed, including with --force.

Ordering: Docker teardown → end_command → git worktree remove. If end_command is set, wtb assumes you own teardown and intentionally skips the automatic docker compose down. The automatic teardown passes your configured Compose file and the worktree's resolved Compose project explicitly (docker compose -f <docker_compose_file> -p <worktree-project> down [-v]), so non-default filenames like compose.dev.yml are torn down correctly.

Cleanup must finish before the worktree is removed. A failed docker compose down, failed end_command, missing/unreadable target Compose file, or target project that cannot be safely distinguished from the source keeps the worktree and exits non-zero. Pass --force only when deleting the worktree despite that partial cleanup is intentional: wtb continues removal, but preserves the failure signal (ok: false; Docker teardown failure exits 5, end/other cleanup failure exits 1). Explicit --no-docker and --no-end skips are successful, intentional outcomes.

wtb remove feature/old --no-docker          # Docker daemon already stopped
wtb remove feature/abandoned -f --no-end    # force-remove, skip cleanup

JSON output (--json): always exactly one object on stdout, including hard errors such as an unknown branch, the main/locked-worktree guard, invalid managed manifest, or dirty worktree. Human messages stay on stderr.

{
  "branch": "feature/old",
  "path": "/Users/me/worktree-feature-old",
  "removed": true,
  "forced": false,
  "composeDown": { "ran": true, "failed": false, "volumesRemoved": false, "skippedReason": null },
  "endCommand": null,
  "cleanupErrors": [],
  "ok": true
}
  • composeDown is null when docker_compose_file isn't configured. Otherwise skippedReason says why teardown didn't run: "no-docker-flag" and "end-command" are successful intentional skips; "same-project", "unresolvable-project", "compose-file-missing", and "volume-ownership" set failed: true and normally keep the worktree.
  • endCommand is null when end_command isn't configured, else { ran, failed } (ran: false under --no-end).
  • cleanupErrors is the full list of safety/cleanup failures. Normally a non-empty list means removed: false, ok: false; with --force, it means removed: true, ok: false and the process still exits non-zero.

wtb reclone [branch]

Re-runs only the volume-clone phase for an existing worktree — useful when a clone previously failed or was skipped (empty/stale volumes) and you want to recover the data without removing and recreating the worktree (so uncommitted work is safe). Defaults to the current worktree; pass a branch to target another.

| Option | Description | |--------|-------------| | --force-volume-copy | Clone even when the source container is running or the target already has data (overwrite is atomic) | | --no-stop | Don't auto-stop the source Compose stack; skip in-use volumes instead | | --strict | Exit non-zero (1) if any volume fails to clone (default: exit 0). For CI / coding-agent pipelines that must detect incomplete data isolation | | --json | Write one machine-readable JSON object to stdout ({ branch, path, dryRun, volumes: { cloned, skipped, failed, sourceStack? }, sourceRestartFailed, ok } — same per-volume shape as create --json, including volumes.sourceStack when the source stack was stopped to clone); human progress moves to stderr | | --dry-run | Print which volumes would be cloned; make no changes |

wtb reclone                       # current worktree
wtb reclone feature/auth          # a specific worktree
wtb reclone feature/auth --force-volume-copy   # overwrite stale target data

Prints the same N cloned, N skipped, N failed summary as create; a failure exits 0 with the loud ⚠️ … data is NOT fully isolated line (resolve and re-run). With --json, ok: false flags the failure, and --strict still exits 1 after the JSON is written. Same hard-failure contract as create, too: if wtb stopped the source stack to clone and it fails to restart, reclone exits 5 (Docker error) regardless of --strict — top-level sourceRestartFailed: true and volumes.sourceStack carry the details, and the recovery command is printed. Refuses to target the main repository worktree (source and target would be the same project). If docker_compose_file isn't configured, it's a no-op with a message. To re-seed instead of re-clone, run your volumes.seed_command inside the worktree.

wtb up [branch] / wtb down [branch]

Bring an existing worktree's Compose stack up (docker compose up -d) or down (docker compose down) later — no worktree creation or removal involved. Defaults to the worktree containing your current directory; pass a branch to target another. Both always invoke Compose with the worktree's own file and project explicitly. A shell-only COMPOSE_PROJECT_NAME is rejected; define a stable worktree-specific value in .env instead.

| Option | Description | |--------|-------------| | --json | Write one machine-readable JSON object to stdout; human progress moves to stderr | | --remove-volumes | (down only) Also delete this worktree's Docker volumes (docker compose down -v) |

Guards — these all exit 1 before Docker is ever invoked:

  • Worktrees only. Targeting the main repository worktree is refused (run docker compose up/down directly in the source repo instead).
  • The compose file must exist inside the worktree — there is no fallback to the source repo's copy (that would up the source's literal ports under the worktree's project).
  • A shell COMPOSE_PROJECT_NAME is refused. If a stable .env/name: still makes the worktree resolve to the same Compose project as the source or a sibling, wtb also hard-refuses.
  • An unresolvable target project (compose unreadable) is refused; an unknown branch prints Available worktrees: to stderr.

Exit codes: 4 when no docker_compose_file is configured, 5 when Docker cannot inspect project ownership or docker compose up/down fails, 1 for configuration/ownership-collision guards.

JSON output (--json):

{
  "branch": "feature/auth",
  "path": "/Users/me/worktree-feature-auth",
  "composeFile": "/Users/me/worktree-feature-auth/docker-compose.yml",
  "project": "app-feature-auth",
  "action": "up",
  "ok": true
}

down adds "volumesRemoved": true|false. The project field is your handle for any further Compose operation against that worktree's stack — docker compose -p <project> logs -f, docker compose -p <project> exec db psql, etc.

Together with reclone, this gives you a full data reset without touching the worktree or your uncommitted work:

wtb down feature/auth --remove-volumes && wtb reclone feature/auth

wtb prune

Removes wtb-managed Docker volumes that are orphaned — i.e., volumes wtb cloned for worktrees that no longer exist (because wtb remove leaves volumes by default) — plus unprotected leftover temp volumes from interrupted overwrites. New volumes carry wtb.managed=true, wtb.repo=<repo-hash>, wtb.project=<compose-project>, and wtb.branch=<branch> labels; staging volumes additionally carry wtb.temp=true. prune filters by the repository label and conservatively keeps a new-format volume when either its project or branch still belongs to a live worktree (so a project/config or branch rename cannot immediately destroy data); only a volume whose two owner labels are both no longer live is orphaned. Older repo-labelled volumes without the new owner labels use the legacy project-name prefix check only. Volumes without the repository label never become candidates and must be cleaned manually.

An interrupted destructive overwrite can leave a recovery record under the repository's shared Git directory and a temp volume that may contain the only verified copy. Such temp volumes are protected, not ordinary leftovers. They are listed separately and are never removed by normal prune --yes.

| Option | Description | |--------|-------------| | -y, --yes | Actually remove the volumes. Without this, prune is a dry run (lists candidates only) | | --discard-recovery | Also delete recovery-protected temp volumes and their records. Requires --yes; use only after deciding recovery is no longer needed | | --json | Machine-readable output: { dryRun, candidates, protected, removed, failed } — protected is the recovery temp-volume name list; each candidate is { name, reason, inUse, inUseBy } |

wtb prune            # preview what would be removed (safe; deletes nothing)
wtb prune --yes      # remove the orphaned + leftover temp volumes
wtb prune --json     # machine-readable preview for scripts/agents
wtb prune --yes --discard-recovery  # explicitly abandon protected recovery data

Safety: it's dry-run by default (deletion needs --yes); a volume currently in use by a container is skipped; recovery data needs the two-key confirmation above; and ownership labels are revalidated immediately before deletion. Destructive prune holds the same repository lock as create, so a branch cannot be recreated between the liveness snapshot and deletion. prune uses the main config's exact docker_compose_file path for every worktree. If config loading, Compose parsing/project resolution (including each worktree's .env), recovery-record reading, Docker inspection, or worktree enumeration fails, it fails closed before deleting anything.

With --yes, any volume-removal failure exits 5 (Docker error — treat it as a partial prune): without --json the error is Error: Failed to remove N volume(s): <names> on stderr; with --json the full JSON payload is still written to stdout (the failures listed in failed) and the exit code is set afterwards, so the JSON stays intact.

wtb ls (alias: list)

Lightweight, scriptable listing of worktrees — like Unix ls. Use this instead of status when you just want to see what worktrees exist, without the Docker noise.

| Option | Description | |--------|-------------| | -l, --long | Long format: short hash, relative age, dirty flag, subject | | --json | Machine-readable JSON (combines with -l for enriched fields) | | -p, --paths | Absolute paths only, one per line — pipe-friendly |

Flags are prioritized, not combined: -p overrides --json and -l (paths-only output wins).

Default (compact, 1 git call):

→ main            /Users/me/proj                          [main]
  feature/api     /Users/me/proj-worktrees/feature-api
  feature/ui      /Users/me/proj-worktrees/feature-ui     [locked]
  hotfix/crash    /Users/me/proj-worktrees/hotfix-crash   [prunable]
  (detached)      /Users/me/proj-worktrees/detached-xyz

Long (-l, extra git log/git status per worktree in parallel):

  BRANCH          COMMIT   AGE        D  PATH                                   TAGS / SUBJECT
→ main            a1b2c3d  2h ago     *  /Users/me/proj                         [main] Add foo
  feature/api     9f8e7d6  3d ago        /Users/me/proj-worktrees/feature-api   WIP refactor

Legend:

  • → in column 0 marks the worktree that contains your current working directory (works even in detached HEAD).
  • Tags: [main] (main repository worktree), [locked] (git worktree lock), [prunable] (worktree directory gone), [bare] (bare repository).
  • D column: * means the worktree has uncommitted changes.

Paths-only for shell pipelines:

cd "$(wtb ls -p | fzf)"                       # fuzzy-jump between worktrees
wtb ls -p | xargs -I{} du -sh {}              # disk usage per worktree

When you already know the branch, skip the interactive picker — wtb path resolves it deterministically: cd "$(wtb path feature/x)".

JSON:

wtb ls --json | jq '.[] | select(.isMain == false) | .path'
wtb ls -l --json | jq '.[] | select(.dirty == true)'

JSON fields (always): path, branch, head, isMain, isCurrent, locked, prunable, bare, detached. With -l: adds shortHash, subject, ageRelative, ageTimestamp, dirty — plus enrichmentError if per-worktree enrichment failed (e.g., prunable).

wtb path <branch>

Prints the absolute path of the worktree that owns <branch> — one newline-terminated line, nothing else on stdout. The deterministic primitive for shell pipelines and coding agents:

cd "$(wtb path feature/x)"                    # jump straight to a worktree
wtcd() { cd "$(wtb path "$1")"; }             # handy shell function

If no worktree matches, it prints an Available worktrees: listing to stderr and exits 1 (stdout stays clean for pipelines). Exits 3 outside a git repository.

wtb ports [branch]

Prints the adjusted env.adjust values, Docker Compose host/container ports, and a pre-rendered http://localhost:<port> endpoint list — for the current worktree, a specific branch's worktree, or all worktrees.

| Option | Description | |--------|-------------| | -a, --all | Output an array of every worktree's ports (default: current worktree as an object). Cannot be combined with a branch argument (exit 2) | | --pretty | Human-readable table instead of JSON | | --json | Accepted as a no-op for consistency — JSON is already the default output. Conflicts with --pretty (exit 2) |

wtb ports                  # current worktree
wtb ports feature/x        # a specific worktree by branch (no cd needed)
wtb ports -a               # every worktree, as a JSON array

An unknown branch prints an Available worktrees: listing to stderr and exits 1.

wtb ports resolves ${VAR} / ${VAR:-default} references in Compose port mappings statically — against the worktree's env files, with no Docker and no running stack. A mapping like '${KONG_HTTP_PORT:-54321}:8000' (previously yielding an empty endpoint) now resolves to a concrete host port. Precedence: worktree env-file value > Compose default > unresolved. An unresolved variable is skipped (no endpoint) with a stderr warning naming it. Known limits: nested defaults (${A:-${B}}), port ranges, and IPv6 are not resolved.

Designed to be called from Claude Code (via the shipped skill) or from shell scripts. See Claude Code integration for the full output schema.

wtb status

Richer inspection: worktrees + Docker Compose services + running containers + volumes. Slower than ls because it shells out to Docker.

| Option | Description | |--------|-------------| | -a, --all | Show all worktrees (default: current branch only) | | --docker-only | Suppress worktree section, show only Docker info | | --json | Machine-readable JSON (worktrees + Docker state) on stdout — for scripts/agents |

📁 Git Worktrees Status

→ main (main)
   📂 /Users/me/project
   🐳 Docker: docker-compose.yml
   📦 Services: 3
   🔧 Environment: .env, .env.local

--json returns one structured object ({ worktrees: [...], docker: {...} }) and stays valid JSON even when Docker is down (docker.available: false). Each docker.volumes.wtb entry carries a labelled boolean: true means the volume has the wtb.managed=true label (the source of truth); false means it was matched only by the legacy wtb/worktree name heuristic — check labelled before treating a volume as wtb-managed. Together with wtb ls --json, wtb ports (JSON by default), and --json on create / remove / reclone / up / down, every wtb read and mutation now has a machine-readable form.

wtb status --json | jq '.docker.containers[] | select(.isWtb) | .name'   # this project's containers
wtb status -a --json | jq '.worktrees[] | {branch, services: .compose.services}'

wtb doctor

Static preflight — no Docker needed. Inspects the repo's Compose and env files for worktree-relocatability problems before you create a worktree: a fixed Compose project name or container_name: that would collide across worktrees, a literally-published port that won't follow an env.adjust bump, an unresolved ${VAR} in a port mapping, an env value that embeds an adjusted port, and a COMPOSE_PROJECT_NAME in your shell that would defeat per-worktree isolation.

| Option | Description | |--------|-------------| | --json | Print exactly one machine-readable JSON object to stdout | | --strict | Exit 1 if any warning-or-error finding exists (default: exit 0 even with warnings) |

wtb doctor                 # human-readable report
wtb doctor --json | jq .   # machine-readable
wtb doctor --strict        # gate a CI step on a clean report

Each finding is { id, severity, message, suggestion?, service?, variable? } (service on per-service port findings, variable on per-variable env findings), where severity is info, warning, or error. Finding ids: fixed-project-name, container-name, literal-env-port, literal-compose-port, unsupported-compose-port (a range or other form that cannot be assigned safely), unresolved-port-variable, compose-project-name-env, compose-file-env (COMPOSE_FILE in your shell), compose-override-file (a docker-compose.override.yml sibling wtb doesn't rewrite), fixed-volume-name / fixed-network-name (a non-external name: that would be shared across worktrees), no-compose-file. A finding is downgraded to info when the relevant auto-handling actually applies. For the fixed-project-name check that means identity rewrite (compose.isolate_name), and for the container-name check container-name rewrite (compose.container_name ≠ keep) — both only when docker_compose_file is set, since without it wtb never rewrites the compose file. The env-embed check (literal-env-port) is gated only on port propagation (env.port_propagation) being enabled — env files are rewritten regardless of whether a compose file is configured. Literal HOST:CONTAINER compose ports always stay a warning (propagation only rewrites ${VAR:-default}-form host ports).

Exit codes: the default exits 0 even when warnings exist (agent/CI friendly — the JSON ok and summary carry the result, so a wrapper can decide what to do). Pass --strict to exit 1 when any warning or error is present. --json prints exactly one JSON object to stdout ({ composeFile, findings, summary: { info, warning, error }, ok }).

The same checks also run automatically during wtb create (including --dry-run): warning/error findings are printed to stderr as a preflight (followed by Run 'wtb doctor' for details.), but the preflight never changes create's exit code.

wtb init-claude

Installs the bundled Claude Code skill into this repo (or globally). See Claude Code integration for what the skill does.

| Option | Description | |--------|-------------| | -f, --force | Overwrite an existing SKILL.md | | --user | Install at ~/.claude/skills/wtb/ instead of the repo | | --dry-run | Print the target path; don't write | | --check | Verify the installed SKILL.md against this CLI's version — exit 0 when up to date, 1 when it's missing, unstamped, or stale. Respects --user; writes nothing |

The installer stamps SKILL.md with <!-- wtb-skill-version: X.Y.Z --> right after the frontmatter. --check (and the skip message when the file already exists without --force) compares that stamp to the CLI version, so a stale skill is machine-detectable after upgrading wtb — refresh it with wtb init-claude --force.

Configuration

wtb searches for a config file in this order and stops at the first match:

  1. wtb.yaml
  2. wtb.yml
  3. .wtb.yaml
  4. .wtb.yml
  5. .wtb/config.yaml
  6. .wtb/config.yml

If nothing is found, wtb still runs with defaults — it prints a warning to stderr spelling out what those defaults mean (base_branch: main, copy ./.env unchanged, no port remapping) and suggests running wtb init to scaffold a config. The config is merged with defaults — any field you omit gets the default.

All configured file paths (docker_compose_file, copy_files, link_files, env.file, and env.port_propagation.files) are relative to the main repository root, including when the config itself lives under .wtb/. Absolute paths, .., ., .git descendants, normalized duplicates, nested link targets, and a link_files entry that is an ancestor of another write target are configuration errors (exit 4). wtb repeats containment and symlink-ancestor checks immediately before I/O.

Migration: move any external file into a repository-relative local path (normally gitignored), then reference that path. For example, replace /home/me/project-local/.env or ../shared/.env with local/.env after placing the file at <main-worktree>/local/.env.

Reference

| Field | Type | Default | Description | |-------|------|---------|-------------| | base_branch | string | "main" | Base branch used when creating a brand-new branch | | docker_compose_file | string | "" | Repository-relative path to the Compose file. Empty/omitted → Docker skipped entirely | | copy_files | string[] | [] | Files/dirs to copy to new worktrees (even if gitignored). Directories are copied recursively | | link_files | string[] | [] | Files/dirs to symlink into the new worktree. Takes priority over copy_files on duplicates | | start_command | string | — | Runs in the new worktree via /bin/sh after creation. Relative scripts are resolved against the worktree root | | end_command | string | — | Runs in the worktree before removal. Setting this suppresses the automatic docker compose down | | env.file | string[] | ["./.env"] | Env files to copy into the worktree | | env.adjust | map | {} | Per-key adjustment (see Environment variable adjustment) | | env.port_propagation | bool / object | true | Propagate a bumped port into other env values + the compose copy. Boolean shorthand, or { enabled, files, compose }. See Port propagation | | compose.isolate_name | bool | true | Rewrite the worktree's top-level Compose name: to <original>-<branch-slug>. See Docker Compose integration | | compose.container_name | enum | "suffix" | How to handle services' container_name:: suffix / strip / keep. See Docker Compose integration | | volumes.exclude | string[] | [] | Compose volume keys to exclude from auto-cloning. Default: every named non-external volume in the Compose file is cloned. See Volume cloning | | volumes.seed_command | string | — | Command run in the new worktree (via /bin/sh) when wtb create --seed is used, instead of cloning volume data. Lets a worktree start from a freshly seeded DB rather than a copy of main's. See Volume cloning |

Validation

On load, wtb validates the config:

  • Errors (fail with exit code 4): wrong types, missing/invalid base_branch, non-array copy_files/link_files, invalid env.adjust value type, or any unsafe/conflicting configured path described above.
  • Warnings (stderr, keep running): referenced docker_compose_file / env.file not found on disk; an env.adjust key that isn't a valid POSIX env var name (it would never match a .env entry — wtb suggests a sanitized form).

Annotated example

# wtb.yaml — full example
base_branch: main
docker_compose_file: ./docker-compose.yml

# Copied into each new worktree even when gitignored
copy_files:
  - .env
  - .env.local
  - .secrets
  - config/

# Symlinked back to the source repo — avoid copying giant dirs
link_files:
  - node_modules
  - .cache
  - .next/cache

# Lifecycle scripts — run inside the worktree via /bin/sh
start_command: npm install && npm run db:migrate
end_command:   docker compose down -v

env:
  file:
    - .env
    - .env.local
  adjust:
    APP_PORT: 1          # any number → "auto-bump to the next free port"
    DB_PORT: 1
    API_KEY: "dev-key"   # string → literal replacement
    DEBUG_PORT: null     # null → remove the variable entirely

Environment variable adjustment

env.adjust lets you rewrite values in every env file as it is copied. Three value types are supported:

| Value type | Behavior on existing key | Behavior when key is absent | |------------|--------------------------|-----------------------------| | number | Scans other worktrees + this file for the port values of every key listed as a number in env.adjust (so cross-key collisions between worktrees are avoided too), then picks the first free port starting at original + 1. The number literal itself is used as a type marker — any positive integer works. | Nothing is added — wtb prints a warning. A port adjustment needs an existing value to bump, so writing the marker integer (e.g. PORT=1) would be meaningless. Define the key in the file, or use a string value to add a literal. | | string | Value is replaced verbatim. | Key is appended with the string value. | | null | Key is removed from the output. | No-op. |

Port collision sources considered:

  1. Other worktrees' .env files (only for keys listed as numbers in env.adjust).
  2. Other numeric entries in the current adjustment pass (so a single file doesn't collide with itself).

Key naming: only POSIX-compliant names (^[A-Za-z_][A-Za-z0-9_]*$) are recognized as adjustable variables. Lines whose key doesn't match are passed through untouched (they're treated as ordinary file content, not as keys to adjust).

Port propagation (env.port_propagation)

A bumped port is rarely referenced only by its own variable. A .env often has it embedded in other values (API_EXTERNAL_URL=http://127.0.0.1:54321), and a compose file embeds it in ${VAR:-default} defaults and string port mappings. Port propagation (on by default) carries every old → new port change into those places too, so a worktree's whole config stays internally consistent after a remap.

When enabled, after wtb bumps a numeric (PORT-marker) env.adjust key it propagates the old→new port into:

  1. Other values in the env files that embed that port (e.g. API_EXTERNAL_URL follows DB_PORT's bump).
  2. The copied compose file — ${VAR:-default} defaults and string port/environment values.
# wtb.yaml
env:
  # Boolean shorthand — enable (default) or disable everything:
  port_propagation: true        # or false
  # Full object form (defaults shown):
  # port_propagation:
  #   enabled: true              # master switch
  #   files: []                  # extra files (beyond env.file) to propagate into
  #   compose: true              # also rewrite ${VAR:-default}/string ports in the compose copy

files lists extra files (beyond env.file) to propagate the port change into. Propagation is boundary-safe: it only rewrites a port immediately preceded by : and followed by a URL/list/quote boundary — a bare number is never matched (a 5432 inside 54321 is safe). Disable the whole feature with port_propagation: false. Run wtb doctor to see which values would or wouldn't follow a bump.

Docker Compose integration

When docker_compose_file is set and the file exists:

  1. wtb reads the file already checked out in the target worktree, preserving an existing branch's Compose definition. It falls back to the main-worktree file only when the target branch has no file at that path.
  2. Calls docker ps to collect ports already claimed by running containers.
  3. For every supported services.*.ports mapping, the host port is rewritten to the first free port at/above the original. Env and Compose allocation share one reservation set: Docker-published ports, ports in all sibling env files, ports in all sibling Compose files (including stopped stacks), and ports already assigned during this create.
  4. Rewrites the worktree's Compose identity so the stack actually isolates per worktree (see below).
  5. Atomically writes the adjusted Compose file into the worktree at the same relative path, preserving an existing file mode.

Per-worktree identity rewrite (compose:)

By default (compose.isolate_name: true) wtb rewrites the worktree's Compose copy so that each worktree gets its own Compose project instead of sharing one. This is the fix for stacks that hardcode a top-level name: and/or container_name: (e.g. Supabase CLI output): without it, a second wtb create produces a stack that attaches to or clobbers the first one's containers and volumes. The rewrite is on by default and happens in place in the worktree's compose file:

  • Project name — the top-level name: becomes <resolved-source-project>-<branch-slug>; when the source omits name:, wtb injects one instead of relying on a directory basename. Resolution honors Compose interpolation and stable worktree .env values. A shell-only COMPOSE_PROJECT_NAME is rejected because it can transiently redirect operations to an unrelated project. If two branches normalize to the same slug, a short hash is appended. Ambient Compose source overrides such as COMPOSE_FILE are also setup failures.
  • Container names — each service's container_name: is handled per compose.container_name.
# wtb.yaml
compose:
  isolate_name: true        # rewrite the top-level name: per worktree (default). false to opt out.
  container_name: suffix    # how to handle services' container_name::
                            #   suffix — append -<branch-slug> (default)
                            #   strip  — remove it (Compose then auto-generates a unique name)
                            #   keep   — leave it as-is (a 2nd worktree's `up` WILL collide; wtb warns)

With container_name: keep, wtb prints a warning naming the services whose fixed container_name: will make a second worktree's docker compose up collide. Use wtb doctor to detect these problems before you hit them. For a runnable fixed-name:/container_name: stack demonstrating the whole rewrite, see examples/compose-identity.

Rewritten files and git skip-worktree

The worktree's compose copy (and any adjusted/propagated env files) may be git-tracked — git worktree add checks them out from the branch. Because wtb rewrites them in place per worktree, wtb marks each rewritten tracked file as git skip-worktree. This keeps the per-worktree rewrites from (a) showing up in git status, (b) blocking wtb remove's dirty check, and (c) being accidentally committed back to the branch. To intentionally commit edits to such a file in a worktree, run git update-index --no-skip-worktree <file> first.

Notes:

  • Supported published-port forms include IPv4/IPv6 short syntax (HOST:CONTAINER, IP:HOST:CONTAINER, optional /tcp//udp) and long syntax with a single numeric/string published value. A variable in the published position is accepted only when the env phase allocated that variable from the shared reservation set.
  • Port ranges, unresolved/unsupported published variables, network_mode: host, network_mode: container:..., and variable network_mode cannot be isolated safely. They are reported as Compose setup failures instead of being silently left unchanged; sibling/main Compose files are checked too because their host use must be reservable. The worktree is kept, ok is false, and --strict exits 1. wtb up repeats the network-mode guard for older or manually edited worktrees. Port-search exhaustion is also a setup failure and never returns an already-reserved port.
  • The original host port is tried first — if the base port is free, it's kept. (Env-file adjustment is stricter and always starts at original + 1.)
  • The Compose copy is parsed and re-serialized — YAML comments, anchors, and original formatting are not preserved. This is true of every Compose file wtb writes.
  • If Docker isn't installed or its daemon can't be queried, create fails closed before env/Compose port allocation instead of risking an unknown collision. The worktree is kept, ok is false, and --strict exits 1. Pass --no-docker only when intentionally skipping Compose setup and volume cloning as well as the Docker-port query; any remaining env allocation still reserves sibling env/Compose ports. A later remove that attempts teardown also fails safely and keeps the worktree.
  • wtb remove runs docker compose -f <your configured compose file> down in the worktree before removing it (so non-default filenames like compose.dev.yml work), unless end_command is set (then you own teardown) or --no-docker is passed.
  • Disable Compose integration entirely by omitting the field or setting it to "".

Volume cloning

After remapping the Compose file, wtb automatically clones every named Docker volume declared in the Compose volumes: section from the source project to the new worktree's project. This is what makes a new worktree start with the same database/cache contents your main worktree already has — no manual pg_dump | pg_restore cycle, no re-seeding.

How it works:

  1. wtb enumerates volumes: keys from the Compose file.

  2. Volumes marked external: true are skipped (they're shared by design).

  3. Source volume name is resolved as <source_project>_<key> (or the explicit volumes.<key>.name if set). Same for the target with the new worktree's project name.

  4. Plan-then-stop-then-copy. wtb computes the full per-volume clone plan before stopping anything, and only stops the source Compose stack if at least one volume will actually be cloned. A non-external fixed name that resolves source and target to the same volume is a setup failure, not a successful skip; intentional sharing must use external: true. Existing targets are ownership-checked even when the source volume does not exist. If a cloneable source volume is in use, wtb stops the source stack, clones, then restarts it (compose start, falling back to up -d). A failed/timed-out stop is treated as possibly partial and always triggers a recovery attempt. Opt out with --no-stop, or use --force-volume-copy to clone live (data-corruption risk).

    If the restart fails (both docker compose start and the up -d fallback error), wtb create / wtb reclone exit with code 5 (Docker error) even without --strict — a failed restart leaves your running source environment broken, so this is treated as a hard failure — and print the exact recovery command to run by hand.

  5. For each volume:

    • If the source stack was stopped (or --force-volume-copy was passed, or nothing was running), wtb clones it.
    • If --no-stop is set and a running container is using the source volume, wtb skips it with a warning (a live filesystem copy of an active database can corrupt — Postgres/MySQL/Redis). Stop the source side with docker compose stop first, drop --no-stop, or pass --force-volume-copy.
    • If the target volume already has data, wtb skips it (assumes you've already populated it). Pass --force-volume-copy to overwrite. Even with force, wtb requires an exact wtb.repo / wtb.project / wtb.branch ownership match. A foreign or data-bearing unmanaged volume is never overwritten. An empty unmanaged volume may be recreated with proper labels only after wtb proves that no container uses it.
    • A destructive overwrite stages the full copy in a wtb.temp=true volume and requires the source and staged byte counts to match. Before clearing the target, wtb atomically records the target, temp volume, ownership, and sizes under the shared Git directory. The record is deleted only after refill succeeds. If commit is interrupted, the verified temp is preserved with a recovery command instead of being pruned as debris.
    • Otherwise, wtb does a recursive copy via a transient instrumentisto/rsync-ssh sidecar container (with an Alpine cp -a fallback if rsync isn't available).

Every target volume wtb creates—either during cloning or later through the rewritten Compose file—is labelled wtb.managed=true, wtb.repo=<repo-hash>, wtb.project=<compose-project>, and wtb.branch=<branch>. Temporary staging volumes add wtb.temp=true. These labels make overwrite/removal checks and prune independent of custom worktree paths.

Selectively exclude volumes you don't want to clone (e.g. regenerable caches):

# wtb.yaml
volumes:
  exclude:
    - cache_data
    - tmp_data

Disable the whole phase per-invocation with wtb create <branch> --no-volume-copy. Keep the source stack running and skip in-use volumes with --no-stop. Force-clone running source volumes live (data-loss risk, dev only) with --force-volume-copy.

The per-volume summary reports N cloned, N skipped, N failed. If any volume fails to clone, the worktree is still created but the success banner is suppressed and the incomplete data state is made explicit. By default the command still exits 0 (the worktree exists) — pass --strict to make a clone, seed, or other setup failure exit 1. A skip is intentional — for example a missing source or an in-use source under --no-stop. A populated target is skippable only when its exact repository/project/branch ownership is proven; foreign and populated unmanaged targets are failures even with force. External and exclude-listed volumes are filtered out before counting. A failure includes safety validation and copy errors.

wtb remove <branch> does not delete cloned volumes by default. --remove-volumes (and wtb down --remove-volumes) runs down -v only after every existing non-external named volume passes the exact ownership check; fixed/shared, unmanaged, foreign, temporary, or uninspectable data is never deleted. When automatic teardown is intentionally skipped, the flag is a no-op with a warning. Volumes retained this way can later be swept with wtb prune.

Seed instead of clone (--seed)

Sometimes you don't want a copy of main's data — you want a freshly seeded database in the new worktree (a clean migration target, a deterministic test fixture, etc.). Configure a seed command and pass --seed:

# wtb.yaml
volumes:
  seed_command: docker compose up -d db && npm run db:migrate && npm run db:seed
wtb create feature/clean-db --seed

With --seed, wtb skips the volume-clone phase entirely and runs volumes.seed_command in the new worktree (via /bin/sh, cwd = worktree root, same path-or-shell resolution as start_command). Because nothing is ever read off a live source volume, this path never stops the source stack — your main services keep running untouched. This is the "data-autonomous by construction" path: the worktree's data is built fresh, not copied.

Notes:

  • --seed requires volumes.seed_command to be set; otherwise create fails with exit 4 before creating the worktree.
  • --seed and --force-volume-copy are mutually exclusive (one seeds, the other clones) — passing both fails with exit 1.
  • If the seed command fails, the worktree is still created but the banner becomes ⚠️ Worktree created, but the seed command FAILED — this worktree's data is NOT ready (exit stays 0, same contract as a failed clone; pass --strict to exit 1 instead). Re-run the seed inside the worktree after fixing it.

Lifecycle scripts

start_command and end_command run inside the worktree with cwd set to the worktree root and a /bin/sh shell. For start_command, wtb first tries resolving the string as a path relative to the worktree (so ./scripts/setup.sh works); if the file doesn't exist it's passed to the shell as-is (so npm install && npm run dev also works).

A failed start_command leaves the newly created worktree in place, suppresses the success banner, and makes create --json report ok: false; default exit is 0, while --strict exits 1. seed_command and start_command are not run at all after an earlier copy/link/env/Compose/data-isolation failure, because an incomplete worktree could redirect them to source data. A failed end_command during remove also leaves the worktree in place and exits 1; remove --force may delete it anyway but still exits 1 and reports partial cleanup.

Architecture

src/
├── cli/
│   ├── commands/      init, create, remove, reclone, up, down, prune, ls, path, ports, status, doctor, init-claude
│   ├── utils/         worktree/ports renderers, command error wrapper, claude skill installer
│   └── index.ts       commander wiring + global error handlers
├── core/
│   ├── config/        YAML loader + validator + defaults merge
│   ├── git/           repository / worktree / commit-info helpers
│   ├── docker/        `docker ps`, compose parse/write, port adjust
│   └── environment/   .env parser (order-preserving) + adjust + serialize
├── utils/             safe exec helpers (execFileSync wrappers), errors
├── types/             all public types (WtbConfig, WorktreeInfo, …)
├── constants/         defaults, command templates, regex, exit codes
└── index.ts           library entry point

For full module-by-module API surface and design rationale, see ARCHITECTURE.md.

Key design choices:

  • No shell-injection surface for git/docker. Anything derived from user input (branch names, paths) is passed to execFileSync as array arguments, never interpolated into a shell string. A few fixed docker compose invocations use execSync with hardcoded constants only (no user input). The one place a shell is used intentionally is user-supplied lifecycle scripts, which run via /bin/sh.
  • Defaults-merge with ??. Missing fields fall back to defaults, but empty arrays/strings you explicitly set are preserved.
  • Order-preserving .env parsing. Comments, blank lines, and inline # comments survive the copy + adjust round-trip.
  • Pure renderers for ls. renderDefault/renderLong/renderPaths/renderJson are unit-tested in isolation; the command module just wires them up.
  • Enrichment is best-effort. ls -l falls back gracefully on prunable/broken worktrees and still prints the rest — the failure is surfaced in JSON as enrichmentError.

Exit codes (src/constants/index.ts):

| Code | Meaning | |------|---------| | 0 | Success | | 1 | General error | | 2 | Invalid CLI usage — missing argument, unknown option/command, invalid/excess arguments, and conflicting options (e.g. wtb ports --json --pretty, or a branch argument combined with --all). --help/--version still exit 0 | | 3 | Not in a git repository | | 4 | Configuration error (config not found-but-invalid, parse failure, or validation failure) — also wtb up / wtb down when no docker_compose_file is configured | | 5 | Docker error — emitted by remove when an attempted Compose teardown fails, by prune --yes on partial removal, by create / reclone when a stopped source stack cannot restart, and by up / down when Compose fails | | 6 | Worktree already exists — wtb create for a branch that already has a worktree, without --exists-ok | | 130 / 143 | Interrupted by SIGINT (Ctrl-C) / SIGTERM. Treat the run as aborted — an interrupted create may be partially done |

Development

git clone https://github.com/origamium/wtb.git
cd wtb
npm install

npm run dev                    # run the CLI from source (tsx)
npm run build                  # tsc → dist/
npm start                      # run the built CLI

npm run test                   # vitest watch
npm run test:run               # vitest once
npm run test:unit              # unit tests (src/)
npm run test:e2e               # e2e (creates real git repos under test-repos/)
npm run test:integration       # real-Docker volume-clone checks (skips if Docker is absent)
npm run test:ui                # vitest UI

npm run typecheck              # tsc --noEmit
npm run lint                   # biome lint
npm run format                 # biome format --write
npm run check                  # biome check --write (lint + format)

npm test, npm run test:run, and npm run test:e2e build dist/ first, so E2E tests never exercise a stale CLI. test:ui stays independent for interactive use.

E2E tests (e2e/) create temporary git repos and exercise the compiled CLI end-to-end. See sample/ for a runnable playground — a tiny Next.js + Postgres stack with a real wtb.yaml, .env, and docker-compose.yml.

For a broader spread of configs — full-stack Compose, minimal Compose, seed/exclude/external volumes, a fixed-identity (Supabase-style) stack (compose-identity), a no-Docker Node project, and a bare-minimum setup — see examples/. Each is a self-contained project, and examples/try.sh <example> [branch] [--real] drives the real CLI against any of them in a throwaway git repo (dry-run by default):

examples/try.sh                                   # list the examples
examples/try.sh minimal                           # preview the plan
examples/try.sh compose-minimal feature/db --real # real run (clones the DB volume)

Design notes

  • Symlinks beat copies for large trees. node_modules, .cache, .next/cache should almost always go in link_files. One source of truth, zero disk duplication, instant worktree creation. The tradeoff: native modules rebuilt for a different platform in one worktree affect all of them — use copy_files for those.
  • Branch name sanitization. / in branch names becomes - in the default path: feature/auth → worktree-feature-auth. Use -p <path> if you need full control.
  • Docker integration is opt-in. Omit docker_compose_file or pass --no-docker to skip it intentionally. If configured teardown is attempted and Docker fails, remove keeps the worktree instead of pretending cleanup succeeded.
  • wtb ls vs wtb status. ls is for fast, scriptable enumeration (1 git call in the default form). status is for human inspection with Docker context. Use ls -l --json in scripts.
  • Dry-run is honest. --dry-run walks every phase and prints what it would do, including which files are missing and would be skipped.

Requirements

  • Node.js ≥ 18
  • Git (any modern version with worktree support)
  • Docker + Docker Compose (optional — only if docker_compose_file is configured)

Claude Code integration

wtb ships a Claude Code skill that teaches the agent how to inspect this repo's worktrees and call the CLI itself. Once installed, Claude can answer "what port is this worktree on?" or "spin up a worktree for feature/auth" without any hand-holding.

Install once per repo

wtb init-claude                          # writes .claude/skills/wtb/SKILL.md
git add .claude/skills/wtb
git commit -m "chore: install wtb Claude Code skill"

Because .claude/skills/ is a regular tracked directory, every worktree you create with git worktree add / wtb create automatically inherits the skill — there is nothing to sync per-worktree.

Prefer a global install?

wtb init-claude --user                   # writes ~/.claude/skills/wtb/SKILL.md

Flags: -f, --force (overwrite existing), --user (global), --dry-run (preview target path only), --check (verify the installed skill against the CLI version; writes nothing).

The installed SKILL.md is stamped with <!-- wtb-skill-version: X.Y.Z -->, so after upgrading wtb you can run wtb init-claude --check (exit 0 when current, 1 when missing/unstamped/stale) and refresh with wtb init-claude --force.

wtb ports — the data source

The skill tells Claude to call wtb ports (JSON is the default output — --json is accepted as a no-op and conflicts with --pretty). The command is useful on its own too:

wtb ports                                # current worktree as a JSON object
wtb ports feature/auth                   # a specific worktree by branch
wtb ports -a                             # every worktree as a JSON array (alias: --all)
wtb ports --pretty                       # human-readable

Output shape:

{
  "path": "/Users/me/worktree-feature-auth",
  "branch": "feature/auth",
  "env": { "APP_PORT": "3001", "DB_PORT": "5433" },
  "compose": {
    "file": "docker-compose.yml",
    "services": {
      "web": { "host_ports": [3001], "container_ports": [80] },
      "db":  { "host_ports": [5433], "container_ports": [5432] }
    }
  },
  "endpoints": ["http://localhost:3001", "http://localhost:5433"]
}

Notes:

  • env only contains keys listed under env.adjust in wtb.yaml — other .env entries (secrets, API keys) are not leaked.
  • compose.services is populated from the worktree's copy of the Compose file, so it reflects the already-adjusted ports.
  • endpoints is a convenience list of http://localhost:<port> entries built from c