Skip to content

devjacjef/wtc

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

wtc — Worktree Container Manager

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 completeup, 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, parallel ps/stats and startup, cached builds with --rebuild, idempotent no-op re-up) all work.


Why not just docker compose?

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.


How isolation works

Two tricks, applied to every worktree:

1. Rename everything per 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.

2. Keep internal DNS working with network aliases

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
Loading

Same alias mariadb in both worktrees — no clash, because each network is separate and aliases are network-scoped.


Install

Installable globally with uv or pipx:

uv tool install --editable .     # or: pipx install --editable .
wtc --version

For local development without uv/pipx:

python3 -m venv .venv
. .venv/bin/activate
pip install -e ".[dev]"
pytest

Requires Python 3.11+ and a running Docker Engine (Docker Desktop).


Usage

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)

Command reference

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).

Behaviour guarantees

  • Stable ports. A worktree keeps the same host ports across down/up restarts (stored in the registry).
  • Idempotent up. Re-running up on a healthy stack is a no-op; a stopped stack is restarted; --recreate forces a fresh build.
  • Volumes are sacred. Neither up nor down ever deletes a volume — your local database survives a restart. Only clean (M5) removes volumes.
  • Concurrency-safe. Two worktrees can up simultaneously without double-assigning a port (port reservation is guarded by a file lock).

Supported compose subset

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.


Conductor integration

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"
}
  • setup seeds .env from .docker/config/.env.local (without clobbering an existing .env), registers the stack and pre-allocates its host ports — so the Run button is instant.
  • run brings 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 down to stop the stack on signal (volumes are still preserved — same as wtc down).
  • archive tears the stack down fully — containers and volumes — even after the worktree directory is gone (the absolute path lives in a container label).

Architecture

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
Loading

Green = pure / Docker-free / unit-tested. Orange = the Docker boundary.

State

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

Development

pip install -e ".[dev]"
pytest                       # 51 pure unit tests, no Docker required

The 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.

About

Working tree containers

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages