From 3dbcf205bbc4187c93082619e502565d3d18b5a0 Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Sat, 16 May 2026 00:28:11 -0700 Subject: [PATCH] fix: detect orphaned compose networks in listManagedDockerProjectsForRepo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker Compose only removes its project network during `compose down`. When containers are reaped some other way — `docker rm -f`, Docker Desktop reset, OS crash, or a `wt new` killed mid-flight before the rollback path runs — the network survives but loses its only link to the wt label set, which was previously only applied to containers. Each leaked network still consumes a subnet slot from Docker's default address pool. After ~30 leaks `wt new` fails with `all predefined address pools have been fully subnetted` and `wt prune` cannot find the projects to clean (its discovery starts from container labels). Two changes: 1. `buildDockerComposeConfig` now emits a labeled `networks.default` carrying the same `dev.tokenbooks.wt.*` metadata as containers. Compose applies these on the real Docker network. 2. `listManagedDockerProjectsForRepo` scans `docker network ls` with the repo-root + managed filters in addition to the existing container scan, then merges by project name (no duplication when both containers and a network exist). `wt prune` already iterates `listManagedDockerProjectsForRepo` results and removes orphan projects, so the recovery path lights up automatically — no command-surface changes. Tests: - Unit: network labels rendered, orphan discovery, dedup, filter shape - Integration (WT_RUN_DOCKER_TESTS=1): real Compose run, force-remove the container, confirm `listManagedDockerProjectsForRepo` reports the survivor and `removeDockerServices` cleans it up. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- __tests__/docker-services.docker.spec.ts | 44 ++++++ __tests__/docker-services.spec.ts | 178 +++++++++++++++++++++++ src/core/docker-services.ts | 89 +++++++++++- 4 files changed, 311 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 817dc1c..32f805e 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ Finds worktrees that Git already marks as prunable, then: 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`. +This is mainly for worktrees that were deleted manually from disk instead of through `wt remove`. It also recovers Docker networks orphaned by container reaps (e.g. `docker rm -f`, Docker Desktop resets, partial `wt new` failures) — these would otherwise consume subnet slots from Docker's default address pool and eventually surface as `all predefined address pools have been fully subnetted` on the next `wt new`. Use `--dry-run` to preview what would be pruned. diff --git a/__tests__/docker-services.docker.spec.ts b/__tests__/docker-services.docker.spec.ts index 897877d..827d62d 100644 --- a/__tests__/docker-services.docker.spec.ts +++ b/__tests__/docker-services.docker.spec.ts @@ -1,8 +1,13 @@ import { afterAll, afterEach, describe, expect, it, jest } from '@jest/globals'; +import { execFileSync } from 'node:child_process'; import * as net from 'node:net'; import * as os from 'node:os'; import * as path from 'node:path'; import * as fs from 'node:fs'; + +function runCli(cmd: string, args: readonly string[]): string { + return execFileSync(cmd, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }); +} import { ensureDockerServices, getDockerProjectName, @@ -152,4 +157,43 @@ describeDocker('docker-services integration', () => { expect(listManagedDockerProjectsForRepo(mainRoot)).toEqual([]); expect(removeDockerServices(mainRoot, SLOT)).toBe(false); }); + + it('discovers a project whose containers have been removed but whose network still exists', async () => { + const port = await reserveFreePort(); + const projectName = getDockerProjectName(mainRoot, SLOT); + const containerName = `${projectName}-redis`; + const networkName = `${projectName}_default`; + + ensureDockerServices({ + mainRoot, + slot: SLOT, + branchName, + worktreePath, + dbName: 'myapp_wt42', + ports: { redis: port }, + config, + }); + await waitForRedis(port); + + // Simulate a container reap (e.g. `docker rm -f`, Docker Desktop reset) + // that leaves the labeled network behind. Without network-aware discovery + // this project would be invisible to `wt prune` and leak a subnet. + runCli('docker', ['rm', '-f', containerName]); + + const projects = listManagedDockerProjectsForRepo(mainRoot); + expect(projects).toContainEqual({ + projectName, + slot: SLOT, + branch: branchName, + worktreePath, + services: [], + containerNames: [], + }); + + expect(removeDockerServices(mainRoot, SLOT)).toBe(true); + + // Confirm the orphan network really existed and is now gone. + const remaining = runCli('docker', ['network', 'ls', '-q', '--filter', `name=${networkName}`]).trim(); + expect(remaining).toBe(''); + }); }); diff --git a/__tests__/docker-services.spec.ts b/__tests__/docker-services.spec.ts index 00d1b27..85ffecc 100644 --- a/__tests__/docker-services.spec.ts +++ b/__tests__/docker-services.spec.ts @@ -5,6 +5,7 @@ import { computeServiceHashes, ensureDockerServices, getDockerProjectName, + listManagedDockerProjectsForRepo, usesDockerServices, } from '../src/core/docker-services'; import type { WtConfig } from '../src/types'; @@ -97,6 +98,33 @@ describe('docker-services', () => { }); }); + it('labels the default network so it can be discovered by repo-root, slot, and project', () => { + 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); + const defaultNetwork = compose.networks?.default; + + expect(defaultNetwork).toBeDefined(); + expect(defaultNetwork?.labels).toEqual( + expect.arrayContaining([ + 'dev.tokenbooks.wt.managed=true', + 'dev.tokenbooks.wt.repo-root=/Users/dev/My Project', + `dev.tokenbooks.wt.project=${projectName}`, + 'dev.tokenbooks.wt.slot=3', + 'dev.tokenbooks.wt.branch=feat/electric', + 'dev.tokenbooks.wt.worktree=/Users/dev/My Project/.worktrees/feat-electric', + ]), + ); + }); + describe('computeServiceHashes', () => { function configWithRedis(): WtConfig { return { @@ -276,3 +304,153 @@ describe('ensureDockerServices invocation', () => { ); }); }); + +describe('listManagedDockerProjectsForRepo orphan discovery', () => { + const mainRoot = '/Users/dev/My Project'; + + interface DockerCall { + readonly args: readonly string[]; + } + + /** + * Routes mocked docker calls by verb. Captures every call so tests can + * assert on the exact CLI invocation when discovery filters matter. + */ + function mockDocker(handlers: { + ps?: () => string; + containerInspect?: (name: string) => string; + networkLs?: () => string; + networkInspect?: (name: string) => string; + }): { calls: DockerCall[] } { + const calls: DockerCall[] = []; + jest.mocked(child_process.execFileSync).mockImplementation((_cmd, rawArgs) => { + const args = [...(rawArgs as string[])]; + calls.push({ args }); + const [verb, sub] = args; + if (verb === 'ps') { + return handlers.ps?.() ?? ''; + } + if (verb === 'container' && sub === 'inspect') { + return handlers.containerInspect?.(args[2]!) ?? '[]'; + } + if (verb === 'network' && sub === 'ls') { + return handlers.networkLs?.() ?? ''; + } + if (verb === 'network' && sub === 'inspect') { + return handlers.networkInspect?.(args[2]!) ?? '[]'; + } + return ''; + }); + return { calls }; + } + + beforeEach(() => { + jest.mocked(child_process.execFileSync).mockReset(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('discovers a project whose containers were reaped but whose compose network survives', () => { + const projectName = getDockerProjectName(mainRoot, 4); + const networkName = `${projectName}_default`; + mockDocker({ + ps: () => '', + networkLs: () => networkName, + networkInspect: () => JSON.stringify([ + { + Name: networkName, + Labels: { + 'com.docker.compose.project': projectName, + 'dev.tokenbooks.wt.managed': 'true', + 'dev.tokenbooks.wt.repo-root': mainRoot, + 'dev.tokenbooks.wt.project': projectName, + 'dev.tokenbooks.wt.slot': '4', + 'dev.tokenbooks.wt.branch': 'feat/sync-tron', + 'dev.tokenbooks.wt.worktree': `${mainRoot}/.worktrees/feat-sync-tron`, + }, + }, + ]), + }); + + const projects = listManagedDockerProjectsForRepo(mainRoot); + + expect(projects).toEqual([ + { + projectName, + slot: 4, + branch: 'feat/sync-tron', + worktreePath: `${mainRoot}/.worktrees/feat-sync-tron`, + services: [], + containerNames: [], + }, + ]); + }); + + it('does not duplicate projects that have both surviving containers and a network', () => { + const projectName = getDockerProjectName(mainRoot, 5); + const containerName = `${projectName}-redis`; + const networkName = `${projectName}_default`; + mockDocker({ + ps: () => containerName, + containerInspect: () => JSON.stringify([ + { + Config: { + Labels: { + 'dev.tokenbooks.wt.managed': 'true', + 'dev.tokenbooks.wt.repo-root': mainRoot, + 'dev.tokenbooks.wt.project': projectName, + 'dev.tokenbooks.wt.slot': '5', + 'dev.tokenbooks.wt.branch': 'feat/dual', + 'dev.tokenbooks.wt.worktree': `${mainRoot}/.worktrees/feat-dual`, + 'dev.tokenbooks.wt.service': 'redis', + }, + }, + }, + ]), + networkLs: () => networkName, + networkInspect: () => JSON.stringify([ + { + Name: networkName, + Labels: { + 'com.docker.compose.project': projectName, + 'dev.tokenbooks.wt.managed': 'true', + 'dev.tokenbooks.wt.repo-root': mainRoot, + 'dev.tokenbooks.wt.project': projectName, + 'dev.tokenbooks.wt.slot': '5', + 'dev.tokenbooks.wt.branch': 'feat/dual', + 'dev.tokenbooks.wt.worktree': `${mainRoot}/.worktrees/feat-dual`, + }, + }, + ]), + }); + + const projects = listManagedDockerProjectsForRepo(mainRoot); + + expect(projects).toHaveLength(1); + expect(projects[0]).toMatchObject({ + projectName, + slot: 5, + branch: 'feat/dual', + services: ['redis'], + containerNames: [containerName], + }); + }); + + it('filters network discovery by repo-root and managed labels', () => { + const { calls } = mockDocker({}); + + listManagedDockerProjectsForRepo(mainRoot); + + const networkLsCall = calls.find(({ args }) => args[0] === 'network' && args[1] === 'ls'); + expect(networkLsCall?.args).toEqual( + expect.arrayContaining([ + '--filter', + `label=dev.tokenbooks.wt.repo-root=${mainRoot}`, + '--filter', + 'label=dev.tokenbooks.wt.managed=true', + ]), + ); + }); +}); diff --git a/src/core/docker-services.ts b/src/core/docker-services.ts index 77d8977..5ebdf13 100644 --- a/src/core/docker-services.ts +++ b/src/core/docker-services.ts @@ -47,6 +47,11 @@ interface DockerInspectRecord { }; } +interface DockerNetworkInspectRecord { + readonly Name?: string; + readonly Labels?: Record; +} + export interface DockerComposeService { readonly image: string; readonly container_name: string; @@ -59,8 +64,13 @@ export interface DockerComposeService { readonly extra_hosts?: string[]; } +export interface DockerComposeNetwork { + readonly labels: string[]; +} + export interface DockerComposeConfig { readonly services: Record; + readonly networks?: Record; } /** @@ -138,6 +148,20 @@ function inspectDockerContainer(containerName: string): DockerInspectRecord | nu } } +function inspectDockerNetwork(networkName: string): DockerNetworkInspectRecord | null { + try { + const raw = runDocker(['network', 'inspect', networkName]); + const parsed = JSON.parse(raw) as DockerNetworkInspectRecord[]; + return parsed[0] ?? null; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes('No such network') || message.includes('not found')) { + 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 = { @@ -282,7 +306,20 @@ export function buildDockerComposeConfig(options: EnsureDockerServicesOptions): services[service.name] = composeService; } - return { services }; + const networks: Record = { + default: { + labels: [ + `${DOCKER_LABEL_PREFIX}.managed=true`, + `${DOCKER_LABEL_PREFIX}.repo-root=${options.mainRoot}`, + `${DOCKER_LABEL_PREFIX}.project=${projectName}`, + `${DOCKER_LABEL_PREFIX}.slot=${options.slot}`, + `${DOCKER_LABEL_PREFIX}.branch=${options.branchName}`, + `${DOCKER_LABEL_PREFIX}.worktree=${options.worktreePath}`, + ], + }, + }; + + return { services, networks }; } export function ensureDockerServices( @@ -439,6 +476,56 @@ export function listManagedDockerProjectsForRepo(mainRoot: string): ManagedDocke projects.set(projectName, existing); } + // Networks outlive their containers when those are reaped by `docker rm`, + // Docker Desktop resets, or partial `wt new` failures. Scanning for our + // labeled networks lets `wt prune` reclaim subnets that would otherwise + // leak until manual `docker network prune`. + let networkNames: string[]; + try { + networkNames = listDockerResourceIds([ + 'network', + 'ls', + '--filter', + `label=${DOCKER_LABEL_PREFIX}.repo-root=${mainRoot}`, + '--filter', + `label=${DOCKER_LABEL_PREFIX}.managed=true`, + '--format', + '{{.Name}}', + ]); + } catch (err) { + if (err instanceof Error && /Docker CLI not found|Cannot connect to the Docker daemon/i.test(err.message)) { + return []; + } + throw err; + } + + for (const networkName of networkNames) { + const inspect = inspectDockerNetwork(networkName); + if (!inspect) { + continue; + } + const labels = inspect.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); + if (projects.has(projectName)) { + continue; + } + projects.set(projectName, { + slot, + branch: labels[`${DOCKER_LABEL_PREFIX}.branch`], + worktreePath: labels[`${DOCKER_LABEL_PREFIX}.worktree`], + services: new Set(), + containerNames: [], + }); + } + return Array.from(projects.entries()) .map(([projectName, project]) => ({ projectName,