Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
44 changes: 44 additions & 0 deletions __tests__/docker-services.docker.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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('');
});
});
178 changes: 178 additions & 0 deletions __tests__/docker-services.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
computeServiceHashes,
ensureDockerServices,
getDockerProjectName,
listManagedDockerProjectsForRepo,
usesDockerServices,
} from '../src/core/docker-services';
import type { WtConfig } from '../src/types';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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',
]),
);
});
});
89 changes: 88 additions & 1 deletion src/core/docker-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ interface DockerInspectRecord {
};
}

interface DockerNetworkInspectRecord {
readonly Name?: string;
readonly Labels?: Record<string, string>;
}

export interface DockerComposeService {
readonly image: string;
readonly container_name: string;
Expand All @@ -59,8 +64,13 @@ export interface DockerComposeService {
readonly extra_hosts?: string[];
}

export interface DockerComposeNetwork {
readonly labels: string[];
}

export interface DockerComposeConfig {
readonly services: Record<string, DockerComposeService>;
readonly networks?: Record<string, DockerComposeNetwork>;
}

/**
Expand Down Expand Up @@ -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<string, string> = {
Expand Down Expand Up @@ -282,7 +306,20 @@ export function buildDockerComposeConfig(options: EnsureDockerServicesOptions):
services[service.name] = composeService;
}

return { services };
const networks: Record<string, DockerComposeNetwork> = {
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}`,
],
Comment thread
pkudinov marked this conversation as resolved.
},
};

return { services, networks };
}

export function ensureDockerServices(
Expand Down Expand Up @@ -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;
Comment thread
pkudinov marked this conversation as resolved.
}

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<string>(),
containerNames: [],
});
}

return Array.from(projects.entries())
.map(([projectName, project]) => ({
projectName,
Expand Down
Loading