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
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ wt remove .worktrees/feat-my-feature

# Prune worktrees Git already considers stale
wt prune

# See which worktrees are merged and safe to remove (then remove them)
wt audit
wt prune --merged
```

### 5. Claude Code skill (optional)
Expand Down Expand Up @@ -258,7 +262,7 @@ Accepts either paths (`.worktrees/feat-my-feature`) or slot numbers (`3`), not b
- `wt remove "1, 2"`
- `wt remove --all`

### `wt prune [--dry-run] [--keep-db] [--json]`
### `wt prune [--dry-run] [--keep-db] [--merged] [--json]`

Finds worktrees that Git already marks as prunable, then:

Expand All @@ -269,7 +273,22 @@ Finds worktrees that Git already marks as prunable, then:

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.
With `--merged`, prune additionally removes **live** worktrees that `wt audit` classifies as safe to delete — branches already in the base ref (merged or squashed) or folded into a retained local branch. The audit excludes any worktree with uncommitted work, so `--merged` never deletes unsaved changes. This is the "act on the audit" companion to `wt audit`.

Use `--dry-run` to preview what would be pruned (including the `--merged` candidates).

### `wt audit [--json] [--no-fetch] [--base <ref>] [--ignore-dirty <paths...>]`

Classifies every registered worktree by how its branch relates to the base ref (`origin/main`, falling back to `main`) and prints a copyable `wt remove` for the trees that are unambiguously safe to delete. Deletion safety is grounded in git, not GitHub PR state — a tip is deletable when it is reachable from the base ref (a merge or squash) or folded into another retained local branch, and never when the worktree has real uncommitted work.

Verdicts: `delete-merged`, `delete-consolidated`, `keep-open-pr`, `keep-wip`, `review-uncommitted`, `review-merged-diverged`, `review-closed-pr`. When `gh` is available, open/closed/merged PR state is added as context.

- `--no-fetch` skips refreshing `origin/main` first (audits the local ref).
- `--base <ref>` audits against a different base.
- `--ignore-dirty <paths...>` treats matching path fragments as never-real dirt (e.g. a per-worktree `.mcp.json`).
- `--json` emits the full per-worktree audit for scripting: `wt audit --json | jq '.data[] | select(.verdict=="delete-merged")'`.

To act on the result, copy the printed `wt remove` line, or run `wt prune --merged` to remove all audit-confirmed-safe worktrees in one step.

### `wt list [--json]`

Expand Down
25 changes: 23 additions & 2 deletions skills/wt/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ wt new $1
```

If it fails, check `wt doctor` for diagnostics. Common issues:
- All slots occupied → suggest `wt list` to find stale ones, then `wt remove` or `wt prune`
- All slots occupied → run `wt audit` to see which worktrees are merged and safe to remove, then `wt prune --merged` (or `wt remove`)
- Database connection failed → check that Postgres is running and `DATABASE_URL` in root `.env` is correct

---
Expand Down Expand Up @@ -219,6 +219,26 @@ Use this when worktree directories were deleted manually and Git already marks t
Flags:
- `--dry-run` to preview what would be pruned
- `--keep-db` to keep databases for matching managed allocations
- `--merged` to also remove live worktrees whose branch is already merged into the base ref (audit-confirmed and clean only — never deletes uncommitted work). This is the "act on it" companion to `wt audit`.

---

### `audit` — Classify worktrees by merge state

Run:
```bash
wt audit $@
```

Use this when slots are full or the workspace is cluttered and you want to know which worktrees are finished and safe to delete. It classifies each worktree by how its branch relates to the base ref (`origin/main`, then `main`) and prints a copyable `wt remove` for the safe ones. Deletion safety is grounded in git (reachable from the base ref, or folded into a retained branch), not GitHub PR state; trees with uncommitted work are never suggested.

Flags:
- `--no-fetch` to skip refreshing `origin/main` first
- `--base <ref>` to audit against a different base
- `--ignore-dirty <paths...>` to treat matching path fragments as never-real dirt
- `--json` for machine-readable verdicts, e.g. `wt audit --json | jq '.data[] | select(.verdict=="delete-merged")'`

To act on it: copy the printed `wt remove` line, or run `wt prune --merged` to remove every audit-confirmed-safe worktree at once.

---

Expand Down Expand Up @@ -280,8 +300,9 @@ Available commands:
/wt new <branch> — Create a worktree with isolated DB, Docker services, and ports
/wt open <slot|branch> — Open a worktree by slot or branch (creates if not found)
/wt list — List all worktree allocations
/wt audit — Classify worktrees by merge state; suggest which are safe to remove
/wt remove <targets...>|--all — Remove one or more worktrees and clean up resources
/wt prune [--dry-run] — Prune Git-prunable worktrees and clean up matching managed resources
/wt prune [--dry-run] [--merged] — Prune Git-prunable (and, with --merged, audit-confirmed merged) worktrees
/wt doctor — Diagnose and fix environment issues
/wt setup [path] — Set up an existing worktree
/wt env seed [path] — Create/fill safe env defaults from examples
Expand Down
30 changes: 30 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { setupCommand } from './commands/setup';
import { removeCommand } from './commands/remove';
import { pruneCommand } from './commands/prune';
import { listCommand } from './commands/list';
import { auditCommand } from './commands/audit';
import { doctorCommand } from './commands/doctor';
import { openCommand } from './commands/open';
import { envSeedCommand } from './commands/env';
Expand Down Expand Up @@ -117,12 +118,41 @@ program
.description('Prune Git-prunable worktrees and clean up managed resources')
.option('--dry-run', 'Show what would be pruned without changing anything', false)
.option('--keep-db', 'Keep databases for managed worktrees (do not drop)', false)
.option('--merged', 'Also remove live worktrees whose branch is merged into the base ref (audit-confirmed, clean only)', false)
.option('--json', 'Output as JSON', false)
.action(async (opts) => {
await pruneCommand({
json: opts.json,
keepDb: opts.keepDb,
dryRun: opts.dryRun,
merged: opts.merged,
});
});

program
.command('audit')
.description('Classify worktrees by how their branch relates to the base ref and suggest which are safe to remove')
.option('--json', 'Output as JSON', false)
.option('--no-fetch', 'Skip fetching origin/main before auditing')
.option('--base <ref>', 'Base ref to audit against (default: origin/main, then main)')
.option('--ignore-dirty <paths...>', 'Worktree path fragments to treat as never-real dirt (e.g. .mcp.json)', [])
.addHelpText(
'after',
[
'',
'Examples:',
' wt audit',
' wt audit --no-fetch',
' wt audit --json | jq \'.data[] | select(.verdict=="delete-merged")\'',
'',
].join('\n'),
)
.action((opts) => {
auditCommand({
json: opts.json,
fetch: opts.fetch,
base: opts.base,
ignoreDirty: opts.ignoreDirty,
});
});

Expand Down
123 changes: 123 additions & 0 deletions src/commands/audit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';

jest.mock('../core/git', () => ({
getMainWorktreePath: jest.fn(),
}));

jest.mock('../core/registry', () => ({
readRegistry: jest.fn(),
}));

jest.mock('../core/audit', () => {
const actual = jest.requireActual('../core/audit') as object;
return {
...actual,
resolveBaseRef: jest.fn(),
fetchBaseRef: jest.fn(),
loadPrStates: jest.fn(),
auditWorktrees: jest.fn(),
};
});

import { getMainWorktreePath } from '../core/git';
import { readRegistry } from '../core/registry';
import { auditWorktrees, fetchBaseRef, loadPrStates, resolveBaseRef, type WorktreeAudit } from '../core/audit';
import { auditCommand } from './audit';
import type { Registry } from '../types';

const mockGetMainWorktreePath = getMainWorktreePath as jest.MockedFunction<typeof getMainWorktreePath>;
const mockReadRegistry = readRegistry as jest.MockedFunction<typeof readRegistry>;
const mockResolveBaseRef = resolveBaseRef as jest.MockedFunction<typeof resolveBaseRef>;
const mockFetchBaseRef = fetchBaseRef as jest.MockedFunction<typeof fetchBaseRef>;
const mockLoadPrStates = loadPrStates as jest.MockedFunction<typeof loadPrStates>;
const mockAuditWorktrees = auditWorktrees as jest.MockedFunction<typeof auditWorktrees>;

function auditOf(slot: number, verdict: WorktreeAudit['verdict']): WorktreeAudit {
return {
slot,
path: `/repo/.worktrees/wt${slot}`,
branch: `feat/${slot}`,
dbName: `app_wt${slot}`,
mergeMethod: verdict === 'delete-merged' ? 'ancestor' : null,
containedIn: [],
realDirty: [],
onRemote: false,
locked: false,
lockedReason: '',
pr: null,
verdict,
reason: '',
};
}

describe('auditCommand', () => {
let consoleLogSpy: jest.SpiedFunction<typeof console.log>;
let consoleErrorSpy: jest.SpiedFunction<typeof console.error>;

beforeEach(() => {
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockGetMainWorktreePath.mockReturnValue('/repo');
mockReadRegistry.mockReturnValue({
version: 1,
allocations: {
'1': { worktreePath: '/repo/.worktrees/wt1', branchName: 'feat/1', dbName: 'app_wt1', ports: {}, createdAt: '2026-06-01T00:00:00.000Z' },
'2': { worktreePath: '/repo/.worktrees/wt2', branchName: 'feat/2', dbName: 'app_wt2', ports: {}, createdAt: '2026-06-01T00:00:00.000Z' },
},
} satisfies Registry);
mockResolveBaseRef.mockReturnValue('origin/main');
mockLoadPrStates.mockReturnValue(new Map());
mockAuditWorktrees.mockReturnValue([auditOf(1, 'delete-merged'), auditOf(2, 'keep-wip')]);
process.exitCode = 0;
});

afterEach(() => {
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
jest.clearAllMocks();
});

it('passes registry slots and the resolved base to the auditor', () => {
auditCommand({ json: true, fetch: true, ignoreDirty: [] });

expect(mockAuditWorktrees).toHaveBeenCalledTimes(1);
const [slots, deps] = mockAuditWorktrees.mock.calls[0]!;
expect(slots).toEqual([
{ slot: 1, path: '/repo/.worktrees/wt1', branch: 'feat/1', dbName: 'app_wt1' },
{ slot: 2, path: '/repo/.worktrees/wt2', branch: 'feat/2', dbName: 'app_wt2' },
]);
expect(deps.base).toBe('origin/main');
expect(deps.repoDir).toBe('/repo');
});

it('renders a human report containing the copyable remove command', () => {
auditCommand({ json: false, fetch: true, ignoreDirty: [] });

const output = consoleLogSpy.mock.calls[0]?.[0] as string;
expect(output).toContain('wt remove 1');
expect(output).toContain('VERDICT');
});

it('emits the audits as JSON when --json is set', () => {
auditCommand({ json: true, fetch: true, ignoreDirty: [] });

const output = JSON.parse(consoleLogSpy.mock.calls[0]?.[0] as string) as {
success: boolean;
data: WorktreeAudit[];
};
expect(output.success).toBe(true);
expect(output.data.map((a) => a.slot)).toEqual([1, 2]);
expect(output.data[0]?.verdict).toBe('delete-merged');
});

it('skips the network fetch when fetch is disabled', () => {
auditCommand({ json: true, fetch: false, ignoreDirty: [] });
expect(mockFetchBaseRef).not.toHaveBeenCalled();
});

it('honors an explicit base override instead of resolving', () => {
auditCommand({ json: true, fetch: false, base: 'origin/develop', ignoreDirty: [] });
expect(mockResolveBaseRef).not.toHaveBeenCalled();
expect(mockAuditWorktrees.mock.calls[0]![1].base).toBe('origin/develop');
});
});
69 changes: 69 additions & 0 deletions src/commands/audit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { getMainWorktreePath } from '../core/git';
import { readRegistry } from '../core/registry';
import {
auditWorktrees,
fetchBaseRef,
loadPrStates,
renderReport,
resolveBaseRef,
type WtSlot,
} from '../core/audit';
import { error, extractErrorMessage, formatJson, success } from '../output';
import type { Allocation } from '../types';

interface AuditOptions {
readonly json: boolean;
readonly fetch: boolean;
readonly base?: string;
readonly ignoreDirty: readonly string[];
}

/** Project the registry's allocations into the audit's slot shape, slot-ascending. */
function slotsFromRegistry(allocations: Record<string, Allocation>): WtSlot[] {
return Object.entries(allocations)
.map(([slot, alloc]) => ({
slot: Number(slot),
path: alloc.worktreePath,
branch: alloc.branchName,
dbName: alloc.dbName,
}))
.sort((a, b) => a.slot - b.slot);
}

/**
* Classify every wt-managed worktree by how its branch relates to the base ref
* and print either a human report (with a copyable `wt remove`) or raw JSON.
*/
export function auditCommand(options: AuditOptions): void {
try {
const repoDir = getMainWorktreePath();
const slots = slotsFromRegistry(readRegistry(repoDir).allocations);

if (options.fetch && !options.base) {
fetchBaseRef(repoDir);
}
const base = options.base ?? resolveBaseRef(repoDir);
const prMap = loadPrStates(repoDir);

const audits = auditWorktrees(slots, {
repoDir,
base,
ignoreDirtyPaths: options.ignoreDirty,
prMap,
});

if (options.json) {
console.log(formatJson(success(audits)));
return;
}
console.log(renderReport(audits, base));
} catch (err) {
const message = extractErrorMessage(err);
if (options.json) {
console.log(formatJson(error('AUDIT_FAILED', message)));
} else {
console.error(`Audit failed: ${message}`);
}
process.exitCode = 1;
}
}
Loading
Loading