Run a project's docker-compose.yml stack once per git worktree, with no
port conflicts, no container-name clashes, and no orphaned volumes when a
worktree is archived.
It was built for Conductor, where many git worktrees
of the same project run in parallel. Each worktree wants to start the same
Docker stack — same ports (80, 3306, 5173, …), same container names, same
volumes — so running two at once collides. wtc gives each worktree its own
isolated copy of the stack and wires itself into Conductor's setup/run/archive
hooks.
Status: milestones M1–M8 complete —
up,down,ports, discovery and cleanup (ps,status,logs,clean), the Conductor hooks (setup,up --foreground,--on-signal), memory limits/monitoring (stats,--mem-limit, oversubscription guard) and the performance pass (lazy CLI startup, reused Docker client, parallelps/statsand startup, cached builds with--rebuild, idempotent no-op re-up) all work.
docker compose -p <name> can namespace a project, but you still have to manage
unique project names per worktree, rewrite every host port to avoid collisions,
and clean up after archived worktrees. wtc automates all of that from the
worktree path alone.
It talks directly to the Docker Engine API (via docker-py) rather than
shelling out to the docker / docker compose binaries. That means it
interprets a supported subset of compose itself — which is the core of the work,
and what lets it remap ports and inject network aliases transparently.
Two tricks, applied to every worktree:
The project namespace is derived from the worktree's absolute path:
/Users/jack/work/accra -> accra-a1b2c3
/Users/jack/work/accra-feature-x -> accra-feature-x-9f8e7d
<sanitised-dir-name>-<6-char-path-hash> — readable and unique. Everything is
prefixed with it: containers (accra-a1b2c3_mariadb), the network
(accra-a1b2c3_net), and named volumes (accra-a1b2c3_mariadb_data). Host ports
are remapped to free ports; container-side ports are never touched.
The stack discovers services by name (DB_HOST=${DB_CONTAINER},
depends_on: [mariadb]). After renaming, each container is attached to the
project network with two aliases — its compose service name (mariadb)
and its original container_name value (${DB_CONTAINER}) — so peers still
resolve it.
Rename for uniqueness; alias for reachability.
flowchart TB
subgraph wtA["worktree A — network accra-a1b2c3_net"]
A1["accra-a1b2c3_php-apache<br/>aliases: php-apache, mtc-pharmacy-web<br/>:56740 -> 80"]
A2["accra-a1b2c3_mariadb<br/>aliases: mariadb, mariadb<br/>:56741 -> 3306"]
A1 -->|DB_HOST=mariadb| A2
end
subgraph wtB["worktree B — network feature-x-9f8e7d_net"]
B1["feature-x-9f8e7d_php-apache<br/>:56737 -> 80"]
B2["feature-x-9f8e7d_mariadb<br/>:56738 -> 3306"]
B1 -->|DB_HOST=mariadb| B2
end
Same alias mariadb in both worktrees — no clash, because each network is
separate and aliases are network-scoped.
Installable globally with uv or pipx:
uv tool install --editable . # or: pipx install --editable .
wtc --versionFor local development without uv/pipx:
python3 -m venv .venv
. .venv/bin/activate
pip install -e ".[dev]"
pytestRequires Python 3.11+ and a running Docker Engine (Docker Desktop).
From inside a worktree that has a docker-compose.yml:
wtc up # build/pull images, create network + volumes, start the stack
wtc ports # show the host ports assigned to this worktree
wtc down # stop & remove containers + network (volumes are KEPT)wtc up prints the assigned ports when the stack is healthy:
✓ stack accra-a1b2c3 is up
php-apache http://localhost:56740 (80/tcp)
mariadb http://localhost:56741 (3306/tcp)
wtc up [-w PATH] [-f FILE] [--foreground] [--on-signal leave|down] [--recreate] [--no-build] [--strict]
wtc down [-w PATH] [-f FILE]
wtc ports [-w PATH] [-f FILE] [--json]
wtc ps [-a] [-s] [--json] # all worktrees, label-driven (-s adds live memory)
wtc status [-w PATH] # per-service up/down + health for one worktree
wtc stats [-w PATH] [-a] [--json] # live memory usage vs limit per service
wtc logs [SERVICE...] [-w PATH] [-f] # show or follow container logs
wtc clean [-w PATH] [--all] [-y] # tear down containers + network + volumes; free ports
wtc setup -w PATH # Conductor setup hook: seed .env, register, pre-allocate ports
wtc inspect [-f FILE] # debug: print the interpreted compose model
wtc --version
| Flag | Meaning |
|---|---|
-w, --worktree PATH |
Worktree path (default: derived from the compose location). |
-f, --compose FILE |
Compose file or directory (default: current directory). |
--foreground |
Stay attached after start (for Conductor's Run button); block until signalled. |
--on-signal leave|down |
On SIGHUP/SIGTERM in --foreground: leave (keep running, default) or down (stop the stack). |
--recreate |
Force-recreate containers even if already running. |
--no-build |
Skip building images (use the existing tag). |
--strict |
Fail on unsupported compose features instead of warning. |
--json |
Machine-readable output (for ports, ps). |
- Stable ports. A worktree keeps the same host ports across
down/uprestarts (stored in the registry). - Idempotent
up. Re-runningupon a healthy stack is a no-op; a stopped stack is restarted;--recreateforces a fresh build. - Volumes are sacred. Neither
upnordownever deletes a volume — your local database survives a restart. Onlyclean(M5) removes volumes. - Concurrency-safe. Two worktrees can
upsimultaneously without double-assigning a port (port reservation is guarded by a file lock).
Supported: build (context/dockerfile/args), image, container_name,
ports, expose, volumes (named + bind, short & long form), environment,
env_file, depends_on (incl. service_healthy / service_started),
healthcheck, command, entrypoint, restart, working_dir, user;
top-level volumes. ${VAR:-default}-style interpolation (and .env files) is
fully supported.
Not supported (warn-and-ignore; --strict to hard-fail): profiles,
extends, secrets, configs, multi-file overrides, deploy,
network_mode: host, links, port ranges, custom multi-network topologies.
Run wtc inspect to see exactly how wtc interprets a compose file, including
which features it's ignoring.
A target project (e.g. the pharmacy starter) wires wtc into its conductor.json
— a ready-to-copy example lives at examples/conductor.json:
{
"scripts": {
"setup": "wtc setup --worktree \"$CONDUCTOR_WORKSPACE_PATH\"",
"run": "wtc up --worktree \"$CONDUCTOR_WORKSPACE_PATH\" --foreground --on-signal leave",
"archive": "wtc clean --worktree \"$CONDUCTOR_WORKSPACE_PATH\" --yes"
},
"runScriptMode": "concurrent"
}setupseeds.envfrom.docker/config/.env.local(without clobbering an existing.env), registers the stack and pre-allocates its host ports — so the Run button is instant.runbrings the stack up and stays attached for the Run button. When Conductor stops it (SIGHUP/SIGTERM),--on-signal leave(the default) just detaches and leaves the containers running, so your local database is never destroyed. Use--on-signal downto stop the stack on signal (volumes are still preserved — same aswtc down).archivetears the stack down fully — containers and volumes — even after the worktree directory is gone (the absolute path lives in a container label).
The codebase is split so the pure, testable parts are isolated from the one place that touches Docker:
src/wtc/
cli.py # Typer commands + output rendering ONLY
config.py # config-dir paths, constants
errors.py # WtcError hierarchy
project.py # namespace derivation from worktree path (pure)
ports.py # free-port allocation (bind-to-0 probe) (pure)
labels.py # com.wtc.* label constants/builders (pure)
registry.py # JSON registry, filelock, stable ports (pure)
compose/ (pure)
interpolate.py # ${VAR:-default} expansion
model.py # typed dataclasses
normalize.py # short/long form -> model; unsupported keys
loader.py # YAML + env files -> interpolate -> model
engine/ # THE ONLY place that imports docker-py
client.py # client wrapper + duration -> nanoseconds
images.py # pull-if-absent
builder.py # image build from Dockerfile/context
network.py # network get-or-create / remove
volumes.py # named-volume get-or-create / remove
containers.py # create/start/stop/remove + ports/env/labels/mounts/aliases
health.py # healthcheck polling + depends_on topo-sort
orchestrator.py # up/down flows (glue between pure core and engine)
flowchart LR
CLI[cli.py] --> ORC[orchestrator.py]
ORC --> COMPOSE[compose/*]
ORC --> REG[registry.py + ports.py]
ORC --> PROJ[project.py]
ORC --> ENG[engine/* — docker-py]
ENG --> DOCKER[(Docker Engine API)]
style COMPOSE fill:#e8f5e9
style REG fill:#e8f5e9
style PROJ fill:#e8f5e9
style ENG fill:#fff3e0
Green = pure / Docker-free / unit-tested. Orange = the Docker boundary.
wtc keeps a registry at ~/.config/wtc/registry.json (override with
WTC_CONFIG_DIR), keyed by absolute worktree path. It records each stack's
namespace, network, volumes, assigned host ports and status. Container labels
are the source of truth for discovery and cleanup; the registry is an
enrichment cache that's cheap to rebuild if it drifts.
Every managed resource carries labels:
| Label | Value |
|---|---|
com.wtc.managed |
true |
com.wtc.worktree |
absolute worktree path |
com.wtc.project |
namespace (e.g. accra-a1b2c3) |
com.wtc.service |
compose service name |
com.wtc.confighash |
hash of the compose file |
pip install -e ".[dev]"
pytest # 51 pure unit tests, no Docker requiredThe pure layers (compose/, project.py, ports.py, registry.py) are fully
unit-tested without Docker. The engine layer is verified end-to-end against a
real daemon: two worktrees up simultaneously on distinct ports, cross-service
DNS resolving via aliases, ports stable across restart, clean teardown.
See .context/plans/wtc-worktree-container-manager-cli.md for the full design
and .context/todos.md for the live build checklist.