Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
40302de
docs: spec for wt setup --repair and --dry-run
pkudinov Apr 26, 2026
9e3c3e1
docs: extend setup-repair spec with idempotent Docker semantics
pkudinov Apr 26, 2026
70d1398
docs: replace --reload-docker flag with auto-detect via per-service h…
pkudinov Apr 26, 2026
2ef0b20
docs: implementation plan for setup --repair and idempotent docker
pkudinov Apr 26, 2026
baab036
feat(types): add PortChange
pkudinov Apr 26, 2026
8b0d588
feat(schema): allow optional docker.serviceHashes on allocations
pkudinov Apr 26, 2026
26ca8bd
feat(slot-allocator): add excludeSlot option to allocateServicePorts
pkudinov Apr 26, 2026
d209161
feat(docker-services): computeServiceHashes for compose-config diffing
pkudinov Apr 26, 2026
cb6800e
feat(docker-services): recreateServices flag and serviceHashes return
pkudinov Apr 26, 2026
8fc2bd9
test(docker-services): assert -f/-p flags and explain namespace import
pkudinov Apr 26, 2026
b713477
feat(output): formatRepairPreview helper
pkudinov Apr 26, 2026
ec0179b
feat(setup): auto-detect compose-config changes via per-service hashing
pkudinov Apr 26, 2026
fe1c0a1
feat(setup): --repair and --dry-run flags
pkudinov Apr 26, 2026
2d40a7b
fix(setup): clarify recreatedDockerServices in repair JSON; cover nat…
pkudinov Apr 26, 2026
fd02af8
docs: --repair and --dry-run for wt setup
pkudinov Apr 26, 2026
fa23a70
docs: align README repair example with formatter output (14-char name…
pkudinov Apr 26, 2026
d61f680
docs(spec): align JSON example with implemented PortChange field names
pkudinov Apr 26, 2026
1f3d863
bump version to 0.4.2
pkudinov Apr 26, 2026
d1b7a43
test(docker-integration): include serviceHashes in expected allocation
pkudinov Apr 27, 2026
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
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,45 @@ Shell helper tip:
wto() { cd "$(wt open "$@")"; }
```

### `wt setup [path] [--no-install] [--json]`
### `wt setup [path] [--no-install] [--json] [--repair] [--dry-run]`

Sets up an existing worktree that was created manually or by another tool. Useful when:

- You ran `git worktree add` directly
- A worktree's env files need regenerating
- Called automatically by the `post-checkout` hook
- A worktree's port allocation has gone stale (use `--repair`)

If the worktree already has a slot allocation, it reuses it.
If the worktree already has a slot allocation, it reuses it. `wt setup` is idempotent for Docker: services whose compose-config hash hasn't changed are left running; only services with a config change (or a port change in `--repair` mode) are stopped and recreated. The first `wt setup` after upgrading to this version stores the current hashes as a baseline without recreating anything.

#### `--repair`

Re-allocates ports for an existing worktree as if creating it fresh now. Useful when:

- An external process has seized one of the worktree's ports.
- The allocation predates port-drift (v0.4.1) and needs refreshing.
- An adjacent slot was removed, freeing a port the worktree had drifted around.

Repair re-runs `allocateServicePorts` excluding the slot's own current ports from the reserved set, then writes the new allocation, re-renders env files, and recreates only the docker services whose ports (or compose config) actually changed. `wt remove` is intentionally not the answer to a stale port allocation — it would delete the worktree directory and any uncommitted work. Repair preserves the worktree directory, the database, and untouched ports.

#### `--dry-run`

Used with `--repair`, prints the proposed reallocation and exits without writing anything. The output looks like:

```
Repair preview for slot 20 (cryptoacc_wt20):
app 5000 → 5005 in use by python3[12345]
server 5001 (unchanged)
slack-bot 5010 (unchanged)
sync-exchanges 5002 (unchanged)
redis 8379 (unchanged)

Docker services to recreate: redis

[dry-run] No changes written. Re-run without --dry-run to apply.
```

`--dry-run` requires `--repair`; using it alone errors out.

### `wt remove <targets...> [--all] [--keep-db] [--json]`

Expand Down
6 changes: 5 additions & 1 deletion __tests__/docker-services.docker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,11 @@ describeDocker('docker-services integration', () => {
config,
});

expect(allocation).toEqual({ projectName, services: ['redis'] });
expect(allocation).toEqual({
projectName,
services: ['redis'],
serviceHashes: { redis: expect.stringMatching(/^[a-f0-9]{12}$/) as unknown as string },
});
await waitForRedis(port);

const projects = listManagedDockerProjectsForRepo(mainRoot);
Expand Down
186 changes: 185 additions & 1 deletion __tests__/docker-services.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { describe, expect, it } from '@jest/globals';
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
import * as child_process from 'node:child_process';
import {
buildDockerComposeConfig,
computeServiceHashes,
ensureDockerServices,
getDockerProjectName,
usesDockerServices,
} from '../src/core/docker-services';
import type { WtConfig } from '../src/types';

jest.mock('node:child_process');

describe('docker-services', () => {
const config: WtConfig = {
baseDatabaseName: 'cryptoacc',
Expand Down Expand Up @@ -91,4 +96,183 @@ describe('docker-services', () => {
extra_hosts: ['host.docker.internal:host-gateway'],
});
});

describe('computeServiceHashes', () => {
function configWithRedis(): WtConfig {
return {
...config,
dockerServices: [config.dockerServices[0]!], // redis only
};
}

function buildOptions(overrides: Partial<{ ports: Record<string, number>; branchName: string }> = {}) {
return {
mainRoot: '/Users/dev/My Project',
slot: 3,
branchName: overrides.branchName ?? 'feat/electric',
worktreePath: '/Users/dev/My Project/.worktrees/feat-electric',
dbName: 'cryptoacc_wt3',
ports: overrides.ports ?? { electric: 3304, redis: 6679 },
config: configWithRedis(),
};
}

it('returns one hash per docker service', () => {
const compose = buildDockerComposeConfig(buildOptions());
const hashes = computeServiceHashes(compose);

expect(Object.keys(hashes)).toEqual(['redis']);
expect(hashes.redis).toMatch(/^[a-f0-9]{12}$/);
});

it('produces identical hashes for identical compose configs', () => {
const a = buildDockerComposeConfig(buildOptions());
const b = buildDockerComposeConfig(buildOptions());

expect(computeServiceHashes(a)).toEqual(computeServiceHashes(b));
});

it('produces different hashes when the rendered service differs', () => {
const original = buildDockerComposeConfig(buildOptions());
const portChanged = buildDockerComposeConfig(buildOptions({ ports: { electric: 3304, redis: 6699 } }));

expect(computeServiceHashes(original).redis).not.toBe(computeServiceHashes(portChanged).redis);
});
});
});

describe('ensureDockerServices invocation', () => {
let calls: string[][] = [];

beforeEach(() => {
calls = [];
jest.mocked(child_process.execFileSync).mockImplementation((_cmd, args) => {
calls.push([...(args as string[])]);
return '';
});
});

afterEach(() => {
jest.resetAllMocks();
});

const config: WtConfig = {
baseDatabaseName: 'cryptoacc',
baseWorktreePath: '.worktrees',
portStride: 100,
maxSlots: 25,
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: {},
command: ['redis-server'],
volumes: [],
extraHosts: [],
},
],
envFiles: [],
postSetup: [],
autoInstall: true,
};

function runEnsure(extra: { recreateServices?: readonly string[] } = {}) {
return ensureDockerServices({
mainRoot: '/Users/dev/My Project',
slot: 3,
branchName: 'feat/x',
worktreePath: '/Users/dev/My Project/.worktrees/x',
dbName: 'cryptoacc_wt3',
ports: { redis: 6679 },
config,
...extra,
});
}

it('uses the idempotent up path when recreateServices is omitted', () => {
const result = runEnsure();

expect(calls).toHaveLength(1);
expect(calls[0]).toEqual(
expect.arrayContaining([
'compose',
'-f',
expect.stringContaining('compose.json'),
'-p',
expect.stringContaining('wt-3-'),
'up',
'-d',
'--no-recreate',
'--remove-orphans',
]),
);
expect(result?.serviceHashes).toBeDefined();
expect(Object.keys(result?.serviceHashes ?? {})).toEqual(['redis']);
});

it('uses the idempotent up path when recreateServices is empty', () => {
runEnsure({ recreateServices: [] });

expect(calls).toHaveLength(1);
expect(calls[0]).toEqual(
expect.arrayContaining([
'-f',
expect.stringContaining('compose.json'),
'-p',
expect.stringContaining('wt-3-'),
'--no-recreate',
'--remove-orphans',
]),
);
});

it('does targeted stop+force-recreate then a final idempotent up when recreateServices is non-empty', () => {
runEnsure({ recreateServices: ['redis'] });

expect(calls).toHaveLength(3);
// 1. stop
expect(calls[0]).toEqual(
expect.arrayContaining([
'compose',
'-f',
expect.stringContaining('compose.json'),
'-p',
expect.stringContaining('wt-3-'),
'stop',
'redis',
]),
);
// 2. force-recreate, no-deps, only the listed services
expect(calls[1]).toEqual(
expect.arrayContaining([
'compose',
'-f',
expect.stringContaining('compose.json'),
'-p',
expect.stringContaining('wt-3-'),
'up',
'-d',
'--force-recreate',
'--no-deps',
'redis',
]),
);
// 3. final idempotent up to bring back unchanged services and prune orphans
expect(calls[2]).toEqual(
expect.arrayContaining([
'compose',
'-f',
expect.stringContaining('compose.json'),
'-p',
expect.stringContaining('wt-3-'),
'up',
'-d',
'--no-recreate',
'--remove-orphans',
]),
);
});
});
52 changes: 51 additions & 1 deletion __tests__/output.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from '@jest/globals';
import { extractErrorMessage } from '../src/output';
import { extractErrorMessage, formatRepairPreview } from '../src/output';

describe('extractErrorMessage', () => {
it('extracts message from a regular Error', () => {
Expand Down Expand Up @@ -48,4 +48,54 @@ describe('extractErrorMessage', () => {
expect(extractErrorMessage(null)).toBe('null');
expect(extractErrorMessage(undefined)).toBe('undefined');
});

describe('formatRepairPreview', () => {
it('renders unchanged services and one repaired service', () => {
const text = formatRepairPreview({
slot: 20,
dbName: 'cryptoacc_wt20',
changes: [
{ service: 'app', registered: 5000, proposed: 5005, reason: 'in use by python3[12345]' },
{ service: 'server', registered: 5001, proposed: 5001, reason: 'unchanged' },
],
recreatedDockerServices: ['redis'],
dryRun: true,
});

expect(text).toContain('Repair preview for slot 20 (cryptoacc_wt20):');
expect(text).toContain('app');
expect(text).toContain('5000 → 5005');
expect(text).toContain('in use by python3[12345]');
expect(text).toContain('server');
expect(text).toContain('(unchanged)');
expect(text).toContain('Docker services to recreate: redis');
expect(text).toContain('[dry-run] No changes written');
});

it('renders an apply-mode preview without the [dry-run] line', () => {
const text = formatRepairPreview({
slot: 20,
dbName: 'cryptoacc_wt20',
changes: [{ service: 'app', registered: 5000, proposed: 5005, reason: 'in use by node[1]' }],
recreatedDockerServices: [],
dryRun: false,
});

expect(text).not.toContain('[dry-run]');
});

it('renders the "no changes needed" form when nothing changed and no docker recreate', () => {
const text = formatRepairPreview({
slot: 20,
dbName: 'cryptoacc_wt20',
changes: [
{ service: 'app', registered: 5000, proposed: 5000, reason: 'unchanged' },
],
recreatedDockerServices: [],
dryRun: false,
});

expect(text).toContain('Repair check for slot 20: no changes needed.');
});
});
});
23 changes: 23 additions & 0 deletions __tests__/slot-allocator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,29 @@ describe('slot-allocator', () => {
allocateServicePorts(0, edgeServices, 0, registry),
).rejects.toThrow(/No available port for service 'edge'/);
});

it('treats the excluded slot\'s registered ports as not reserved', async () => {
// Slot 2's registered ports include 3200 (web). Without excludeSlot
// we'd see this as an internal conflict and drift; with
// excludeSlot=2 we ignore it and treat 3200 as available.
const registry: Registry = {
version: 1,
allocations: {
'2': {
worktreePath: '/tmp/wt2',
branchName: 'feat/own',
dbName: 'db_wt2',
ports: { web: 3200, api: 4200 },
createdAt: '2026-04-25T00:00:00.000Z',
},
},
};

const result = await allocateServicePorts(2, services, stride, registry, { excludeSlot: 2 });

expect(result.ports).toEqual({ web: 3200, api: 4200 });
expect(result.drifts).toEqual([]);
});
});
});

Expand Down
Loading
Loading