From ebd855938b981e00b6481bb5db8343a04eed3a71 Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Fri, 24 Apr 2026 19:56:24 -0700 Subject: [PATCH 1/4] generalize docker services --- README.md | 98 ++++-- __tests__/config.spec.ts | 47 +-- __tests__/docker-services.docker.spec.ts | 151 +++++++++ __tests__/docker-services.spec.ts | 94 ++++++ __tests__/env-patcher.spec.ts | 9 +- __tests__/managed-redis.docker.spec.ts | 171 ---------- __tests__/managed-redis.spec.ts | 89 ----- __tests__/registry.spec.ts | 5 +- __tests__/slot-allocator.spec.ts | 1 - package.json | 7 +- scripts/clean.js | 3 + scripts/test-docker.js | 2 +- skills/wt/SKILL.md | 26 +- src/commands/new.spec.ts | 102 +++--- src/commands/new.ts | 71 ++-- src/commands/prune.spec.ts | 139 ++++---- src/commands/prune.ts | 71 ++-- src/commands/remove.ts | 20 +- src/commands/setup.ts | 184 ++--------- src/core/docker-services.ts | 396 +++++++++++++++++++++++ src/core/env-patcher.ts | 14 - src/core/managed-redis.ts | 350 -------------------- src/output.ts | 21 +- src/schemas/config.schema.ts | 25 +- src/schemas/registry.schema.ts | 6 +- src/types.ts | 3 +- 26 files changed, 1060 insertions(+), 1045 deletions(-) create mode 100644 __tests__/docker-services.docker.spec.ts create mode 100644 __tests__/docker-services.spec.ts delete mode 100644 __tests__/managed-redis.docker.spec.ts delete mode 100644 __tests__/managed-redis.spec.ts create mode 100644 scripts/clean.js create mode 100644 src/core/docker-services.ts delete mode 100644 src/core/managed-redis.ts diff --git a/README.md b/README.md index cdf1a36..79a1935 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # wt — Git Worktree Environment Isolation -A CLI tool that gives each git worktree its own Postgres database, managed Redis container, ports, and `.env` files. Prevents worktrees from corrupting each other's data. +A CLI tool that gives each git worktree its own Postgres database, Docker services, ports, and `.env` files. Prevents worktrees from corrupting each other's data. ## The Problem @@ -11,7 +11,7 @@ When you use `git worktree add` for parallel development, all worktrees share th - Two dev servers can't run simultaneously on the same port - `.env` files point to the same resources everywhere -`wt` solves this by assigning each worktree an isolated **slot** that determines its database name, Redis container, and port range. +`wt` solves this by assigning each worktree an isolated **slot** that determines its database name, Docker Compose project, and port range. ## How It Works @@ -20,11 +20,11 @@ Each worktree gets a numbered slot. The slot determines everything: | Resource | Formula | Slot 0 (main) | Slot 1 | Slot 2 | Slot 3 | |----------|---------|:-:|:-:|:-:|:-:| | Database | `{baseName}_wt{slot}` | `mydb` | `mydb_wt1` | `mydb_wt2` | `mydb_wt3` | -| Redis | `wt---slot--redis` on `6379 + slot * stride` | shared/local | `6479` | `6579` | `6679` | +| Docker project | `wt---slot-` | shared/local | slot 1 group | slot 2 group | slot 3 group | | Ports | `slot * stride + defaultPort` | 3000, 3001 | 3100, 3101 | 3200, 3201 | 3300, 3301 | - **Database**: Created via `CREATE DATABASE ... TEMPLATE` (fast filesystem copy, not dump/restore) -- **Redis**: Runs in a dedicated Docker container per worktree, visible in Docker Desktop +- **Docker services**: Run in a dedicated Docker Compose project per worktree, grouped in Docker Desktop - **Ports**: Offset by `portStride` (default 100) per slot - **Env files**: Copied from main worktree and patched with the slot's values @@ -57,8 +57,31 @@ Create this file in your repository root and commit it. See [Configuration Refer "services": [ { "name": "web", "defaultPort": 3000 }, { "name": "api", "defaultPort": 4000 }, + { "name": "electric", "defaultPort": 3004 }, { "name": "redis", "defaultPort": 6379 } ], + "dockerServices": [ + { + "name": "redis", + "image": "redis:8-alpine", + "ports": [ + { "service": "redis", "target": 6379 } + ], + "command": ["redis-server", "--requirepass", "local_password"] + }, + { + "name": "electric", + "image": "docker.io/electricsql/electric:subqueries-beta-7", + "ports": [ + { "service": "electric", "target": 3000 } + ], + "environment": { + "DATABASE_URL": "postgresql://user:password@host.docker.internal:5432/{{dbName}}?sslmode=disable", + "ELECTRIC_INSECURE": "true", + "ELECTRIC_USAGE_REPORTING": "false" + } + } + ], "envFiles": [ { "source": ".env", @@ -70,7 +93,7 @@ Create this file in your repository root and commit it. See [Configuration Refer "source": "backend/.env", "patches": [ { "var": "DATABASE_URL", "type": "database" }, - { "var": "REDIS_URL", "type": "redis", "service": "redis" }, + { "var": "REDIS_URL", "type": "url", "service": "redis" }, { "var": "PORT", "type": "port", "service": "api" } ] }, @@ -137,7 +160,7 @@ Creates a new git worktree and sets up its isolated environment: 2. Checks whether `origin/` exists; if it does, fetches it and creates a tracking local branch, otherwise creates a fresh local branch 3. Creates a new Postgres database from the main DB as template 4. Copies all configured `.env` files, patching each with slot-specific values -5. Starts a managed Redis Docker container if Redis patching is configured +5. Starts configured Docker services after the slot database exists 6. Runs `postSetup` commands (unless `--no-install`) ### `wt open [--no-install] [--json]` @@ -173,7 +196,7 @@ If the worktree already has a slot allocation, it reuses it. Removes a worktree and cleans up its resources: 1. Drops the worktree's Postgres database (unless `--keep-db`) -2. Removes the managed Redis Docker container for that slot +2. Removes the managed Docker project for that slot 3. Runs `git worktree remove` 4. Removes the allocation from the registry @@ -190,7 +213,7 @@ Finds worktrees that Git already marks as prunable, then: 1. Cleans up `wt`-managed resources for matching registry entries 2. Drops their databases unless `--keep-db` is set -3. Removes managed Redis containers if present +3. Removes managed Docker projects if present 4. Runs `git worktree prune` This is mainly for worktrees that were deleted manually from disk instead of through `wt remove`. @@ -199,7 +222,7 @@ Use `--dry-run` to preview what would be pruned. ### `wt list [--json]` -Shows all worktree allocations with their slot, branch, database, Redis info, ports, and status (ok/stale). +Shows all worktree allocations with their slot, branch, database, Docker project, ports, and status (ok/stale). ### `wt doctor [--fix] [--json]` @@ -256,12 +279,32 @@ This file lives in your repository root and is committed to version control. "maxSlots": number, // Services that need port allocation. - // If you use a `redis` patch and omit a redis service, - // wt assumes { name: "redis", defaultPort: 6379 }. "services": [ { "name": string, "defaultPort": number } ], + // Docker services to run per worktree (default: []). + // wt renders this into an internal Docker Compose project named + // wt---slot-, so Docker Desktop groups them together. + "dockerServices": [ + { + "name": string, + "image": string, + "restart": "no" | "always" | "unless-stopped" | "on-failure", + "ports": [ + { + "service": string, // service name from "services" + "target": number, // container port + "host": string // default "127.0.0.1" + } + ], + "environment": { [key: string]: string }, + "command": string | string[], + "volumes": string[], + "extraHosts": string[] + } + ], + // Env files to copy and patch for each worktree "envFiles": [ { @@ -269,8 +312,8 @@ This file lives in your repository root and is committed to version control. "patches": [ { "var": string, // Env var name to patch - "type": string, // "database" | "redis" | "port" | "url" - "service": string // Required for "redis", "port", and "url" types + "type": string, // "database" | "port" | "url" | "branch" + "service": string // Required for "port" and "url" types } ] } @@ -284,18 +327,20 @@ This file lives in your repository root and is committed to version control. } ``` -Legacy configs that used a `redis` patch without an explicit `redis` service are auto-migrated on first run. +`dockerServices` string values support these templates: `{{slot}}`, `{{dbName}}`, `{{branchName}}`, `{{worktreePath}}`, `{{mainRoot}}`, `{{projectName}}`, `{{ports.}}`, and `{{services..port}}`. ### Patch Types | Type | What it patches | Input | Output (slot 3) | |------|----------------|-------|------------------| | `database` | Replaces DB name in a Postgres URL | `postgresql://u:p@host:5432/myapp?schema=public` | `postgresql://u:p@host:5432/myapp_wt3?schema=public` | -| `redis` | Rewrites a Redis URL to the managed local Redis container on DB 0 | `redis://:pass@host:6379/0` | `redis://:pass@127.0.0.1:6679/0` | | `port` | Replaces the entire value with the allocated port | `4000` | `4300` | | `url` | Replaces the port number inside a URL | `http://localhost:4000/api` | `http://localhost:4300/api` | +| `branch` | Replaces the entire value with the current git branch | `main` | `feat/my-work` | + +The `port` and `url` types require a `service` field that matches a name in `services`. -The `redis`, `port`, and `url` types require a `service` field that matches a name in `services`. +Legacy `type: "redis"` patches are no longer supported. Declare Redis in `dockerServices` and patch `REDIS_URL` with `type: "url"` instead. ### `.worktree-registry.json` @@ -309,7 +354,10 @@ Auto-managed file at the repo root. **Add to `.gitignore`** — it's machine-loc "worktreePath": "/absolute/path/to/.worktrees/feat-auth", "branchName": "feat/auth", "dbName": "myapp_wt1", - "redisContainerName": "wt-myapp-a1b2c3d4-slot-1-redis", + "docker": { + "projectName": "wt-myapp-a1b2c3d4-slot-1", + "services": ["redis", "electric"] + }, "ports": { "web": 3100, "api": 4100, "redis": 6479 }, "createdAt": "2026-02-17T14:30:00Z" } @@ -370,8 +418,9 @@ If you are an LLM agent setting up `wt` for a repository, follow these steps: Identify these from the repository: - **Database URL format**: Search `.env` files for `DATABASE_URL`. Extract the database name (the path segment after the port, before `?`). -- **Redis URL format**: Search for `REDIS_URL`. `wt` will rewrite it to a local Docker-managed Redis URL on DB 0. -- **Services and ports**: Find all dev server commands and their default ports. Check `package.json` scripts, `docker-compose.yml`, and framework configs. +- **Redis URL format**: Search for `REDIS_URL`. If Redis should be per-worktree, declare Redis in both `services` and `dockerServices`, then patch `REDIS_URL` with `type: "url"`. +- **Services and ports**: Find all dev server commands and their default ports. Check `package.json` scripts, existing Docker Compose files, and framework configs. +- **Docker services**: Move per-worktree containers from Docker Compose files into `dockerServices`. - **Env files**: List all `.env` files (not `.env.example`). These are the files that need patching. ### Step 2: Map env vars to patch types @@ -381,7 +430,7 @@ For each `.env` file, identify which variables need patching: | If the variable contains... | Use patch type | |----------------------------|----------------| | A Postgres connection URL (`postgresql://...`) | `database` | -| A Redis connection URL (`redis://...`) | `redis` + service name (`redis`) | +| A Redis connection URL (`redis://...`) | `url` + service name (`redis`) | | Just a port number (`3000`) | `port` + service name | | A URL with a port (`http://localhost:3000/...`) | `url` + service name | @@ -394,14 +443,15 @@ Using the discovered information, construct the config: ``` 1. baseDatabaseName = the DB name from the main DATABASE_URL 2. services = each dev server as { name, defaultPort } -3. if using a `redis` patch, include { name: "redis", defaultPort: 6379 } unless you want a custom base port +3. dockerServices = each per-worktree container, with ports referencing `services` 4. envFiles = each .env file with its patches 5. postSetup = the install command for the package manager (npm install, pnpm install, etc.) ``` Validate that: - Every `port` and `url` patch has a `service` that exists in `services` -- If using a `redis` patch, Docker is available locally and the Redis service port is included or left to the default `6379` +- Every `dockerServices[].ports[].service` exists in `services` +- If using `dockerServices`, Docker is available locally - The `portStride` (default 100) doesn't cause port collisions with other local services - `maxSlots * portStride` doesn't push ports into reserved ranges (e.g., above 65535) @@ -424,7 +474,7 @@ wt list # Should show the new allocation wt remove .worktrees/test-wt-smoke wt list # Should be empty again -# Opt-in Docker integration test for managed Redis +# Opt-in Docker integration test for managed Docker services pnpm test:docker ``` @@ -445,7 +495,7 @@ pnpm test:docker - Node.js >= 20.19.0 - PostgreSQL (running, accessible via `DATABASE_URL` in root `.env`) -- Docker (if using `redis` patch type) +- Docker (if using `dockerServices`) - Git (for worktree operations) ## License diff --git a/__tests__/config.spec.ts b/__tests__/config.spec.ts index 9c4fda8..4c19434 100644 --- a/__tests__/config.spec.ts +++ b/__tests__/config.spec.ts @@ -15,7 +15,7 @@ describe('loadConfig', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it('migrates legacy redis config to an explicit redis service on first load', () => { + it('rejects legacy redis patch configs', () => { fs.writeFileSync( path.join(tmpDir, 'wt.config.json'), JSON.stringify({ @@ -31,29 +31,8 @@ describe('loadConfig', () => { ], }, null, 2), ); - fs.writeFileSync( - path.join(tmpDir, '.env'), - 'REDIS_URL=redis://:local_password@127.0.0.1:6380/0\n', - ); - - const config = loadConfig(tmpDir); - - expect(config.services).toContainEqual({ name: 'redis', defaultPort: 6380 }); - expect(config.envFiles[0]?.patches[0]).toEqual({ - var: 'REDIS_URL', - type: 'redis', - service: 'redis', - }); - - const rewritten = JSON.parse( - fs.readFileSync(path.join(tmpDir, 'wt.config.json'), 'utf-8'), - ) as { - services: Array<{ name: string; defaultPort: number }>; - envFiles: Array<{ patches: Array<{ var: string; type: string; service?: string }> }>; - }; - expect(rewritten.services).toContainEqual({ name: 'redis', defaultPort: 6380 }); - expect(rewritten.envFiles[0]?.patches[0]?.service).toBe('redis'); + expect(() => loadConfig(tmpDir)).toThrow(); }); it('rejects configs whose generated ports collide across slots', () => { @@ -73,4 +52,26 @@ describe('loadConfig', () => { expect(() => loadConfig(tmpDir)).toThrow('collides'); }); + + it('rejects docker services that reference unknown port services', () => { + fs.writeFileSync( + path.join(tmpDir, 'wt.config.json'), + JSON.stringify({ + baseDatabaseName: 'myapp', + services: [{ name: 'web', defaultPort: 3000 }], + dockerServices: [ + { + name: 'electric', + image: 'docker.io/electricsql/electric:latest', + ports: [{ service: 'electric', target: 3000 }], + }, + ], + envFiles: [], + }, null, 2), + ); + + expect(() => loadConfig(tmpDir)).toThrow( + "Docker service 'electric' references unknown port service 'electric'.", + ); + }); }); diff --git a/__tests__/docker-services.docker.spec.ts b/__tests__/docker-services.docker.spec.ts new file mode 100644 index 0000000..7269604 --- /dev/null +++ b/__tests__/docker-services.docker.spec.ts @@ -0,0 +1,151 @@ +import { afterAll, afterEach, describe, expect, it, jest } from '@jest/globals'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { + ensureDockerServices, + getDockerProjectName, + listManagedDockerProjectsForRepo, + removeDockerServices, +} from '../src/core/docker-services'; +import type { WtConfig } from '../src/types'; + +const describeDocker = process.env.WT_RUN_DOCKER_TESTS === '1' ? describe : describe.skip; +const SLOT = 42; + +async function reserveFreePort(): Promise { + const server = net.createServer(); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected a TCP address.'); + } + + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + + return address.port; +} + +async function pingRedis(port: number): Promise { + return await new Promise((resolve) => { + let finished = false; + let response = ''; + const finish = (value: boolean) => { + if (!finished) { + finished = true; + resolve(value); + } + }; + + const socket = net.createConnection({ host: '127.0.0.1', port }); + socket.setTimeout(2000); + socket.on('connect', () => { + socket.write('*1\r\n$4\r\nPING\r\n'); + }); + socket.on('data', (chunk: Buffer | string) => { + response += chunk.toString(); + if (response.includes('PONG')) { + socket.end(); + finish(true); + } + }); + socket.on('timeout', () => { + socket.destroy(); + finish(false); + }); + socket.on('error', () => { + finish(false); + }); + socket.on('close', () => { + finish(response.includes('PONG')); + }); + }); +} + +async function waitForRedis(port: number, timeoutMs = 30000): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (await pingRedis(port)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + throw new Error(`Redis did not respond on port ${port} within ${timeoutMs}ms.`); +} + +describeDocker('docker-services integration', () => { + jest.setTimeout(120000); + + const mainRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-docker-services-')); + const worktreePath = path.join(mainRoot, '.worktrees', 'feat-integration'); + const branchName = 'feat/integration'; + + const config: WtConfig = { + baseDatabaseName: 'myapp', + baseWorktreePath: '.worktrees', + portStride: 100, + maxSlots: 50, + services: [{ name: 'redis', defaultPort: 6379 }], + dockerServices: [ + { + name: 'redis', + image: 'redis:8-alpine', + restart: 'unless-stopped', + ports: [{ service: 'redis', target: 6379, host: '127.0.0.1' }], + environment: {}, + volumes: [], + extraHosts: [], + }, + ], + envFiles: [], + postSetup: [], + autoInstall: true, + }; + + afterEach(() => { + removeDockerServices(mainRoot, SLOT); + }); + + afterAll(() => { + fs.rmSync(mainRoot, { recursive: true, force: true }); + }); + + it('creates, lists, and removes real Docker Compose-managed services', async () => { + const port = await reserveFreePort(); + const projectName = getDockerProjectName(mainRoot, SLOT); + + const allocation = ensureDockerServices({ + mainRoot, + slot: SLOT, + branchName, + worktreePath, + dbName: 'myapp_wt42', + ports: { redis: port }, + config, + }); + + expect(allocation).toEqual({ projectName, services: ['redis'] }); + await waitForRedis(port); + + const projects = listManagedDockerProjectsForRepo(mainRoot); + expect(projects).toContainEqual({ + projectName, + slot: SLOT, + branch: branchName, + worktreePath, + services: ['redis'], + containerNames: [`${projectName}-redis`], + }); + + expect(removeDockerServices(mainRoot, SLOT)).toBe(true); + expect(listManagedDockerProjectsForRepo(mainRoot)).toEqual([]); + expect(removeDockerServices(mainRoot, SLOT)).toBe(false); + }); +}); diff --git a/__tests__/docker-services.spec.ts b/__tests__/docker-services.spec.ts new file mode 100644 index 0000000..4799ad9 --- /dev/null +++ b/__tests__/docker-services.spec.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from '@jest/globals'; +import { + buildDockerComposeConfig, + getDockerProjectName, + usesDockerServices, +} from '../src/core/docker-services'; +import type { WtConfig } from '../src/types'; + +describe('docker-services', () => { + const config: WtConfig = { + baseDatabaseName: 'cryptoacc', + baseWorktreePath: '.worktrees', + portStride: 100, + maxSlots: 25, + services: [ + { name: 'electric', defaultPort: 3004 }, + { name: 'redis', defaultPort: 6379 }, + ], + dockerServices: [ + { + name: 'redis', + image: 'redis:8-alpine', + restart: 'unless-stopped', + ports: [{ service: 'redis', target: 6379, host: '127.0.0.1' }], + environment: {}, + command: ['redis-server', '--requirepass', 'local_password'], + volumes: [], + extraHosts: [], + }, + { + name: 'electric', + image: 'docker.io/electricsql/electric:subqueries-beta-7', + restart: 'unless-stopped', + ports: [{ service: 'electric', target: 3000, host: '127.0.0.1' }], + environment: { + DATABASE_URL: 'postgresql://user:password@host.docker.internal:5432/{{dbName}}?sslmode=disable', + ELECTRIC_INSECURE: 'true', + ELECTRIC_PORT: '{{ports.electric}}', + }, + volumes: [], + extraHosts: ['host.docker.internal:host-gateway'], + }, + ], + envFiles: [], + postSetup: [], + autoInstall: true, + }; + + it('detects whether docker services are configured', () => { + expect(usesDockerServices(config)).toBe(true); + expect(usesDockerServices({ ...config, dockerServices: [] })).toBe(false); + }); + + it('builds a deterministic Docker Compose project name from repo path and slot', () => { + const name = getDockerProjectName('/Users/dev/My Project', 7); + + expect(name).toMatch(/^wt-my-project-[a-f0-9]{8}-slot-7$/); + expect(getDockerProjectName('/Users/dev/My Project', 7)).toBe(name); + }); + + it('renders compose services with allocated ports, labels, and template values', () => { + const compose = buildDockerComposeConfig({ + mainRoot: '/Users/dev/My Project', + slot: 3, + branchName: 'feat/electric', + worktreePath: '/Users/dev/My Project/.worktrees/feat-electric', + dbName: 'cryptoacc_wt3', + ports: { electric: 3304, redis: 6679 }, + config, + }); + + const projectName = getDockerProjectName('/Users/dev/My Project', 3); + expect(compose.services.redis).toMatchObject({ + image: 'redis:8-alpine', + container_name: `${projectName}-redis`, + ports: ['127.0.0.1:6679:6379'], + command: ['redis-server', '--requirepass', 'local_password'], + }); + expect(compose.services.redis?.labels).toContain('dev.tokenbooks.wt.service=redis'); + expect(compose.services.redis?.labels).toContain('dev.tokenbooks.wt.slot=3'); + + expect(compose.services.electric).toMatchObject({ + image: 'docker.io/electricsql/electric:subqueries-beta-7', + container_name: `${projectName}-electric`, + ports: ['127.0.0.1:3304:3000'], + environment: { + DATABASE_URL: 'postgresql://user:password@host.docker.internal:5432/cryptoacc_wt3?sslmode=disable', + ELECTRIC_INSECURE: 'true', + ELECTRIC_PORT: '3304', + }, + extra_hosts: ['host.docker.internal:host-gateway'], + }); + }); +}); diff --git a/__tests__/env-patcher.spec.ts b/__tests__/env-patcher.spec.ts index ee40aab..57f8fe9 100644 --- a/__tests__/env-patcher.spec.ts +++ b/__tests__/env-patcher.spec.ts @@ -5,7 +5,6 @@ import type { PatchConfig, PatchContext } from '../src/types'; describe('env-patcher', () => { const context: PatchContext = { dbName: 'cryptoacc_wt3', - redisPort: 3379, ports: { app: 3300, server: 3301, 'sync-exchanges': 3302, redis: 3379 }, branchName: 'chore/observability', }; @@ -40,8 +39,8 @@ describe('env-patcher', () => { }); }); - describe('redis patch', () => { - const patches: PatchConfig[] = [{ var: 'REDIS_URL', type: 'redis', service: 'redis' }]; + describe('redis url patch', () => { + const patches: PatchConfig[] = [{ var: 'REDIS_URL', type: 'url', service: 'redis' }]; it.each([ [ @@ -52,7 +51,7 @@ describe('env-patcher', () => { [ 'without DB index', 'REDIS_URL=redis://:local_password@localhost:6379', - 'REDIS_URL=redis://:local_password@127.0.0.1:3379/0', + 'REDIS_URL=redis://:local_password@localhost:3379', ], ])('%s', (_name, input, expected) => { // Act @@ -184,7 +183,7 @@ describe('env-patcher', () => { const patches: PatchConfig[] = [ { var: 'DATABASE_URL', type: 'database' }, - { var: 'REDIS_URL', type: 'redis', service: 'redis' }, + { var: 'REDIS_URL', type: 'url', service: 'redis' }, { var: 'PORT', type: 'port', service: 'server' }, ]; diff --git a/__tests__/managed-redis.docker.spec.ts b/__tests__/managed-redis.docker.spec.ts deleted file mode 100644 index f685277..0000000 --- a/__tests__/managed-redis.docker.spec.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { afterAll, afterEach, describe, expect, it, jest } from '@jest/globals'; -import * as net from 'node:net'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import * as fs from 'node:fs'; -import { execFileSync } from 'node:child_process'; -import { - ensureManagedRedisContainer, - getManagedRedisContainerName, - removeManagedRedisContainer, -} from '../src/core/managed-redis'; - -const describeDocker = process.env.WT_RUN_DOCKER_TESTS === '1' ? describe : describe.skip; -const SLOT = 42; - -interface DockerInspectRecord { - readonly Id: string; - readonly Config?: { - readonly Labels?: Record; - }; - readonly NetworkSettings?: { - readonly Ports?: Record | null>; - }; -} - -function runDocker(args: readonly string[]): string { - return execFileSync('docker', args, { - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'pipe'], - }).trim(); -} - -async function reserveFreePort(): Promise { - const server = net.createServer(); - await new Promise((resolve) => { - server.listen(0, '127.0.0.1', () => resolve()); - }); - - const address = server.address(); - if (!address || typeof address === 'string') { - throw new Error('Expected a TCP address.'); - } - - await new Promise((resolve, reject) => { - server.close((err) => (err ? reject(err) : resolve())); - }); - - return address.port; -} - -function inspectContainer(containerName: string): DockerInspectRecord | null { - try { - const raw = runDocker(['container', 'inspect', containerName]); - const parsed = JSON.parse(raw) as DockerInspectRecord[]; - return parsed[0] ?? null; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (message.includes('No such container')) { - return null; - } - throw err; - } -} - -async function pingRedis(port: number): Promise { - return await new Promise((resolve) => { - let finished = false; - let response = ''; - const finish = (value: boolean) => { - if (!finished) { - finished = true; - resolve(value); - } - }; - - const socket = net.createConnection({ host: '127.0.0.1', port }); - socket.setTimeout(2000); - socket.on('connect', () => { - socket.write('*1\r\n$4\r\nPING\r\n'); - }); - socket.on('data', (chunk: Buffer | string) => { - response += chunk.toString(); - if (response.includes('PONG')) { - socket.end(); - finish(true); - } - }); - socket.on('timeout', () => { - socket.destroy(); - finish(false); - }); - socket.on('error', () => { - finish(false); - }); - socket.on('close', () => { - finish(response.includes('PONG')); - }); - }); -} - -async function waitForRedis(port: number, timeoutMs = 30000): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - if (await pingRedis(port)) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - - throw new Error(`Redis did not respond on port ${port} within ${timeoutMs}ms.`); -} - -describeDocker('managed-redis docker integration', () => { - jest.setTimeout(120000); - - const mainRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-managed-redis-docker-')); - const worktreePath = path.join(mainRoot, '.worktrees', 'feat-integration'); - const branchName = 'feat/integration'; - const containerName = getManagedRedisContainerName(mainRoot, SLOT); - - afterEach(() => { - removeManagedRedisContainer(mainRoot, SLOT); - }); - - afterAll(() => { - fs.rmSync(mainRoot, { recursive: true, force: true }); - }); - - it('creates, reuses, and removes a real Docker-managed Redis container', async () => { - const port = await reserveFreePort(); - - const firstName = ensureManagedRedisContainer({ - mainRoot, - slot: SLOT, - branchName, - worktreePath, - port, - sourceUrl: 'redis://127.0.0.1:6379/0', - }); - - expect(firstName).toBe(containerName); - await waitForRedis(port); - - const firstInspect = inspectContainer(containerName); - expect(firstInspect).not.toBeNull(); - expect(firstInspect?.NetworkSettings?.Ports?.['6379/tcp']?.[0]?.HostIp).toBe('127.0.0.1'); - expect(firstInspect?.NetworkSettings?.Ports?.['6379/tcp']?.[0]?.HostPort).toBe(String(port)); - expect(firstInspect?.Config?.Labels?.['dev.tokenbooks.wt.managed']).toBe('true'); - expect(firstInspect?.Config?.Labels?.['dev.tokenbooks.wt.purpose']).toBe('git-worktree-redis'); - expect(firstInspect?.Config?.Labels?.['dev.tokenbooks.wt.slot']).toBe(String(SLOT)); - expect(firstInspect?.Config?.Labels?.['dev.tokenbooks.wt.branch']).toBe(branchName); - expect(firstInspect?.Config?.Labels?.['dev.tokenbooks.wt.worktree']).toBe(worktreePath); - - const secondName = ensureManagedRedisContainer({ - mainRoot, - slot: SLOT, - branchName, - worktreePath, - port, - sourceUrl: 'redis://127.0.0.1:6379/0', - }); - - const secondInspect = inspectContainer(containerName); - expect(secondName).toBe(containerName); - expect(secondInspect?.Id).toBe(firstInspect?.Id); - - expect(removeManagedRedisContainer(mainRoot, SLOT)).toBe(true); - expect(inspectContainer(containerName)).toBeNull(); - expect(removeManagedRedisContainer(mainRoot, SLOT)).toBe(false); - }); -}); diff --git a/__tests__/managed-redis.spec.ts b/__tests__/managed-redis.spec.ts deleted file mode 100644 index 2cd0b61..0000000 --- a/__tests__/managed-redis.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { - getAllocationServices, - getManagedRedisContainerName, - patchManagedRedisUrl, - readManagedRedisSourceUrl, - usesManagedRedis, -} from '../src/core/managed-redis'; -import type { WtConfig } from '../src/types'; - -describe('managed-redis', () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-redis-test-')); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - const configWithRedis: WtConfig = { - baseDatabaseName: 'cryptoacc', - baseWorktreePath: '.worktrees', - portStride: 100, - maxSlots: 25, - services: [{ name: 'web', defaultPort: 3000 }], - envFiles: [ - { - source: '.env', - patches: [{ var: 'REDIS_URL', type: 'redis', service: 'redis' }], - }, - ], - postSetup: [], - autoInstall: true, - }; - - it('detects when managed redis is enabled', () => { - expect(usesManagedRedis(configWithRedis)).toBe(true); - expect(usesManagedRedis({ ...configWithRedis, envFiles: [] })).toBe(false); - }); - - it('auto-adds a redis service when redis patching is enabled', () => { - expect(getAllocationServices(configWithRedis)).toEqual([ - { name: 'web', defaultPort: 3000 }, - { name: 'redis', defaultPort: 6379 }, - ]); - }); - - it('reuses the configured redis service when present', () => { - const config: WtConfig = { - ...configWithRedis, - services: [ - { name: 'web', defaultPort: 3000 }, - { name: 'redis', defaultPort: 6380 }, - ], - }; - - expect(getAllocationServices(config)).toEqual(config.services); - }); - - it('patches redis urls to localhost db 0 on the allocated port', () => { - expect( - patchManagedRedisUrl('redis://:local_password@localhost:6379/9', 6479), - ).toBe('redis://:local_password@127.0.0.1:6479/0'); - }); - - it('reads the base redis url from a configured env file', () => { - fs.writeFileSync( - path.join(tmpDir, '.env'), - 'DATABASE_URL=postgresql://localhost:5432/cryptoacc\nREDIS_URL=redis://:secret@localhost:6379/0\n', - 'utf-8', - ); - - expect(readManagedRedisSourceUrl(tmpDir, configWithRedis)).toBe( - 'redis://:secret@localhost:6379/0', - ); - }); - - it('builds a deterministic docker container name from repo path and slot', () => { - const name = getManagedRedisContainerName('/Users/dev/My Project', 7); - - expect(name).toMatch(/^wt-my-project-[a-f0-9]{8}-slot-7-redis$/); - expect(getManagedRedisContainerName('/Users/dev/My Project', 7)).toBe(name); - }); -}); diff --git a/__tests__/registry.spec.ts b/__tests__/registry.spec.ts index bfb653b..0c1594f 100644 --- a/__tests__/registry.spec.ts +++ b/__tests__/registry.spec.ts @@ -26,7 +26,10 @@ describe('registry', () => { worktreePath: '/tmp/worktrees/feat-test', branchName: 'feat/test', dbName: 'cryptoacc_wt1', - redisContainerName: 'wt-project-12345678-slot-1-redis', + docker: { + projectName: 'wt-project-12345678-slot-1', + services: ['redis'], + }, ports: { app: 3100, server: 3101, redis: 6479 }, createdAt: '2026-02-17T14:30:00Z', }; diff --git a/__tests__/slot-allocator.spec.ts b/__tests__/slot-allocator.spec.ts index 038a2ef..eb4f6ad 100644 --- a/__tests__/slot-allocator.spec.ts +++ b/__tests__/slot-allocator.spec.ts @@ -135,7 +135,6 @@ function makeAllocation(slot: number) { worktreePath: `/tmp/wt${slot}`, branchName: `feat/test-${slot}`, dbName: `cryptoacc_wt${slot}`, - redisContainerName: `wt-project-12345678-slot-${slot}-redis`, ports: { app: 3000 + slot * 100, redis: 6379 + slot * 100 }, createdAt: new Date().toISOString(), }; diff --git a/package.json b/package.json index d077bc9..25c0619 100644 --- a/package.json +++ b/package.json @@ -17,13 +17,14 @@ "README.md" ], "scripts": { - "prepare": "tsc && node scripts/link-cli.js", - "build": "tsc && node scripts/link-cli.js", + "clean": "node scripts/clean.js", + "prepare": "node scripts/clean.js && tsc && node scripts/link-cli.js", + "build": "node scripts/clean.js && tsc && node scripts/link-cli.js", "dev": "tsc --watch", "test": "jest", "test:docker": "node scripts/test-docker.js", "lint": "eslint 'src/**/*.ts' '__tests__/**/*.ts'", - "prepack": "tsc", + "prepack": "node scripts/clean.js && tsc", "release": "node scripts/release.js", "release:patch": "node scripts/release.js patch", "release:minor": "node scripts/release.js minor", diff --git a/scripts/clean.js b/scripts/clean.js new file mode 100644 index 0000000..a136dd0 --- /dev/null +++ b/scripts/clean.js @@ -0,0 +1,3 @@ +const fs = require('node:fs'); + +fs.rmSync('dist', { recursive: true, force: true }); diff --git a/scripts/test-docker.js b/scripts/test-docker.js index 032f441..e0c70c8 100644 --- a/scripts/test-docker.js +++ b/scripts/test-docker.js @@ -13,7 +13,7 @@ execFileSync( [ 'exec', 'jest', - '__tests__/managed-redis.docker.spec.ts', + '__tests__/docker-services.docker.spec.ts', '--runInBand', ], { diff --git a/skills/wt/SKILL.md b/skills/wt/SKILL.md index 162bdd3..bc6df88 100644 --- a/skills/wt/SKILL.md +++ b/skills/wt/SKILL.md @@ -1,13 +1,13 @@ --- name: wt -description: Manage git worktree isolation — create, list, remove, and prune worktrees with isolated databases, managed Redis, and ports +description: Manage git worktree isolation — create, list, remove, and prune worktrees with isolated databases, Docker services, and ports argument-hint: "[new|open|list|remove|prune|doctor|setup|init] [args...]" allowed-tools: Bash, Read, Write, Edit, Grep, Glob --- # wt — Git Worktree Isolation -You are managing git worktrees with isolated Postgres databases, managed Redis Docker containers, and ports using the `wt` CLI. +You are managing git worktrees with isolated Postgres databases, per-worktree Docker services, and ports using the `wt` CLI. ## Routing @@ -23,7 +23,8 @@ Search the repository to find: - All `.env` files (not `.env.example`): `find . -name '.env' -not -path '*/node_modules/*' -not -path '*/.git/*'` - The `DATABASE_URL` value to extract the base database name (path segment after the port, before `?`) - Any `REDIS_URL` values and their base port/auth format -- All services and their default ports — check `package.json` scripts, `docker-compose.yml`, framework config files +- All services and their default ports — check `package.json` scripts, Docker Compose files, framework config files +- Per-worktree Docker services that should move into `dockerServices` - The package manager in use (`pnpm`, `npm`, `yarn`) — check for lockfiles **Step 2: Map env vars to patch types** @@ -33,7 +34,7 @@ For each `.env` file, examine every variable and classify: | Variable contains | Patch type | Needs `service`? | |---|---|---| | Postgres connection URL (`postgresql://...`) | `database` | No | -| Redis connection URL (`redis://...`) | `redis` | Yes (`redis`) | +| Redis connection URL (`redis://...`) | `url` | Yes (`redis`) | | Just a port number | `port` | Yes | | A URL containing a service port (`http://localhost:3000/...`) | `url` | Yes | | Anything else (API keys, secrets, flags) | Skip — do not patch | — | @@ -52,11 +53,18 @@ Build the config file at the repository root: { "name": "", "defaultPort": }, { "name": "redis", "defaultPort": } ], + "dockerServices": [ + { + "name": "redis", + "image": "redis:8-alpine", + "ports": [{ "service": "redis", "target": 6379 }] + } + ], "envFiles": [ { "source": "", "patches": [ - { "var": "", "type": "", "service": "" } + { "var": "", "type": "", "service": "" } ] } ], @@ -66,10 +74,12 @@ Build the config file at the repository root: ``` Validation rules: -- Every `redis`, `port`, and `url` patch must have a `service` that exists in `services` +- Every `port` and `url` patch must have a `service` that exists in `services` +- Every `dockerServices[].ports[].service` must exist in `services` - `portStride` * `maxSlots` + max default port must be < 65535 - `baseDatabaseName` must match the actual DB name in `DATABASE_URL` -- If using a `redis` patch, Docker must be available locally +- If using `dockerServices`, Docker must be available locally +- Do not use legacy `type: "redis"` patches; use `type: "url"` for `REDIS_URL` **Step 4: Install wt** @@ -238,7 +248,7 @@ Show a brief help: ``` Available commands: /wt init — Set up wt in a new repository (discovers env files, generates config) - /wt new — Create a worktree with isolated DB, managed Redis, and ports + /wt new — Create a worktree with isolated DB, Docker services, and ports /wt open — Open a worktree by slot or branch (creates if not found) /wt list — List all worktree allocations /wt remove |--all — Remove one or more worktrees and clean up resources diff --git a/src/commands/new.spec.ts b/src/commands/new.spec.ts index 3978e5f..1710e53 100644 --- a/src/commands/new.spec.ts +++ b/src/commands/new.spec.ts @@ -26,12 +26,9 @@ jest.mock('../core/database', () => ({ dropDatabase: jest.fn(), })); -jest.mock('../core/managed-redis', () => ({ - ensureManagedRedisContainer: jest.fn(), - getAllocationServices: jest.fn(), - readManagedRedisSourceUrl: jest.fn(), - removeManagedRedisContainer: jest.fn(), - usesManagedRedis: jest.fn(), +jest.mock('../core/docker-services', () => ({ + ensureDockerServices: jest.fn(), + removeDockerServices: jest.fn(), })); jest.mock('../core/git', () => ({ @@ -59,12 +56,9 @@ import { import { copyAndPatchAllEnvFiles } from '../core/env-patcher'; import { createDatabase, databaseExists, dropDatabase } from '../core/database'; import { - ensureManagedRedisContainer, - getAllocationServices, - readManagedRedisSourceUrl, - removeManagedRedisContainer, - usesManagedRedis, -} from '../core/managed-redis'; + ensureDockerServices, + removeDockerServices, +} from '../core/docker-services'; import { getMainWorktreePath, createWorktree, @@ -90,17 +84,11 @@ const mockCopyAndPatchAllEnvFiles = const mockCreateDatabase = createDatabase as jest.MockedFunction; const mockDatabaseExists = databaseExists as jest.MockedFunction; const mockDropDatabase = dropDatabase as jest.MockedFunction; -const mockGetAllocationServices = - getAllocationServices as jest.MockedFunction; -const mockUsesManagedRedis = usesManagedRedis as jest.MockedFunction; -const mockEnsureManagedRedisContainer = ensureManagedRedisContainer as jest.MockedFunction< - typeof ensureManagedRedisContainer +const mockEnsureDockerServices = ensureDockerServices as jest.MockedFunction< + typeof ensureDockerServices >; -const mockReadManagedRedisSourceUrl = readManagedRedisSourceUrl as jest.MockedFunction< - typeof readManagedRedisSourceUrl ->; -const mockRemoveManagedRedisContainer = removeManagedRedisContainer as jest.MockedFunction< - typeof removeManagedRedisContainer +const mockRemoveDockerServices = removeDockerServices as jest.MockedFunction< + typeof removeDockerServices >; const mockGetMainWorktreePath = getMainWorktreePath as jest.MockedFunction; const mockCreateWorktree = createWorktree as jest.MockedFunction; @@ -123,6 +111,7 @@ describe('new command branch selection', () => { services: [ { name: 'web', defaultPort: 3000 }, ], + dockerServices: [], envFiles: [], postSetup: [], autoInstall: true, @@ -153,8 +142,6 @@ describe('new command branch selection', () => { mockFindAvailablePortSafeSlot.mockResolvedValue(2); mockCalculatePorts.mockReturnValue({ web: 3200 }); mockCalculateDbName.mockReturnValue('myapp_wt2'); - mockGetAllocationServices.mockReturnValue(config.services); - mockUsesManagedRedis.mockReturnValue(false); mockDatabaseExists.mockResolvedValue(false); mockCreateDatabase.mockResolvedValue(); mockCreateWorktree.mockReturnValue(allocation.worktreePath); @@ -256,7 +243,7 @@ describe('new command rollback on failure', () => { let worktreeDir: string; let stderrSpy: jest.SpiedFunction; - const configWithRedis: WtConfig = { + const configWithDocker: WtConfig = { baseDatabaseName: 'myapp', baseWorktreePath: '.worktrees', portStride: 100, @@ -268,7 +255,19 @@ describe('new command rollback on failure', () => { envFiles: [ { source: '.env', - patches: [{ var: 'REDIS_URL', type: 'redis', service: 'redis' }], + patches: [{ var: 'REDIS_URL', type: 'url', service: 'redis' }], + }, + ], + dockerServices: [ + { + name: 'redis', + image: 'redis:8-alpine', + restart: 'unless-stopped', + ports: [{ service: 'redis', target: 6379, host: '127.0.0.1' }], + environment: {}, + command: ['redis-server'], + volumes: [], + extraHosts: [], }, ], postSetup: [], @@ -292,16 +291,16 @@ describe('new command rollback on failure', () => { jest.spyOn(console, 'log').mockImplementation(() => {}); mockGetMainWorktreePath.mockReturnValue(tmpDir); - mockLoadConfig.mockReturnValue(configWithRedis); + mockLoadConfig.mockReturnValue(configWithDocker); mockReadRegistry.mockReturnValue({ version: 1, allocations: {} } satisfies Registry); mockFindAvailablePortSafeSlot.mockResolvedValue(2); mockCalculatePorts.mockReturnValue({ web: 3200, redis: 6579 }); mockCalculateDbName.mockReturnValue('myapp_wt2'); - mockGetAllocationServices.mockReturnValue(configWithRedis.services); - mockUsesManagedRedis.mockReturnValue(true); - mockReadManagedRedisSourceUrl.mockReturnValue('redis://:pw@localhost:6379/0'); - mockEnsureManagedRedisContainer.mockReturnValue('wt-myapp-deadbeef-slot-2-redis'); - mockRemoveManagedRedisContainer.mockReturnValue(true); + mockEnsureDockerServices.mockReturnValue({ + projectName: 'wt-myapp-deadbeef-slot-2', + services: ['redis'], + }); + mockRemoveDockerServices.mockReturnValue(true); mockDatabaseExists.mockResolvedValue(false); mockCreateDatabase.mockResolvedValue(); mockDropDatabase.mockResolvedValue(); @@ -321,7 +320,25 @@ describe('new command rollback on failure', () => { jest.clearAllMocks(); }); - it('rolls back Redis, database, and worktree when env patching fails', async () => { + it('starts Docker after database creation', async () => { + await createNewWorktree('feat/auth', { install: false, quiet: true }); + + const createOrder = mockCreateDatabase.mock.invocationCallOrder[0]; + const dockerOrder = mockEnsureDockerServices.mock.invocationCallOrder[0]; + expect(createOrder).toBeLessThan(dockerOrder!); + expect(mockEnsureDockerServices).toHaveBeenCalledWith({ + mainRoot: tmpDir, + slot: 2, + branchName: 'feat/auth', + worktreePath: worktreeDir, + dbName: 'myapp_wt2', + ports: { web: 3200, redis: 6579 }, + config: configWithDocker, + log: expect.any(Function), + }); + }); + + it('rolls back Docker, database, and worktree when env patching fails', async () => { const boom = new Error('env patch exploded'); mockCopyAndPatchAllEnvFiles.mockImplementation(() => { throw boom; @@ -335,7 +352,7 @@ describe('new command rollback on failure', () => { 'myapp', expect.any(Function), ); - expect(mockRemoveManagedRedisContainer).toHaveBeenCalledWith(tmpDir, 2, expect.any(Function)); + expect(mockRemoveDockerServices).toHaveBeenCalledWith(tmpDir, 2, expect.any(Function)); expect(mockRemoveWorktree).toHaveBeenCalledWith(worktreeDir, expect.any(Function)); expect(mockWriteRegistry).not.toHaveBeenCalled(); }); @@ -351,11 +368,11 @@ describe('new command rollback on failure', () => { // We reused an existing DB, so we must not drop it on rollback. expect(mockDropDatabase).not.toHaveBeenCalled(); - expect(mockRemoveManagedRedisContainer).toHaveBeenCalledWith(tmpDir, 2, expect.any(Function)); + expect(mockRemoveDockerServices).toHaveBeenCalledWith(tmpDir, 2, expect.any(Function)); expect(mockRemoveWorktree).toHaveBeenCalled(); }); - it('rolls back the Redis container when createDatabase fails', async () => { + it('does not start Docker when createDatabase fails', async () => { const boom = new Error('CREATE DATABASE failed'); mockCreateDatabase.mockRejectedValue(boom); @@ -363,7 +380,8 @@ describe('new command rollback on failure', () => { // DB creation failed before completion so no drop needed. expect(mockDropDatabase).not.toHaveBeenCalled(); - expect(mockRemoveManagedRedisContainer).toHaveBeenCalledWith(tmpDir, 2, expect.any(Function)); + expect(mockEnsureDockerServices).not.toHaveBeenCalled(); + expect(mockRemoveDockerServices).not.toHaveBeenCalled(); expect(mockRemoveWorktree).toHaveBeenCalled(); expect(mockWriteRegistry).not.toHaveBeenCalled(); }); @@ -374,7 +392,7 @@ describe('new command rollback on failure', () => { throw originalError; }); mockDropDatabase.mockRejectedValue(new Error('postgres unreachable')); - mockRemoveManagedRedisContainer.mockImplementation(() => { + mockRemoveDockerServices.mockImplementation(() => { throw new Error('docker daemon down'); }); mockRemoveWorktree.mockImplementation(() => { @@ -390,8 +408,8 @@ describe('new command rollback on failure', () => { expect(stderr).toContain('git refused'); }); - it('skips Redis rollback when managed redis is disabled', async () => { - mockUsesManagedRedis.mockReturnValue(false); + it('skips Docker rollback when no docker services are configured', async () => { + mockLoadConfig.mockReturnValue({ ...configWithDocker, dockerServices: [] }); const boom = new Error('env patch exploded'); mockCopyAndPatchAllEnvFiles.mockImplementation(() => { throw boom; @@ -399,8 +417,8 @@ describe('new command rollback on failure', () => { await expect(createNewWorktree('feat/auth', { install: false, quiet: true })).rejects.toBe(boom); - expect(mockEnsureManagedRedisContainer).not.toHaveBeenCalled(); - expect(mockRemoveManagedRedisContainer).not.toHaveBeenCalled(); + expect(mockEnsureDockerServices).toHaveBeenCalled(); + expect(mockRemoveDockerServices).not.toHaveBeenCalled(); expect(mockRemoveWorktree).toHaveBeenCalled(); }); }); diff --git a/src/commands/new.ts b/src/commands/new.ts index 8da222a..d31b627 100644 --- a/src/commands/new.ts +++ b/src/commands/new.ts @@ -9,12 +9,9 @@ import { import { copyAndPatchAllEnvFiles } from '../core/env-patcher'; import { createDatabase, databaseExists, dropDatabase } from '../core/database'; import { - ensureManagedRedisContainer, - getAllocationServices, - readManagedRedisSourceUrl, - removeManagedRedisContainer, - usesManagedRedis, -} from '../core/managed-redis'; + ensureDockerServices, + removeDockerServices, +} from '../core/docker-services'; import { getMainWorktreePath, createWorktree, @@ -64,7 +61,6 @@ export async function createNewWorktree( const mainRoot = getMainWorktreePath(); const config = loadConfig(mainRoot); - const services = getAllocationServices(config); let registry = readRegistry(mainRoot); // Determine slot @@ -77,7 +73,7 @@ export async function createNewWorktree( if (String(slot) in registry.allocations) { throw new Error(`Slot ${slot} is already occupied.`); } - const requestedPorts = calculatePorts(slot, services, config.portStride); + const requestedPorts = calculatePorts(slot, config.services, config.portStride); const unavailablePorts = await findUnavailableServicePorts(requestedPorts); if (unavailablePorts.length > 0) { const detail = unavailablePorts @@ -89,7 +85,7 @@ export async function createNewWorktree( const available = await findAvailablePortSafeSlot( registry, config.maxSlots, - services, + config.services, config.portStride, ); if (available === null) { @@ -114,21 +110,19 @@ export async function createNewWorktree( log(describeBranchSelection(branchSelection)); const dbName = calculateDbName(slot, config.baseDatabaseName); - const ports = calculatePorts(slot, services, config.portStride); + const ports = calculatePorts(slot, config.services, config.portStride); const databaseUrl = readDatabaseUrl(mainRoot); - const managedRedis = usesManagedRedis(config); // Track what each step has created so we can roll back on failure. Resource - // leaks from partially-successful `wt new` runs were the main source of - // orphan Redis containers in practice — everything allocated here must be - // torn down if we fail before writing the registry. + // leaks from partially-successful `wt new` runs are hard to clean up later; + // everything allocated here must be torn down if we fail before writing the + // registry. let worktreeCreated = false; - let redisContainerCreated = false; + let dockerStarted = false; let databaseCreated = false; let worktreePath: string; let actualBranch: string; let allocation: Allocation; - let redisContainerName: string | undefined; try { worktreePath = createWorktree( @@ -139,20 +133,6 @@ export async function createNewWorktree( worktreeCreated = true; actualBranch = getBranchName(worktreePath); - if (managedRedis) { - const redisSourceUrl = readManagedRedisSourceUrl(mainRoot, config); - redisContainerName = ensureManagedRedisContainer({ - mainRoot, - slot, - branchName: actualBranch, - worktreePath, - port: ports.redis!, - sourceUrl: redisSourceUrl, - log, - }); - redisContainerCreated = true; - } - const dbAlreadyExists = await databaseExists(databaseUrl, dbName); if (!dbAlreadyExists) { log(`Creating database '${dbName}'...`); @@ -167,10 +147,21 @@ export async function createNewWorktree( log(`Database '${dbName}' already exists, reusing.`); } + dockerStarted = config.dockerServices.length > 0; + const docker = ensureDockerServices({ + mainRoot, + slot, + branchName: actualBranch, + worktreePath, + dbName, + ports, + config, + log, + }); + log(`Patching ${config.envFiles.length} env file(s)...`); copyAndPatchAllEnvFiles(config, mainRoot, worktreePath, { dbName, - redisPort: ports.redis, ports, branchName: actualBranch, }); @@ -179,7 +170,7 @@ export async function createNewWorktree( worktreePath, branchName: actualBranch, dbName, - redisContainerName, + docker, ports, createdAt: new Date().toISOString(), }; @@ -190,6 +181,14 @@ export async function createNewWorktree( warn(`Failed to create worktree for '${branchName}' in slot ${slot}: ${reason}`); warn('Rolling back partial setup...'); + if (dockerStarted) { + try { + removeDockerServices(mainRoot, slot, log); + } catch (rollbackErr) { + warn(`Rollback failed to remove Docker services for slot ${slot}: ${extractErrorMessage(rollbackErr)}`); + } + } + if (databaseCreated) { try { await dropDatabase( @@ -204,14 +203,6 @@ export async function createNewWorktree( } } - if (redisContainerCreated) { - try { - removeManagedRedisContainer(mainRoot, slot, log); - } catch (rollbackErr) { - warn(`Rollback failed to remove Redis container for slot ${slot}: ${extractErrorMessage(rollbackErr)}`); - } - } - if (worktreeCreated && worktreePath! && fs.existsSync(worktreePath!)) { try { removeWorktree(worktreePath!, (command) => log(`Rollback: ${command}`)); diff --git a/src/commands/prune.spec.ts b/src/commands/prune.spec.ts index 3dc403e..50cfb1a 100644 --- a/src/commands/prune.spec.ts +++ b/src/commands/prune.spec.ts @@ -20,10 +20,10 @@ jest.mock('../core/git', () => ({ pruneWorktrees: jest.fn(), })); -jest.mock('../core/managed-redis', () => ({ - removeManagedRedisContainer: jest.fn(), - listManagedRedisContainersForRepo: jest.fn(), - usesManagedRedis: jest.fn(), +jest.mock('../core/docker-services', () => ({ + removeDockerServices: jest.fn(), + listManagedDockerProjectsForRepo: jest.fn(), + usesDockerServices: jest.fn(), })); jest.mock('./setup', () => ({ @@ -34,10 +34,10 @@ import { readRegistry, writeRegistry, removeAllocation, findByPath } from '../co import { dropDatabase } from '../core/database'; import { getMainWorktreePath, listPrunableWorktrees, pruneWorktrees } from '../core/git'; import { - removeManagedRedisContainer, - listManagedRedisContainersForRepo, - usesManagedRedis, -} from '../core/managed-redis'; + removeDockerServices, + listManagedDockerProjectsForRepo, + usesDockerServices, +} from '../core/docker-services'; import { loadConfig } from './setup'; import { pruneCommand } from './prune'; import type { Allocation, Registry, WtConfig } from '../types'; @@ -50,12 +50,12 @@ const mockDropDatabase = dropDatabase as jest.MockedFunction; const mockListPrunableWorktrees = listPrunableWorktrees as jest.MockedFunction; const mockPruneWorktrees = pruneWorktrees as jest.MockedFunction; -const mockRemoveManagedRedisContainer = removeManagedRedisContainer as jest.MockedFunction< - typeof removeManagedRedisContainer +const mockRemoveDockerServices = removeDockerServices as jest.MockedFunction< + typeof removeDockerServices >; -const mockListManagedRedisContainersForRepo = - listManagedRedisContainersForRepo as jest.MockedFunction; -const mockUsesManagedRedis = usesManagedRedis as jest.MockedFunction; +const mockListManagedDockerProjectsForRepo = + listManagedDockerProjectsForRepo as jest.MockedFunction; +const mockUsesDockerServices = usesDockerServices as jest.MockedFunction; const mockLoadConfig = loadConfig as jest.MockedFunction; describe('pruneCommand', () => { @@ -67,7 +67,10 @@ describe('pruneCommand', () => { worktreePath: '/repo/.worktrees/feat-auth', branchName: 'feat/auth', dbName: 'myapp_wt2', - redisContainerName: 'wt-myapp-deadbeef-slot-2-redis', + docker: { + projectName: 'wt-myapp-deadbeef-slot-2', + services: ['redis'], + }, ports: { web: 3200, redis: 6579 }, createdAt: '2026-03-08T00:00:00.000Z', }; @@ -81,6 +84,17 @@ describe('pruneCommand', () => { { name: 'web', defaultPort: 3000 }, { name: 'redis', defaultPort: 6379 }, ], + dockerServices: [ + { + name: 'redis', + image: 'redis:8-alpine', + restart: 'unless-stopped', + ports: [{ service: 'redis', target: 6379, host: '127.0.0.1' }], + environment: {}, + volumes: [], + extraHosts: [], + }, + ], envFiles: [], postSetup: [], autoInstall: true, @@ -113,9 +127,9 @@ describe('pruneCommand', () => { ...registry, allocations: {}, })); - mockRemoveManagedRedisContainer.mockReturnValue(true); - mockListManagedRedisContainersForRepo.mockReturnValue([]); - mockUsesManagedRedis.mockReturnValue(true); + mockRemoveDockerServices.mockReturnValue(true); + mockListManagedDockerProjectsForRepo.mockReturnValue([]); + mockUsesDockerServices.mockReturnValue(true); mockLoadConfig.mockReturnValue(config); mockListPrunableWorktrees.mockReturnValue([]); process.exitCode = 0; @@ -146,7 +160,7 @@ describe('pruneCommand', () => { prunableCount: number; managed: Array<{ slot: number }>; unmanaged: Array<{ worktreePath: string }>; - orphanContainers: Array<{ containerName: string }>; + orphanDockerProjects: Array<{ projectName: string }>; }; }; expect(output.success).toBe(true); @@ -156,7 +170,7 @@ describe('pruneCommand', () => { expect(output.data.unmanaged).toEqual([ { worktreePath: '/repo/.worktrees/unmanaged', reason: 'gitdir file points to non-existent location' }, ]); - expect(output.data.orphanContainers).toEqual([]); + expect(output.data.orphanDockerProjects).toEqual([]); }); it('drops managed resources and prunes Git metadata', async () => { @@ -174,7 +188,7 @@ describe('pruneCommand', () => { 'myapp', expect.any(Function), ); - expect(mockRemoveManagedRedisContainer).toHaveBeenCalledWith(tmpDir, 2, expect.any(Function)); + expect(mockRemoveDockerServices).toHaveBeenCalledWith(tmpDir, 2, expect.any(Function)); expect(mockRemoveAllocation).toHaveBeenCalled(); expect(mockWriteRegistry).toHaveBeenCalledWith(tmpDir, { version: 1, allocations: {} }); expect(mockPruneWorktrees).toHaveBeenCalledWith(expect.any(Function)); @@ -182,20 +196,20 @@ describe('pruneCommand', () => { const output = JSON.parse(consoleLogSpy.mock.calls[0]?.[0] ?? 'null') as { success: boolean; data: { - prunedManaged: Array<{ slot: number; redisContainerRemoved: boolean }>; + prunedManaged: Array<{ slot: number; dockerRemoved: boolean }>; prunedUnmanaged: Array<{ worktreePath: string }>; - prunedOrphanContainers: unknown[]; + prunedOrphanDockerProjects: unknown[]; failed: unknown[]; }; }; expect(output.success).toBe(true); expect(output.data.prunedManaged).toHaveLength(1); expect(output.data.prunedManaged[0]?.slot).toBe(2); - expect(output.data.prunedManaged[0]?.redisContainerRemoved).toBe(true); + expect(output.data.prunedManaged[0]?.dockerRemoved).toBe(true); expect(output.data.prunedUnmanaged).toEqual([ { worktreePath: '/repo/.worktrees/unmanaged', reason: 'gitdir file points to non-existent location' }, ]); - expect(output.data.prunedOrphanContainers).toEqual([]); + expect(output.data.prunedOrphanDockerProjects).toEqual([]); expect(output.data.failed).toEqual([]); }); @@ -213,7 +227,7 @@ describe('pruneCommand', () => { 'myapp', expect.any(Function), ); - expect(mockRemoveManagedRedisContainer).toHaveBeenCalledWith(tmpDir, 2, expect.any(Function)); + expect(mockRemoveDockerServices).toHaveBeenCalledWith(tmpDir, 2, expect.any(Function)); expect(mockRemoveAllocation).toHaveBeenCalled(); expect(mockWriteRegistry).toHaveBeenCalled(); // No Git-prunable entries, so `git worktree prune` must not run. @@ -237,7 +251,7 @@ describe('pruneCommand', () => { await pruneCommand({ json: true, keepDb: false, dryRun: false }); expect(mockDropDatabase).toHaveBeenCalledTimes(1); - expect(mockRemoveManagedRedisContainer).toHaveBeenCalledTimes(1); + expect(mockRemoveDockerServices).toHaveBeenCalledTimes(1); expect(mockRemoveAllocation).toHaveBeenCalledTimes(1); const output = JSON.parse(consoleLogSpy.mock.calls[0]?.[0] ?? 'null') as { @@ -250,19 +264,32 @@ describe('pruneCommand', () => { expect(output.data.prunedManaged[0]?.reasonSource).toBe('git'); }); - it('removes orphan Redis containers whose slot is not in the registry', async () => { - mockListManagedRedisContainersForRepo.mockReturnValue([ + it('removes orphan Docker projects whose slot is not in the registry', async () => { + mockListManagedDockerProjectsForRepo.mockReturnValue([ // slot 2 matches the live registry entry — NOT an orphan. - { containerName: 'wt-myapp-deadbeef-slot-2-redis', slot: 2, branch: 'feat/auth' }, + { + projectName: 'wt-myapp-deadbeef-slot-2', + slot: 2, + branch: 'feat/auth', + services: ['redis'], + containerNames: ['wt-myapp-deadbeef-slot-2-redis'], + }, // slot 7 has no registry entry — orphan. { - containerName: 'wt-myapp-deadbeef-slot-7-redis', + projectName: 'wt-myapp-deadbeef-slot-7', slot: 7, branch: 'fix/old', worktreePath: '/repo/.worktrees/fix-old', + services: ['redis', 'electric'], + containerNames: ['wt-myapp-deadbeef-slot-7-redis', 'wt-myapp-deadbeef-slot-7-electric'], }, // slot 9 has no registry entry — orphan. - { containerName: 'wt-myapp-deadbeef-slot-9-redis', slot: 9 }, + { + projectName: 'wt-myapp-deadbeef-slot-9', + slot: 9, + services: ['redis'], + containerNames: ['wt-myapp-deadbeef-slot-9-redis'], + }, ]); // Registry entry for slot 2 points to an existing dir so it stays put. const livePath = path.join(tmpDir, 'live-worktree'); @@ -276,48 +303,44 @@ describe('pruneCommand', () => { await pruneCommand({ json: true, keepDb: false, dryRun: false }); - // Registry slot 2 should remain — we must not touch its container. - expect(mockRemoveManagedRedisContainer).not.toHaveBeenCalledWith(tmpDir, 2, expect.any(Function)); - expect(mockRemoveManagedRedisContainer).toHaveBeenCalledWith(tmpDir, 7, expect.any(Function)); - expect(mockRemoveManagedRedisContainer).toHaveBeenCalledWith(tmpDir, 9, expect.any(Function)); + // Registry slot 2 should remain — we must not touch its Docker project. + expect(mockRemoveDockerServices).not.toHaveBeenCalledWith(tmpDir, 2, expect.any(Function)); + expect(mockRemoveDockerServices).toHaveBeenCalledWith(tmpDir, 7, expect.any(Function)); + expect(mockRemoveDockerServices).toHaveBeenCalledWith(tmpDir, 9, expect.any(Function)); expect(mockWriteRegistry).not.toHaveBeenCalled(); const output = JSON.parse(consoleLogSpy.mock.calls[0]?.[0] ?? 'null') as { data: { prunedManaged: unknown[]; - prunedOrphanContainers: Array<{ slot: number; containerName: string; removed: boolean }>; + prunedOrphanDockerProjects: Array<{ slot: number; projectName: string; removed: boolean }>; }; }; expect(output.data.prunedManaged).toEqual([]); - expect(output.data.prunedOrphanContainers).toHaveLength(2); - expect(output.data.prunedOrphanContainers.map((o) => o.slot).sort()).toEqual([7, 9]); - expect(output.data.prunedOrphanContainers.every((o) => o.removed)).toBe(true); + expect(output.data.prunedOrphanDockerProjects).toHaveLength(2); + expect(output.data.prunedOrphanDockerProjects.map((o) => o.slot).sort()).toEqual([7, 9]); + expect(output.data.prunedOrphanDockerProjects.every((o) => o.removed)).toBe(true); }); - it('reports orphan Redis containers in dry-run without touching Docker', async () => { - mockListManagedRedisContainersForRepo.mockReturnValue([ - { containerName: 'wt-myapp-deadbeef-slot-9-redis', slot: 9 }, + it('reports orphan Docker projects in dry-run without touching Docker', async () => { + mockListManagedDockerProjectsForRepo.mockReturnValue([ + { + projectName: 'wt-myapp-deadbeef-slot-9', + slot: 9, + services: ['redis'], + containerNames: ['wt-myapp-deadbeef-slot-9-redis'], + }, ]); mockReadRegistry.mockReturnValue({ version: 1, allocations: {} } satisfies Registry); await pruneCommand({ json: true, keepDb: false, dryRun: true }); - expect(mockRemoveManagedRedisContainer).not.toHaveBeenCalled(); + expect(mockRemoveDockerServices).not.toHaveBeenCalled(); const output = JSON.parse(consoleLogSpy.mock.calls[0]?.[0] ?? 'null') as { - data: { orphanContainers: Array<{ slot: number }> }; + data: { orphanDockerProjects: Array<{ slot: number }> }; }; - expect(output.data.orphanContainers).toHaveLength(1); - expect(output.data.orphanContainers[0]?.slot).toBe(9); - }); - - it('does not scan Docker when managed Redis is disabled for the repo', async () => { - mockUsesManagedRedis.mockReturnValue(false); - mockReadRegistry.mockReturnValue({ version: 1, allocations: {} } satisfies Registry); - - await pruneCommand({ json: true, keepDb: false, dryRun: true }); - - expect(mockListManagedRedisContainersForRepo).not.toHaveBeenCalled(); + expect(output.data.orphanDockerProjects).toHaveLength(1); + expect(output.data.orphanDockerProjects[0]?.slot).toBe(9); }); it('reports nothing-to-prune cleanly when registry is empty and no orphans exist', async () => { @@ -326,7 +349,7 @@ describe('pruneCommand', () => { await pruneCommand({ json: true, keepDb: false, dryRun: false }); expect(mockDropDatabase).not.toHaveBeenCalled(); - expect(mockRemoveManagedRedisContainer).not.toHaveBeenCalled(); + expect(mockRemoveDockerServices).not.toHaveBeenCalled(); expect(mockWriteRegistry).not.toHaveBeenCalled(); const output = JSON.parse(consoleLogSpy.mock.calls[0]?.[0] ?? 'null') as { @@ -334,12 +357,12 @@ describe('pruneCommand', () => { data: { prunedManaged: unknown[]; prunedUnmanaged: unknown[]; - prunedOrphanContainers: unknown[]; + prunedOrphanDockerProjects: unknown[]; failed: unknown[]; }; }; expect(output.success).toBe(true); expect(output.data.prunedManaged).toEqual([]); - expect(output.data.prunedOrphanContainers).toEqual([]); + expect(output.data.prunedOrphanDockerProjects).toEqual([]); }); }); diff --git a/src/commands/prune.ts b/src/commands/prune.ts index 15765a0..a07cf06 100644 --- a/src/commands/prune.ts +++ b/src/commands/prune.ts @@ -8,11 +8,11 @@ import { pruneWorktrees, } from '../core/git'; import { - listManagedRedisContainersForRepo, - removeManagedRedisContainer, - usesManagedRedis, - type ManagedRedisContainerSummary, -} from '../core/managed-redis'; + listManagedDockerProjectsForRepo, + removeDockerServices, + usesDockerServices, + type ManagedDockerProjectSummary, +} from '../core/docker-services'; import { loadConfig } from './setup'; import { extractErrorMessage, formatJson, success, error } from '../output'; import type { Allocation } from '../types'; @@ -37,7 +37,7 @@ interface PrunedManagedWorktree { readonly worktreePath: string; readonly dbName: string; readonly dbDropped: boolean; - readonly redisContainerRemoved: boolean; + readonly dockerRemoved: boolean; readonly reason: string; readonly reasonSource: PrunableReasonSource; } @@ -47,11 +47,13 @@ interface PrunedUnmanagedWorktree { readonly reason: string; } -interface PrunedOrphanContainer { +interface PrunedOrphanDockerProject { readonly slot: number; - readonly containerName: string; + readonly projectName: string; readonly branch?: string; readonly worktreePath?: string; + readonly services: string[]; + readonly containerNames: string[]; readonly removed: boolean; } @@ -78,7 +80,7 @@ function readDatabaseUrl(mainRoot: string): string { * 1. Git-prunable worktrees (disk entry missing but Git still tracks it). * 2. Registry allocations whose worktreePath no longer exists on disk — these * can survive step 1 when Git itself has already been pruned. - * 3. Managed Redis containers labeled for this repo whose slot is no longer + * 3. Managed Docker projects labeled for this repo whose slot is no longer * in the registry — leftovers from partially-failed `wt new` runs or from * worktrees cleaned up by something other than `wt remove`. */ @@ -132,19 +134,18 @@ export async function pruneCommand(options: PruneOptions): Promise { const activeSlots = new Set( Object.keys(registry.allocations).map((slotStr) => Number(slotStr)), ); - const orphanContainers: ManagedRedisContainerSummary[] = usesManagedRedis(config) - ? listManagedRedisContainersForRepo(mainRoot).filter((c) => !activeSlots.has(c.slot)) - : []; + const orphanDockerProjects: ManagedDockerProjectSummary[] = listManagedDockerProjectsForRepo(mainRoot) + .filter((project) => !activeSlots.has(project.slot)); const nothingToDo = - managed.length === 0 && unmanaged.length === 0 && orphanContainers.length === 0; + managed.length === 0 && unmanaged.length === 0 && orphanDockerProjects.length === 0; if (options.dryRun) { const payload = { prunableCount: gitPrunable.length, managed, unmanaged, - orphanContainers, + orphanDockerProjects, }; if (options.json) { console.log(formatJson(success(payload))); @@ -154,28 +155,31 @@ export async function pruneCommand(options: PruneOptions): Promise { console.log('Nothing to prune.'); return; } - const totalActions = managed.length + unmanaged.length + orphanContainers.length; + const totalActions = managed.length + unmanaged.length + orphanDockerProjects.length; console.log(`Would prune ${totalActions} item${totalActions === 1 ? '' : 's'}:`); for (const item of managed) { console.log(` Slot ${item.slot}: ${item.allocation.worktreePath}`); console.log(` Reason: ${item.reason}`); console.log(` Database: ${item.allocation.dbName}${options.keepDb ? ' (kept)' : ' (dropped)'}`); - if (item.allocation.redisContainerName) { - console.log(` Redis: ${item.allocation.redisContainerName} (removed)`); + if (item.allocation.docker) { + console.log(` Docker: ${item.allocation.docker.projectName} (removed)`); } } for (const item of unmanaged) { console.log(` Unmanaged: ${item.worktreePath}`); console.log(` Reason: ${item.reason}`); } - for (const orphan of orphanContainers) { - console.log(` Orphan Redis: ${orphan.containerName} (slot ${orphan.slot})`); + for (const orphan of orphanDockerProjects) { + console.log(` Orphan Docker: ${orphan.projectName} (slot ${orphan.slot})`); if (orphan.branch) { console.log(` Branch: ${orphan.branch}`); } if (orphan.worktreePath) { console.log(` Worktree: ${orphan.worktreePath}`); } + if (orphan.services.length > 0) { + console.log(` Services: ${orphan.services.join(', ')}`); + } } return; } @@ -184,7 +188,7 @@ export async function pruneCommand(options: PruneOptions): Promise { const empty = { prunedManaged: [], prunedUnmanaged: [], - prunedOrphanContainers: [], + prunedOrphanDockerProjects: [], failed: [], }; if (options.json) { @@ -203,7 +207,7 @@ export async function pruneCommand(options: PruneOptions): Promise { }; const prunedManaged: PrunedManagedWorktree[] = []; - const prunedOrphans: PrunedOrphanContainer[] = []; + const prunedOrphans: PrunedOrphanDockerProject[] = []; const failed: PruneFailure[] = []; for (const item of managed) { @@ -219,8 +223,8 @@ export async function pruneCommand(options: PruneOptions): Promise { log(`Skipping database drop for '${item.allocation.dbName}' (${options.keepDb ? '--keep-db' : 'no config'}).`); } - const redisContainerRemoved = item.allocation.redisContainerName !== undefined - ? removeManagedRedisContainer(mainRoot, item.slot, log) + const dockerRemoved = item.allocation.docker !== undefined || usesDockerServices(config) + ? removeDockerServices(mainRoot, item.slot, log) : false; registry = removeAllocation(registry, item.slot); @@ -229,7 +233,7 @@ export async function pruneCommand(options: PruneOptions): Promise { worktreePath: item.allocation.worktreePath, dbName: item.allocation.dbName, dbDropped: dbContext !== null, - redisContainerRemoved, + dockerRemoved, reason: item.reason, reasonSource: item.reasonSource, }); @@ -241,19 +245,21 @@ export async function pruneCommand(options: PruneOptions): Promise { } } - for (const orphan of orphanContainers) { + for (const orphan of orphanDockerProjects) { try { - const removed = removeManagedRedisContainer(mainRoot, orphan.slot, log); + const removed = removeDockerServices(mainRoot, orphan.slot, log); prunedOrphans.push({ slot: orphan.slot, - containerName: orphan.containerName, + projectName: orphan.projectName, branch: orphan.branch, worktreePath: orphan.worktreePath, + services: orphan.services, + containerNames: orphan.containerNames, removed, }); } catch (err) { failed.push({ - worktreePath: orphan.containerName, + worktreePath: orphan.projectName, message: extractErrorMessage(err), }); } @@ -270,7 +276,7 @@ export async function pruneCommand(options: PruneOptions): Promise { const payload = { prunedManaged, prunedUnmanaged: unmanaged, - prunedOrphanContainers: prunedOrphans, + prunedOrphanDockerProjects: prunedOrphans, failed, }; @@ -301,20 +307,23 @@ export async function pruneCommand(options: PruneOptions): Promise { console.log(` Slot ${item.slot}: ${item.worktreePath}`); console.log(` Reason: ${item.reason}`); console.log(` Database: ${item.dbName} ${item.dbDropped ? '(dropped)' : '(kept)'}`); - console.log(` Redis: ${item.redisContainerRemoved ? '(removed)' : '(not found)'}`); + console.log(` Docker: ${item.dockerRemoved ? '(removed)' : '(not found)'}`); } for (const item of unmanaged) { console.log(` Unmanaged: ${item.worktreePath}`); console.log(` Reason: ${item.reason}`); } for (const orphan of prunedOrphans) { - console.log(` Orphan Redis: ${orphan.containerName} (slot ${orphan.slot}) ${orphan.removed ? '(removed)' : '(not found)'}`); + console.log(` Orphan Docker: ${orphan.projectName} (slot ${orphan.slot}) ${orphan.removed ? '(removed)' : '(not found)'}`); if (orphan.branch) { console.log(` Branch: ${orphan.branch}`); } if (orphan.worktreePath) { console.log(` Worktree: ${orphan.worktreePath}`); } + if (orphan.services.length > 0) { + console.log(` Services: ${orphan.services.join(', ')}`); + } } if (failed.length > 0) { console.error(`Failed to clean up ${failed.length} target${failed.length === 1 ? '' : 's'}:`); diff --git a/src/commands/remove.ts b/src/commands/remove.ts index d5ff734..8cfb660 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -2,7 +2,7 @@ import * as path from 'node:path'; import * as fs from 'node:fs'; import { readRegistry, writeRegistry, removeAllocation, findByPath } from '../core/registry'; import { dropDatabase } from '../core/database'; -import { removeManagedRedisContainer, usesManagedRedis } from '../core/managed-redis'; +import { removeDockerServices, usesDockerServices } from '../core/docker-services'; import { getMainWorktreePath, removeWorktree, @@ -35,7 +35,7 @@ interface ResolvedTarget { readonly slot: number; readonly worktreePath: string; readonly dbName: string; - readonly redisContainerName?: string; + readonly dockerProjectName?: string; } interface ResolvedTargetError { @@ -48,7 +48,7 @@ interface RemoveSuccess { readonly worktreePath: string; readonly dbName: string; readonly dbDropped: boolean; - readonly redisContainerRemoved: boolean; + readonly dockerRemoved: boolean; } interface RemoveFailure { @@ -93,7 +93,7 @@ function resolveTarget( slot, worktreePath: allocation.worktreePath, dbName: allocation.dbName, - redisContainerName: allocation.redisContainerName, + dockerProjectName: allocation.docker?.projectName, }; } @@ -106,7 +106,7 @@ function resolveTarget( slot: found[0], worktreePath: found[1].worktreePath, dbName: found[1].dbName, - redisContainerName: found[1].redisContainerName, + dockerProjectName: found[1].docker?.projectName, }; } @@ -163,7 +163,7 @@ export async function removeCommand( } const config = loadConfig(mainRoot); - const managedRedisEnabled = usesManagedRedis(config); + const dockerServicesEnabled = usesDockerServices(config); const dbContext = options.keepDb ? null : { @@ -229,8 +229,8 @@ export async function removeCommand( log(`Skipping database drop for '${resolved.dbName}' (--keep-db).`); } - const redisContainerRemoved = managedRedisEnabled || resolved.redisContainerName !== undefined - ? removeManagedRedisContainer(mainRoot, resolved.slot, log) + const dockerRemoved = dockerServicesEnabled || resolved.dockerProjectName !== undefined + ? removeDockerServices(mainRoot, resolved.slot, log) : false; if (fs.existsSync(resolved.worktreePath)) { @@ -249,7 +249,7 @@ export async function removeCommand( worktreePath: resolved.worktreePath, dbName: resolved.dbName, dbDropped: !options.keepDb, - redisContainerRemoved, + dockerRemoved, }); } catch (err) { failed.push({ target, message: extractErrorMessage(err) }); @@ -282,7 +282,7 @@ export async function removeCommand( for (const item of removed) { console.log(` Slot ${item.slot}: ${item.worktreePath}`); console.log(` Database: ${item.dbName} ${item.dbDropped ? '(dropped)' : '(kept)'}`); - console.log(` Redis: ${item.redisContainerRemoved ? '(removed)' : '(not found)'}`); + console.log(` Docker: ${item.dockerRemoved ? '(removed)' : '(not found)'}`); } } if (failed.length > 0) { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 84eb2b1..16d4c88 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -13,11 +13,8 @@ import { import { copyAndPatchAllEnvFiles } from '../core/env-patcher'; import { createDatabase, databaseExists } from '../core/database'; import { - ensureManagedRedisContainer, - getAllocationServices, - readManagedRedisSourceUrl, - usesManagedRedis, -} from '../core/managed-redis'; + ensureDockerServices, +} from '../core/docker-services'; import { getMainWorktreePath, isMainWorktree, getBranchName } from '../core/git'; import { extractErrorMessage, formatJson, formatSetupSummary, success, error } from '../output'; import type { Allocation, WtConfig } from '../types'; @@ -27,127 +24,9 @@ interface SetupOptions { readonly install: boolean; } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function readPatchedEnvVar( - mainRoot: string, - source: string, - varName: string, -): string | null { - const envPath = path.join(mainRoot, source); - if (!fs.existsSync(envPath)) { - return null; - } - - const content = fs.readFileSync(envPath, 'utf-8'); - const match = content.match(new RegExp(`^${varName}=["']?([^"'\\n]+)`, 'm')); - return match?.[1] ?? null; -} - -function inferLegacyRedisDefaultPort( - mainRoot: string, - envFiles: readonly unknown[], -): number { - for (const envFile of envFiles) { - if (!isRecord(envFile) || typeof envFile.source !== 'string' || !Array.isArray(envFile.patches)) { - continue; - } - - for (const patch of envFile.patches) { - if (!isRecord(patch) || patch.type !== 'redis' || typeof patch.var !== 'string') { - continue; - } - - const sourceUrl = readPatchedEnvVar(mainRoot, envFile.source, patch.var); - if (!sourceUrl) { - continue; - } - - try { - const parsed = new URL(sourceUrl); - return Number(parsed.port) || 6379; - } catch { - return 6379; - } - } - } - - return 6379; -} - -function migrateLegacyConfig( - mainRoot: string, - raw: unknown, -): { readonly config: unknown; readonly migrated: boolean } { - if (!isRecord(raw)) { - return { config: raw, migrated: false }; - } - - const next = JSON.parse(JSON.stringify(raw)) as Record; - const envFiles = Array.isArray(next.envFiles) ? next.envFiles : []; - const services = Array.isArray(next.services) ? [...next.services] : []; - const redisServiceNames = new Set(); - let migrated = false; - - for (const envFile of envFiles) { - if (!isRecord(envFile) || !Array.isArray(envFile.patches)) { - continue; - } - - for (const patch of envFile.patches) { - if (!isRecord(patch) || patch.type !== 'redis') { - continue; - } - - const serviceName = typeof patch.service === 'string' && patch.service.length > 0 - ? patch.service - : 'redis'; - - if (patch.service !== serviceName) { - patch.service = serviceName; - migrated = true; - } - - redisServiceNames.add(serviceName); - } - } - - if (redisServiceNames.size > 0) { - const declaredServiceNames = new Set( - services - .filter(isRecord) - .map((service) => (typeof service.name === 'string' ? service.name : null)) - .filter((name): name is string => name !== null), - ); - const inferredRedisPort = inferLegacyRedisDefaultPort(mainRoot, envFiles); - - for (const serviceName of redisServiceNames) { - if (declaredServiceNames.has(serviceName)) { - continue; - } - - services.push({ - name: serviceName, - defaultPort: inferredRedisPort, - }); - declaredServiceNames.add(serviceName); - migrated = true; - } - } - - if (migrated) { - next.services = services; - } - - return { config: next, migrated }; -} - function validateConfig(config: WtConfig): WtConfig { - const services = getAllocationServices(config); const seenServiceNames = new Set(); - for (const service of services) { + for (const service of config.services) { if (seenServiceNames.has(service.name)) { throw new Error(`Duplicate service name in wt.config.json: ${service.name}`); } @@ -161,16 +40,26 @@ function validateConfig(config: WtConfig): WtConfig { `Patch '${patch.var}' references unknown service '${patch.service}'.`, ); } + } + } - if (patch.type === 'redis' && patch.service !== 'redis') { + const seenDockerServiceNames = new Set(); + for (const dockerService of config.dockerServices) { + if (seenDockerServiceNames.has(dockerService.name)) { + throw new Error(`Duplicate docker service name in wt.config.json: ${dockerService.name}`); + } + seenDockerServiceNames.add(dockerService.name); + + for (const port of dockerService.ports) { + if (!seenServiceNames.has(port.service)) { throw new Error( - `Redis patch '${patch.var}' must use service name 'redis', got '${patch.service}'.`, + `Docker service '${dockerService.name}' references unknown port service '${port.service}'.`, ); } } } - validatePortPlan(services, config.maxSlots, config.portStride); + validatePortPlan(config.services, config.maxSlots, config.portStride); return config; } @@ -178,13 +67,7 @@ function validateConfig(config: WtConfig): WtConfig { export function loadConfig(mainRoot: string): WtConfig { const configPath = path.join(mainRoot, 'wt.config.json'); const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as unknown; - const { config, migrated } = migrateLegacyConfig(mainRoot, raw); - - if (migrated) { - fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); - } - - return validateConfig(configSchema.parse(config)); + return validateConfig(configSchema.parse(raw)); } /** @@ -201,7 +84,7 @@ function readDatabaseUrl(mainRoot: string): string { return match[1]; } -/** Set up an existing worktree with isolated DB, Redis, ports, and env files */ +/** Set up an existing worktree with isolated DB, Docker services, ports, and env files */ export async function setupCommand( targetPath: string | undefined, options: SetupOptions, @@ -222,7 +105,6 @@ export async function setupCommand( } const config = loadConfig(mainRoot); - const services = getAllocationServices(config); let registry = readRegistry(mainRoot); // Reuse existing allocation or allocate a new slot @@ -234,7 +116,7 @@ export async function setupCommand( const available = await findAvailablePortSafeSlot( registry, config.maxSlots, - services, + config.services, config.portStride, ); if (available === null) { @@ -253,7 +135,7 @@ export async function setupCommand( } const dbName = calculateDbName(slot, config.baseDatabaseName); - const ports = calculatePorts(slot, services, config.portStride); + const ports = calculatePorts(slot, config.services, config.portStride); if (!existing) { const unavailablePorts = await findUnavailableServicePorts(ports); if (unavailablePorts.length > 0) { @@ -264,19 +146,6 @@ export async function setupCommand( } } const branchName = getBranchName(worktreePath); - const redisSourceUrl = usesManagedRedis(config) - ? readManagedRedisSourceUrl(mainRoot, config) - : null; - const redisContainerName = usesManagedRedis(config) - ? ensureManagedRedisContainer({ - mainRoot, - slot, - branchName, - worktreePath, - port: ports.redis!, - sourceUrl: redisSourceUrl, - }) - : undefined; // Create database if it doesn't exist const databaseUrl = readDatabaseUrl(mainRoot); @@ -285,10 +154,19 @@ export async function setupCommand( await createDatabase(databaseUrl, config.baseDatabaseName, dbName); } + const docker = ensureDockerServices({ + mainRoot, + slot, + branchName, + worktreePath, + dbName, + ports, + config, + }); + // Copy and patch env files copyAndPatchAllEnvFiles(config, mainRoot, worktreePath, { dbName, - redisPort: ports.redis, ports, branchName, }); @@ -298,7 +176,7 @@ export async function setupCommand( worktreePath, branchName, dbName, - redisContainerName, + docker, ports, createdAt: new Date().toISOString(), }; diff --git a/src/core/docker-services.ts b/src/core/docker-services.ts new file mode 100644 index 0000000..fb3151c --- /dev/null +++ b/src/core/docker-services.ts @@ -0,0 +1,396 @@ +import * as crypto from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { DockerServiceConfig, WtConfig } from '../types'; + +const DOCKER_LABEL_PREFIX = 'dev.tokenbooks.wt'; + +export interface EnsureDockerServicesOptions { + readonly mainRoot: string; + readonly slot: number; + readonly branchName: string; + readonly worktreePath: string; + readonly dbName: string; + readonly ports: Record; + readonly config: WtConfig; + readonly log?: (message: string) => void; +} + +export interface DockerServicesAllocation { + readonly projectName: string; + readonly services: string[]; +} + +export interface ManagedDockerProjectSummary { + readonly projectName: string; + readonly slot: number; + readonly branch?: string; + readonly worktreePath?: string; + readonly services: string[]; + readonly containerNames: string[]; +} + +interface DockerInspectRecord { + readonly Config?: { + readonly Labels?: Record; + }; +} + +export interface DockerComposeService { + readonly image: string; + readonly container_name: string; + readonly restart: string; + readonly labels: string[]; + readonly ports?: string[]; + readonly environment?: Record; + readonly command?: string | string[]; + readonly volumes?: string[]; + readonly extra_hosts?: string[]; +} + +export interface DockerComposeConfig { + readonly services: Record; +} + +interface DockerRenderContext { + readonly mainRoot: string; + readonly slot: number; + readonly branchName: string; + readonly worktreePath: string; + readonly dbName: string; + readonly ports: Record; + readonly projectName: string; +} + +function slugify(input: string): string { + return input + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 32) || 'repo'; +} + +function repoHash(mainRoot: string): string { + return crypto + .createHash('sha1') + .update(mainRoot) + .digest('hex') + .slice(0, 8); +} + +function runDocker(args: readonly string[]): string { + try { + return execFileSync('docker', args, { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + } catch (err) { + if (err instanceof Error && 'code' in err && err.code === 'ENOENT') { + throw new Error('Docker CLI not found on PATH.'); + } + const stderr = err && typeof err === 'object' && 'stderr' in err + ? String((err as { stderr?: Buffer | string }).stderr ?? '').trim() + : ''; + throw new Error(stderr || `Docker command failed: docker ${args.join(' ')}`); + } +} + +function inspectDockerContainer(containerName: string): DockerInspectRecord | null { + try { + const raw = runDocker(['container', 'inspect', containerName]); + const parsed = JSON.parse(raw) as DockerInspectRecord[]; + return parsed[0] ?? null; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes('No such container')) { + return null; + } + throw err; + } +} + +function renderTemplate(value: string, context: DockerRenderContext): string { + return value.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key: string) => { + const values: Record = { + mainRoot: context.mainRoot, + slot: String(context.slot), + branchName: context.branchName, + worktreePath: context.worktreePath, + dbName: context.dbName, + projectName: context.projectName, + }; + + for (const [serviceName, port] of Object.entries(context.ports)) { + values[`ports.${serviceName}`] = String(port); + values[`services.${serviceName}.port`] = String(port); + } + + return values[key] ?? match; + }); +} + +function renderTemplateArray(values: readonly string[], context: DockerRenderContext): string[] { + return values.map((value) => renderTemplate(value, context)); +} + +function renderCommand( + command: DockerServiceConfig['command'], + context: DockerRenderContext, +): string | string[] | undefined { + if (command === undefined) { + return undefined; + } + if (Array.isArray(command)) { + return renderTemplateArray(command, context); + } + return renderTemplate(command, context); +} + +function renderEnvironment( + environment: Record, + context: DockerRenderContext, +): Record | undefined { + const entries = Object.entries(environment); + if (entries.length === 0) { + return undefined; + } + return Object.fromEntries( + entries.map(([key, value]) => [key, renderTemplate(value, context)]), + ); +} + +function renderPorts( + service: DockerServiceConfig, + context: DockerRenderContext, +): string[] | undefined { + if (service.ports.length === 0) { + return undefined; + } + + return service.ports.map((port) => { + const allocatedPort = context.ports[port.service]; + if (allocatedPort === undefined) { + throw new Error( + `Docker service '${service.name}' references unknown port service '${port.service}'.`, + ); + } + return `${port.host}:${allocatedPort}:${port.target}`; + }); +} + +function composePath(projectName: string): string { + const dir = path.join(os.tmpdir(), 'wt-docker-compose', projectName); + fs.mkdirSync(dir, { recursive: true }); + return path.join(dir, 'compose.json'); +} + +function writeComposeFile(projectName: string, compose: DockerComposeConfig): string { + const filePath = composePath(projectName); + fs.writeFileSync(filePath, JSON.stringify(compose, null, 2) + '\n', 'utf-8'); + return filePath; +} + +export function usesDockerServices(config: WtConfig): boolean { + return config.dockerServices.length > 0; +} + +export function getDockerProjectName(mainRoot: string, slot: number): string { + const repoName = slugify(path.basename(mainRoot)); + return `wt-${repoName}-${repoHash(mainRoot)}-slot-${slot}`; +} + +export function buildDockerComposeConfig(options: EnsureDockerServicesOptions): DockerComposeConfig { + const projectName = getDockerProjectName(options.mainRoot, options.slot); + const context: DockerRenderContext = { + mainRoot: options.mainRoot, + slot: options.slot, + branchName: options.branchName, + worktreePath: options.worktreePath, + dbName: options.dbName, + ports: options.ports, + projectName, + }; + + const services: Record = {}; + for (const service of options.config.dockerServices) { + const labels = [ + `${DOCKER_LABEL_PREFIX}.managed=true`, + `${DOCKER_LABEL_PREFIX}.repo-root=${options.mainRoot}`, + `${DOCKER_LABEL_PREFIX}.project=${projectName}`, + `${DOCKER_LABEL_PREFIX}.service=${service.name}`, + `${DOCKER_LABEL_PREFIX}.slot=${options.slot}`, + `${DOCKER_LABEL_PREFIX}.branch=${options.branchName}`, + `${DOCKER_LABEL_PREFIX}.worktree=${options.worktreePath}`, + ]; + const composeService: DockerComposeService = { + image: renderTemplate(service.image, context), + container_name: `${projectName}-${service.name}`, + restart: service.restart, + labels, + }; + const ports = renderPorts(service, context); + if (ports) { + Object.assign(composeService, { ports }); + } + const environment = renderEnvironment(service.environment, context); + if (environment) { + Object.assign(composeService, { environment }); + } + const command = renderCommand(service.command, context); + if (command !== undefined) { + Object.assign(composeService, { command }); + } + if (service.volumes.length > 0) { + Object.assign(composeService, { + volumes: renderTemplateArray(service.volumes, context), + }); + } + if (service.extraHosts.length > 0) { + Object.assign(composeService, { + extra_hosts: renderTemplateArray(service.extraHosts, context), + }); + } + services[service.name] = composeService; + } + + return { services }; +} + +export function ensureDockerServices( + options: EnsureDockerServicesOptions, +): DockerServicesAllocation | undefined { + if (!usesDockerServices(options.config)) { + return undefined; + } + + const projectName = getDockerProjectName(options.mainRoot, options.slot); + const compose = buildDockerComposeConfig(options); + const filePath = writeComposeFile(projectName, compose); + + runDocker(['compose', '-f', filePath, '-p', projectName, 'up', '-d', '--remove-orphans']); + const services = options.config.dockerServices.map((service) => service.name); + options.log?.(`Started Docker project '${projectName}' (${services.join(', ')}).`); + return { projectName, services }; +} + +function listDockerResourceIds(args: readonly string[]): string[] { + const output = runDocker(args); + if (!output) { + return []; + } + return output.split('\n').map((line) => line.trim()).filter((line) => line.length > 0); +} + +export function removeDockerServices( + mainRoot: string, + slot: number, + log?: (message: string) => void, +): boolean { + const projectName = getDockerProjectName(mainRoot, slot); + const containerIds = listDockerResourceIds([ + 'ps', + '-a', + '-q', + '--filter', + `label=${DOCKER_LABEL_PREFIX}.repo-root=${mainRoot}`, + '--filter', + `label=${DOCKER_LABEL_PREFIX}.slot=${slot}`, + '--filter', + `label=${DOCKER_LABEL_PREFIX}.managed=true`, + ]); + + if (containerIds.length > 0) { + runDocker(['rm', '-f', ...containerIds]); + } + + const networkIds = listDockerResourceIds([ + 'network', + 'ls', + '-q', + '--filter', + `label=com.docker.compose.project=${projectName}`, + ]); + if (networkIds.length > 0) { + runDocker(['network', 'rm', ...networkIds]); + } + + const removed = containerIds.length > 0 || networkIds.length > 0; + if (removed) { + log?.(`Removed Docker project '${projectName}'.`); + } else { + log?.(`Skipping Docker cleanup; no resources found for project '${projectName}'.`); + } + return removed; +} + +export function listManagedDockerProjectsForRepo(mainRoot: string): ManagedDockerProjectSummary[] { + let names: string[]; + try { + names = listDockerResourceIds([ + 'ps', + '-a', + '--filter', + `label=${DOCKER_LABEL_PREFIX}.repo-root=${mainRoot}`, + '--filter', + `label=${DOCKER_LABEL_PREFIX}.managed=true`, + '--format', + '{{.Names}}', + ]); + } catch (err) { + if (err instanceof Error && /Docker CLI not found|Cannot connect to the Docker daemon/i.test(err.message)) { + return []; + } + throw err; + } + + const projects = new Map; + containerNames: string[]; + }>(); + + for (const name of names) { + const inspect = inspectDockerContainer(name); + if (!inspect) { + continue; + } + const labels = inspect.Config?.Labels ?? {}; + const slotLabel = labels[`${DOCKER_LABEL_PREFIX}.slot`]; + if (!slotLabel) { + continue; + } + const slot = Number.parseInt(slotLabel, 10); + if (!Number.isSafeInteger(slot)) { + continue; + } + const projectName = labels[`${DOCKER_LABEL_PREFIX}.project`] ?? getDockerProjectName(mainRoot, slot); + const existing = projects.get(projectName) ?? { + slot, + branch: labels[`${DOCKER_LABEL_PREFIX}.branch`], + worktreePath: labels[`${DOCKER_LABEL_PREFIX}.worktree`], + services: new Set(), + containerNames: [], + }; + const serviceName = labels[`${DOCKER_LABEL_PREFIX}.service`]; + if (serviceName) { + existing.services.add(serviceName); + } + existing.containerNames.push(name); + projects.set(projectName, existing); + } + + return Array.from(projects.entries()) + .map(([projectName, project]) => ({ + projectName, + slot: project.slot, + branch: project.branch, + worktreePath: project.worktreePath, + services: Array.from(project.services).sort(), + containerNames: project.containerNames.sort(), + })) + .sort((a, b) => a.slot - b.slot || a.projectName.localeCompare(b.projectName)); +} diff --git a/src/core/env-patcher.ts b/src/core/env-patcher.ts index 76cdc64..6175c0b 100644 --- a/src/core/env-patcher.ts +++ b/src/core/env-patcher.ts @@ -1,7 +1,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import type { PatchConfig, PatchContext, WtConfig } from '../types'; -import { patchManagedRedisUrl } from './managed-redis'; type PortPatch = Extract; type UrlPatch = Extract; @@ -19,8 +18,6 @@ function applyPatch( switch (patch.type) { case 'database': return patchDatabaseUrl(value, context.dbName); - case 'redis': - return patchRedisUrl(value, context); case 'port': return patchPort(patch, context); case 'url': @@ -41,17 +38,6 @@ function patchDatabaseUrl(url: string, dbName: string): string { ); } -/** - * Rewrite a Redis URL to point at the managed per-worktree Redis container. - * The managed Redis always runs locally on DB 0. - */ -function patchRedisUrl(url: string, context: PatchContext): string { - if (context.redisPort === undefined) { - throw new Error('Redis patch requires an allocated redis service port.'); - } - return patchManagedRedisUrl(url, context.redisPort); -} - /** Replace port value entirely with the allocated port for the service */ function patchPort(patch: PortPatch, context: PatchContext): string { const serviceName = patch.service; diff --git a/src/core/managed-redis.ts b/src/core/managed-redis.ts deleted file mode 100644 index 06b4313..0000000 --- a/src/core/managed-redis.ts +++ /dev/null @@ -1,350 +0,0 @@ -import * as crypto from 'node:crypto'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { execFileSync } from 'node:child_process'; -import type { ServiceConfig, WtConfig } from '../types'; - -const MANAGED_REDIS_SERVICE_NAME = 'redis'; -const DEFAULT_REDIS_PORT = 6379; -const DEFAULT_REDIS_IMAGE = 'redis:8-alpine'; -const DOCKER_LABEL_PREFIX = 'dev.tokenbooks.wt'; - -interface EnsureManagedRedisContainerOptions { - readonly mainRoot: string; - readonly slot: number; - readonly branchName: string; - readonly worktreePath: string; - readonly port: number; - readonly sourceUrl: string | null; - readonly log?: (message: string) => void; -} - -interface DockerInspectPortBinding { - readonly HostIp?: string; - readonly HostPort?: string; -} - -interface DockerInspectRecord { - readonly Config?: { - readonly Image?: string; - readonly Labels?: Record; - }; - readonly NetworkSettings?: { - readonly Ports?: Record; - }; - readonly State?: { - readonly Running?: boolean; - }; -} - -export interface ManagedRedisContainerSummary { - readonly containerName: string; - readonly slot: number; - readonly branch?: string; - readonly worktreePath?: string; -} - -function slugify(input: string): string { - return input - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 32) || 'repo'; -} - -function repoHash(mainRoot: string): string { - return crypto - .createHash('sha1') - .update(mainRoot) - .digest('hex') - .slice(0, 8); -} - -function runDocker(args: readonly string[]): string { - try { - return execFileSync('docker', args, { - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'pipe'], - }).trim(); - } catch (err) { - if (err instanceof Error && 'code' in err && err.code === 'ENOENT') { - throw new Error('Docker CLI not found on PATH.'); - } - const stderr = err && typeof err === 'object' && 'stderr' in err - ? String((err as { stderr?: Buffer | string }).stderr ?? '').trim() - : ''; - throw new Error(stderr || `Docker command failed: docker ${args.join(' ')}`); - } -} - -function inspectManagedRedisContainer(containerName: string): DockerInspectRecord | null { - try { - const raw = runDocker(['container', 'inspect', containerName]); - const parsed = JSON.parse(raw) as DockerInspectRecord[]; - return parsed[0] ?? null; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (message.includes('No such container')) { - return null; - } - throw err; - } -} - -function parseManagedRedisPassword(sourceUrl: string | null): string | undefined { - if (!sourceUrl) { - return undefined; - } - - let parsed: URL; - try { - parsed = new URL(sourceUrl); - } catch { - return undefined; - } - - if (parsed.protocol !== 'redis:') { - throw new Error(`Managed Redis only supports redis:// URLs, got: ${parsed.protocol}//`); - } - if (parsed.username && parsed.username !== 'default') { - throw new Error( - `Managed Redis does not support non-default ACL usernames in REDIS_URL, got: ${parsed.username}`, - ); - } - return parsed.password || undefined; -} - -function passwordHash(password: string | undefined): string { - return crypto - .createHash('sha256') - .update(password ?? '') - .digest('hex'); -} - -export function usesManagedRedis(config: WtConfig): boolean { - return config.envFiles.some((envFile) => envFile.patches.some((patch) => patch.type === 'redis')); -} - -export function getManagedRedisService(config: WtConfig): ServiceConfig | null { - if (!usesManagedRedis(config)) { - return null; - } - - const configured = config.services.find((service) => service.name === MANAGED_REDIS_SERVICE_NAME); - if (configured) { - return configured; - } - - return { - name: MANAGED_REDIS_SERVICE_NAME, - defaultPort: DEFAULT_REDIS_PORT, - }; -} - -export function getAllocationServices(config: WtConfig): readonly ServiceConfig[] { - const redisService = getManagedRedisService(config); - if (!redisService) { - return config.services; - } - if (config.services.some((service) => service.name === MANAGED_REDIS_SERVICE_NAME)) { - return config.services; - } - return [...config.services, redisService]; -} - -export function getManagedRedisContainerName(mainRoot: string, slot: number): string { - const repoName = slugify(path.basename(mainRoot)); - return `wt-${repoName}-${repoHash(mainRoot)}-slot-${slot}-redis`; -} - -export function readManagedRedisSourceUrl( - mainRoot: string, - config: WtConfig, -): string | null { - for (const envFile of config.envFiles) { - const redisVars = envFile.patches - .filter((patch) => patch.type === 'redis') - .map((patch) => patch.var); - if (redisVars.length === 0) { - continue; - } - - const sourcePath = path.join(mainRoot, envFile.source); - if (!fs.existsSync(sourcePath)) { - continue; - } - - const content = fs.readFileSync(sourcePath, 'utf-8'); - for (const redisVar of redisVars) { - const match = content.match(new RegExp(`^${redisVar}=["']?([^"'\\n]+)`, 'm')); - if (match?.[1]) { - return match[1]; - } - } - } - - return null; -} - -export function patchManagedRedisUrl(sourceUrl: string, port: number): string { - const parsed = new URL(sourceUrl); - if (parsed.protocol !== 'redis:') { - throw new Error(`Managed Redis only supports redis:// URLs, got: ${parsed.protocol}//`); - } - - parsed.hostname = '127.0.0.1'; - parsed.port = String(port); - parsed.pathname = '/0'; - parsed.search = ''; - parsed.hash = ''; - return parsed.toString(); -} - -export function ensureManagedRedisContainer( - options: EnsureManagedRedisContainerOptions, -): string { - const containerName = getManagedRedisContainerName(options.mainRoot, options.slot); - const inspect = inspectManagedRedisContainer(containerName); - const password = parseManagedRedisPassword(options.sourceUrl); - const expectedPasswordHash = passwordHash(password); - const expectedPort = String(options.port); - - if (inspect) { - const actualPort = inspect.NetworkSettings?.Ports?.['6379/tcp']?.[0]?.HostPort; - const actualPasswordHash = inspect.Config?.Labels?.[`${DOCKER_LABEL_PREFIX}.redis-password-sha256`]; - const actualRepoRoot = inspect.Config?.Labels?.[`${DOCKER_LABEL_PREFIX}.repo-root`]; - const shouldRecreate = actualPort !== expectedPort - || actualPasswordHash !== expectedPasswordHash - || actualRepoRoot !== options.mainRoot - || inspect.Config?.Image !== DEFAULT_REDIS_IMAGE; - - if (shouldRecreate) { - runDocker(['rm', '-f', containerName]); - } else { - if (!inspect.State?.Running) { - runDocker(['start', containerName]); - options.log?.(`Started Redis container '${containerName}'.`); - } else { - options.log?.(`Reusing Redis container '${containerName}'.`); - } - return containerName; - } - } - - const args = [ - 'run', - '-d', - '--name', - containerName, - '--restart', - 'unless-stopped', - '--label', - `${DOCKER_LABEL_PREFIX}.managed=true`, - '--label', - `${DOCKER_LABEL_PREFIX}.repo-root=${options.mainRoot}`, - '--label', - `${DOCKER_LABEL_PREFIX}.service=redis`, - '--label', - `${DOCKER_LABEL_PREFIX}.purpose=git-worktree-redis`, - '--label', - `${DOCKER_LABEL_PREFIX}.slot=${options.slot}`, - '--label', - `${DOCKER_LABEL_PREFIX}.branch=${options.branchName}`, - '--label', - `${DOCKER_LABEL_PREFIX}.worktree=${options.worktreePath}`, - '--label', - `${DOCKER_LABEL_PREFIX}.redis-password-sha256=${expectedPasswordHash}`, - '-p', - `127.0.0.1:${options.port}:6379`, - DEFAULT_REDIS_IMAGE, - 'redis-server', - '--save', - '', - '--appendonly', - 'no', - '--port', - '6379', - ]; - - if (password) { - args.push('--requirepass', password); - } - - runDocker(args); - options.log?.(`Started Redis container '${containerName}' on port ${options.port}.`); - return containerName; -} - -export function removeManagedRedisContainer( - mainRoot: string, - slot: number, - log?: (message: string) => void, -): boolean { - const containerName = getManagedRedisContainerName(mainRoot, slot); - const inspect = inspectManagedRedisContainer(containerName); - if (!inspect) { - log?.(`Skipping Redis container cleanup; not found: ${containerName}`); - return false; - } - - runDocker(['rm', '-f', containerName]); - log?.(`Removed Redis container '${containerName}'.`); - return true; -} - -/** - * List all wt-managed Redis containers labeled as belonging to the given repo root. - * Returns an empty array if Docker is unavailable or the daemon isn't running — - * prune should tolerate environments without Docker. - */ -export function listManagedRedisContainersForRepo( - mainRoot: string, -): ManagedRedisContainerSummary[] { - let output: string; - try { - output = runDocker([ - 'ps', - '-a', - '--filter', - `label=${DOCKER_LABEL_PREFIX}.repo-root=${mainRoot}`, - '--filter', - `label=${DOCKER_LABEL_PREFIX}.managed=true`, - '--format', - '{{.Names}}', - ]); - } catch (err) { - if (err instanceof Error && /Docker CLI not found|Cannot connect to the Docker daemon/i.test(err.message)) { - return []; - } - throw err; - } - - if (!output) { - return []; - } - - const names = output.split('\n').map((line) => line.trim()).filter((line) => line.length > 0); - const summaries: ManagedRedisContainerSummary[] = []; - for (const name of names) { - const inspect = inspectManagedRedisContainer(name); - if (!inspect) { - continue; - } - const labels = inspect.Config?.Labels ?? {}; - const slotLabel = labels[`${DOCKER_LABEL_PREFIX}.slot`]; - if (!slotLabel) { - continue; - } - const slot = Number.parseInt(slotLabel, 10); - if (!Number.isSafeInteger(slot)) { - continue; - } - summaries.push({ - containerName: name, - slot, - branch: labels[`${DOCKER_LABEL_PREFIX}.branch`], - worktreePath: labels[`${DOCKER_LABEL_PREFIX}.worktree`], - }); - } - return summaries; -} diff --git a/src/output.ts b/src/output.ts index 046d04f..42f30c1 100644 --- a/src/output.ts +++ b/src/output.ts @@ -57,14 +57,14 @@ export function formatAllocationTable( return 'No worktree allocations found.'; } - const header = padRow(['Slot', 'Branch', 'DB', 'Redis', 'Ports', 'Status']); + const header = padRow(['Slot', 'Branch', 'DB', 'Docker', 'Ports', 'Status']); const separator = '-'.repeat(header.length); const rows = entries.map(([slot, alloc]) => { const portStr = Object.entries(alloc.ports) .map(([name, port]) => `${name}:${port}`) .join(' '); const status = fs.existsSync(alloc.worktreePath) ? 'ok' : 'stale'; - return padRow([slot, alloc.branchName, alloc.dbName, formatRedisCell(alloc), portStr, status]); + return padRow([slot, alloc.branchName, alloc.dbName, formatDockerCell(alloc), portStr, status]); }); return [header, separator, ...rows].join('\n'); @@ -76,14 +76,11 @@ function padRow(cols: string[]): string { return cols.map((col, i) => col.padEnd(widths[i] ?? 10)).join(''); } -function formatRedisCell(alloc: Allocation): string { - if (alloc.redisContainerName) { - return alloc.ports.redis ? `port:${alloc.ports.redis}` : alloc.redisContainerName; +function formatDockerCell(alloc: Allocation): string { + if (!alloc.docker) { + return '-'; } - if (alloc.redisDb !== undefined) { - return `db:${alloc.redisDb}`; - } - return '-'; + return `${alloc.docker.projectName} (${alloc.docker.services.length})`; } /** Print a setup summary for human output */ @@ -101,9 +98,9 @@ export function formatSetupSummary( ` Branch: ${alloc.branchName}`, ...(options?.branchSourceLabel ? [` Source: ${options.branchSourceLabel}`] : []), ` Database: ${alloc.dbName}`, - alloc.redisContainerName - ? ` Redis: ${alloc.redisContainerName}${alloc.ports.redis ? ` (port ${alloc.ports.redis})` : ''}` - : ` Redis DB: ${alloc.redisDb ?? '-'}`, + alloc.docker + ? ` Docker: ${alloc.docker.projectName} (${alloc.docker.services.join(', ')})` + : ' Docker: -', ` Ports:`, portLines, ` Path: ${alloc.worktreePath}`, diff --git a/src/schemas/config.schema.ts b/src/schemas/config.schema.ts index e34cb3a..32d6be6 100644 --- a/src/schemas/config.schema.ts +++ b/src/schemas/config.schema.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -const PATCH_TYPES = ['database', 'redis', 'port', 'url', 'branch'] as const; +const PATCH_TYPES = ['database', 'port', 'url', 'branch'] as const; /** Schema for a single env var patch rule */ export const patchSchema = z.discriminatedUnion('type', [ @@ -21,11 +21,6 @@ export const patchSchema = z.discriminatedUnion('type', [ z.object({ var: z.string().min(1), type: z.literal(PATCH_TYPES[3]), - service: z.string().min(1), - }), - z.object({ - var: z.string().min(1), - type: z.literal(PATCH_TYPES[4]), }), ]); @@ -41,6 +36,23 @@ export const envFileSchema = z.object({ patches: z.array(patchSchema), }); +export const dockerPortSchema = z.object({ + service: z.string().min(1), + target: z.number().int().positive(), + host: z.string().min(1).default('127.0.0.1'), +}); + +export const dockerServiceSchema = z.object({ + name: z.string().min(1), + image: z.string().min(1), + restart: z.enum(['no', 'always', 'unless-stopped', 'on-failure']).default('unless-stopped'), + ports: z.array(dockerPortSchema).default([]), + environment: z.record(z.string(), z.string()).default({}), + command: z.union([z.string(), z.array(z.string())]).optional(), + volumes: z.array(z.string()).default([]), + extraHosts: z.array(z.string()).default([]), +}); + /** Schema for wt.config.json */ export const configSchema = z.object({ baseDatabaseName: z.string().min(1), @@ -48,6 +60,7 @@ export const configSchema = z.object({ portStride: z.number().int().positive().default(100), maxSlots: z.number().int().min(1).default(50), services: z.array(serviceSchema).min(1), + dockerServices: z.array(dockerServiceSchema).default([]), envFiles: z.array(envFileSchema), postSetup: z.array(z.string()).default([]), autoInstall: z.boolean().default(true), diff --git a/src/schemas/registry.schema.ts b/src/schemas/registry.schema.ts index 1c6f52c..eaf2ea3 100644 --- a/src/schemas/registry.schema.ts +++ b/src/schemas/registry.schema.ts @@ -5,8 +5,10 @@ export const allocationSchema = z.object({ worktreePath: z.string().min(1), branchName: z.string().min(1), dbName: z.string().min(1), - redisDb: z.number().int().min(0).optional(), - redisContainerName: z.string().min(1).optional(), + docker: z.object({ + projectName: z.string().min(1), + services: z.array(z.string().min(1)), + }).optional(), ports: z.record(z.string(), z.number().int().positive()), createdAt: z.string().datetime(), }); diff --git a/src/types.ts b/src/types.ts index 04cd9bf..06b7085 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,7 @@ import type { serviceSchema, envFileSchema, patchSchema, + dockerServiceSchema, } from './schemas/config.schema'; import type { registrySchema, @@ -15,6 +16,7 @@ export type WtConfig = z.infer; export type ServiceConfig = z.infer; export type EnvFileConfig = z.infer; export type PatchConfig = z.infer; +export type DockerServiceConfig = z.infer; /** Registry persisted at .worktree-registry.json */ export type Registry = z.infer; @@ -23,7 +25,6 @@ export type Allocation = z.infer; /** Context passed to env patcher with computed values for a slot */ export interface PatchContext { readonly dbName: string; - readonly redisPort?: number; readonly ports: Record; readonly branchName?: string; } From 75dd3aee9760375566f15d854d38d463cbfdf44c Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Fri, 24 Apr 2026 20:06:34 -0700 Subject: [PATCH 2/4] add wt-local build shim --- scripts/link-cli.js | 62 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/scripts/link-cli.js b/scripts/link-cli.js index cd697fd..fc3a67f 100644 --- a/scripts/link-cli.js +++ b/scripts/link-cli.js @@ -1,8 +1,61 @@ const fs = require('node:fs'); +const os = require('node:os'); const path = require('node:path'); console.log('Linking CLI...'); +const WT_LOCAL_MARKER = 'generated by @tokenbooks/wt local build'; + +function quoteForSh(value) { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function pathIncludes(dir) { + const entries = (process.env.PATH ?? '').split(path.delimiter); + return entries.some((entry) => path.resolve(entry) === path.resolve(dir)); +} + +function canOverwriteShim(shimPath) { + if (!fs.existsSync(shimPath)) { + return true; + } + + const stat = fs.lstatSync(shimPath); + if (stat.isSymbolicLink()) { + return true; + } + if (!stat.isFile()) { + return false; + } + + const content = fs.readFileSync(shimPath, 'utf8'); + return content.includes(WT_LOCAL_MARKER); +} + +function writeShim(shimPath, cliPath) { + if (!canOverwriteShim(shimPath)) { + console.warn(`Skipping wt-local shim; existing file is not managed by this build: ${shimPath}`); + return false; + } + + fs.mkdirSync(path.dirname(shimPath), { recursive: true }); + if (fs.existsSync(shimPath)) { + fs.rmSync(shimPath, { force: true }); + } + + const content = [ + '#!/bin/sh', + `# ${WT_LOCAL_MARKER}`, + `exec ${quoteForSh(cliPath)} "$@"`, + '', + ].join('\n'); + + fs.writeFileSync(shimPath, content, 'utf8'); + fs.chmodSync(shimPath, '755'); + console.log('wt-local shim prepared at', shimPath); + return true; +} + try { const cliPath = path.join(__dirname, '..', 'dist', 'cli.js'); @@ -23,6 +76,15 @@ try { } console.log('CLI prepared at', cliPath); + + writeShim(path.join(__dirname, '..', 'node_modules', '.bin', 'wt-local'), cliPath); + + const globalBinDir = process.env.WT_LOCAL_BIN_DIR + || process.env.PNPM_HOME + || path.join(os.homedir(), 'Library', 'pnpm'); + if (writeShim(path.join(globalBinDir, 'wt-local'), cliPath) && !pathIncludes(globalBinDir)) { + console.warn(`wt-local was linked into ${globalBinDir}, but that directory is not on PATH.`); + } } catch (error) { console.error('CLI linking failed:', error.message); process.exit(1); From a3a87539e4f4b10f9435e23e2ee98deafec8fc62 Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Fri, 24 Apr 2026 20:14:55 -0700 Subject: [PATCH 3/4] bump version to 0.4.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 25c0619..b89b16a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tokenbooks/wt", - "version": "0.3.3", + "version": "0.4.0", "description": "Git worktree environment isolation CLI", "license": "MIT", "repository": { From e46aceb8bdc1e8f0adbc6ea1f5a45135fef394b2 Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Fri, 24 Apr 2026 20:24:51 -0700 Subject: [PATCH 4/4] put slot first in docker project names --- README.md | 6 +++--- __tests__/docker-services.spec.ts | 2 +- __tests__/registry.spec.ts | 2 +- src/commands/new.spec.ts | 2 +- src/commands/prune.spec.ts | 18 +++++++++--------- src/core/docker-services.ts | 2 +- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 79a1935..a95f86d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Each worktree gets a numbered slot. The slot determines everything: | Resource | Formula | Slot 0 (main) | Slot 1 | Slot 2 | Slot 3 | |----------|---------|:-:|:-:|:-:|:-:| | Database | `{baseName}_wt{slot}` | `mydb` | `mydb_wt1` | `mydb_wt2` | `mydb_wt3` | -| Docker project | `wt---slot-` | shared/local | slot 1 group | slot 2 group | slot 3 group | +| Docker project | `wt---` | shared/local | slot 1 group | slot 2 group | slot 3 group | | Ports | `slot * stride + defaultPort` | 3000, 3001 | 3100, 3101 | 3200, 3201 | 3300, 3301 | - **Database**: Created via `CREATE DATABASE ... TEMPLATE` (fast filesystem copy, not dump/restore) @@ -285,7 +285,7 @@ This file lives in your repository root and is committed to version control. // Docker services to run per worktree (default: []). // wt renders this into an internal Docker Compose project named - // wt---slot-, so Docker Desktop groups them together. + // wt---, so Docker Desktop groups them together. "dockerServices": [ { "name": string, @@ -355,7 +355,7 @@ Auto-managed file at the repo root. **Add to `.gitignore`** — it's machine-loc "branchName": "feat/auth", "dbName": "myapp_wt1", "docker": { - "projectName": "wt-myapp-a1b2c3d4-slot-1", + "projectName": "wt-1-myapp-a1b2c3d4", "services": ["redis", "electric"] }, "ports": { "web": 3100, "api": 4100, "redis": 6479 }, diff --git a/__tests__/docker-services.spec.ts b/__tests__/docker-services.spec.ts index 4799ad9..2a4cb41 100644 --- a/__tests__/docker-services.spec.ts +++ b/__tests__/docker-services.spec.ts @@ -54,7 +54,7 @@ describe('docker-services', () => { it('builds a deterministic Docker Compose project name from repo path and slot', () => { const name = getDockerProjectName('/Users/dev/My Project', 7); - expect(name).toMatch(/^wt-my-project-[a-f0-9]{8}-slot-7$/); + expect(name).toMatch(/^wt-7-my-project-[a-f0-9]{8}$/); expect(getDockerProjectName('/Users/dev/My Project', 7)).toBe(name); }); diff --git a/__tests__/registry.spec.ts b/__tests__/registry.spec.ts index 0c1594f..3e9ef0c 100644 --- a/__tests__/registry.spec.ts +++ b/__tests__/registry.spec.ts @@ -27,7 +27,7 @@ describe('registry', () => { branchName: 'feat/test', dbName: 'cryptoacc_wt1', docker: { - projectName: 'wt-project-12345678-slot-1', + projectName: 'wt-1-project-12345678', services: ['redis'], }, ports: { app: 3100, server: 3101, redis: 6479 }, diff --git a/src/commands/new.spec.ts b/src/commands/new.spec.ts index 1710e53..cd3e381 100644 --- a/src/commands/new.spec.ts +++ b/src/commands/new.spec.ts @@ -297,7 +297,7 @@ describe('new command rollback on failure', () => { mockCalculatePorts.mockReturnValue({ web: 3200, redis: 6579 }); mockCalculateDbName.mockReturnValue('myapp_wt2'); mockEnsureDockerServices.mockReturnValue({ - projectName: 'wt-myapp-deadbeef-slot-2', + projectName: 'wt-2-myapp-deadbeef', services: ['redis'], }); mockRemoveDockerServices.mockReturnValue(true); diff --git a/src/commands/prune.spec.ts b/src/commands/prune.spec.ts index 50cfb1a..b904420 100644 --- a/src/commands/prune.spec.ts +++ b/src/commands/prune.spec.ts @@ -68,7 +68,7 @@ describe('pruneCommand', () => { branchName: 'feat/auth', dbName: 'myapp_wt2', docker: { - projectName: 'wt-myapp-deadbeef-slot-2', + projectName: 'wt-2-myapp-deadbeef', services: ['redis'], }, ports: { web: 3200, redis: 6579 }, @@ -268,27 +268,27 @@ describe('pruneCommand', () => { mockListManagedDockerProjectsForRepo.mockReturnValue([ // slot 2 matches the live registry entry — NOT an orphan. { - projectName: 'wt-myapp-deadbeef-slot-2', + projectName: 'wt-2-myapp-deadbeef', slot: 2, branch: 'feat/auth', services: ['redis'], - containerNames: ['wt-myapp-deadbeef-slot-2-redis'], + containerNames: ['wt-2-myapp-deadbeef-redis'], }, // slot 7 has no registry entry — orphan. { - projectName: 'wt-myapp-deadbeef-slot-7', + projectName: 'wt-7-myapp-deadbeef', slot: 7, branch: 'fix/old', worktreePath: '/repo/.worktrees/fix-old', services: ['redis', 'electric'], - containerNames: ['wt-myapp-deadbeef-slot-7-redis', 'wt-myapp-deadbeef-slot-7-electric'], + containerNames: ['wt-7-myapp-deadbeef-redis', 'wt-7-myapp-deadbeef-electric'], }, // slot 9 has no registry entry — orphan. { - projectName: 'wt-myapp-deadbeef-slot-9', + projectName: 'wt-9-myapp-deadbeef', slot: 9, services: ['redis'], - containerNames: ['wt-myapp-deadbeef-slot-9-redis'], + containerNames: ['wt-9-myapp-deadbeef-redis'], }, ]); // Registry entry for slot 2 points to an existing dir so it stays put. @@ -324,10 +324,10 @@ describe('pruneCommand', () => { it('reports orphan Docker projects in dry-run without touching Docker', async () => { mockListManagedDockerProjectsForRepo.mockReturnValue([ { - projectName: 'wt-myapp-deadbeef-slot-9', + projectName: 'wt-9-myapp-deadbeef', slot: 9, services: ['redis'], - containerNames: ['wt-myapp-deadbeef-slot-9-redis'], + containerNames: ['wt-9-myapp-deadbeef-redis'], }, ]); mockReadRegistry.mockReturnValue({ version: 1, allocations: {} } satisfies Registry); diff --git a/src/core/docker-services.ts b/src/core/docker-services.ts index fb3151c..9722846 100644 --- a/src/core/docker-services.ts +++ b/src/core/docker-services.ts @@ -198,7 +198,7 @@ export function usesDockerServices(config: WtConfig): boolean { export function getDockerProjectName(mainRoot: string, slot: number): string { const repoName = slugify(path.basename(mainRoot)); - return `wt-${repoName}-${repoHash(mainRoot)}-slot-${slot}`; + return `wt-${slot}-${repoName}-${repoHash(mainRoot)}`; } export function buildDockerComposeConfig(options: EnsureDockerServicesOptions): DockerComposeConfig {