dev-disk-doctor 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { sizeOf } from '../core/size.js';
4
+ import { daysAgo, pathExists } from '../core/util.js';
5
+ import { removeAction } from './helpers.js';
6
+ const SKIP = new Set(['.git', 'Library', '.Trash', '.cache', 'vendor', '__pycache__', '.gradle']);
7
+ /** Hidden folders worth entering because AI tools park worktrees there. */
8
+ const HIDDEN_OK = new Set(['.claude', '.codex', '.cursor', '.worktrees', '.conductor']);
9
+ const MAX_DEPTH = 7;
10
+ const MAX_DIRS = 400_000;
11
+ async function artifactKind(dir, name) {
12
+ const sib = (f) => pathExists(path.join(dir, f));
13
+ switch (name) {
14
+ case 'node_modules':
15
+ return { kind: 'node_modules', why: 'Installed JavaScript dependencies.', rebuild: 'npm/pnpm/yarn install' };
16
+ case '.next':
17
+ case '.nuxt':
18
+ case '.svelte-kit':
19
+ case '.turbo':
20
+ case '.expo':
21
+ if (await sib('package.json'))
22
+ return { kind: name, why: 'Framework build output and cache.', rebuild: 'the next dev/build run' };
23
+ return undefined;
24
+ case 'target':
25
+ if (await sib('Cargo.toml'))
26
+ return { kind: 'Rust target', why: 'Cargo build output.', rebuild: 'cargo build' };
27
+ return undefined;
28
+ case 'Pods':
29
+ if (await sib('Podfile'))
30
+ return { kind: 'CocoaPods Pods', why: 'Installed iOS dependencies.', rebuild: 'pod install' };
31
+ return undefined;
32
+ case '.venv':
33
+ case 'venv':
34
+ if (await pathExists(path.join(dir, name, 'pyvenv.cfg')))
35
+ return { kind: 'Python venv', why: 'A virtual environment.', rebuild: 'recreate the venv and reinstall requirements' };
36
+ return undefined;
37
+ default:
38
+ return undefined;
39
+ }
40
+ }
41
+ async function newestMtime(paths) {
42
+ let best;
43
+ for (const p of paths) {
44
+ try {
45
+ const st = await fs.stat(p);
46
+ if (best === undefined || st.mtimeMs > best)
47
+ best = st.mtimeMs;
48
+ }
49
+ catch {
50
+ /* missing */
51
+ }
52
+ }
53
+ return best;
54
+ }
55
+ async function findArtifacts(roots) {
56
+ const out = [];
57
+ let visited = 0;
58
+ const seen = new Set();
59
+ async function walk(dir, depth) {
60
+ if (depth > MAX_DEPTH || visited++ > MAX_DIRS)
61
+ return;
62
+ let entries;
63
+ try {
64
+ entries = await fs.readdir(dir, { withFileTypes: true });
65
+ }
66
+ catch {
67
+ return;
68
+ }
69
+ for (const e of entries) {
70
+ if (!e.isDirectory())
71
+ continue;
72
+ const full = path.join(dir, e.name);
73
+ const a = await artifactKind(dir, e.name);
74
+ if (a) {
75
+ if (!seen.has(full)) {
76
+ seen.add(full);
77
+ out.push({ dir: full, ...a });
78
+ }
79
+ continue; // never descend into an artifact
80
+ }
81
+ if (SKIP.has(e.name))
82
+ continue;
83
+ if (e.name.startsWith('.') && !HIDDEN_OK.has(e.name))
84
+ continue;
85
+ await walk(full, depth + 1);
86
+ }
87
+ }
88
+ await Promise.all(roots.map((r) => walk(r, 0)));
89
+ return out;
90
+ }
91
+ export const projects = {
92
+ id: 'projects',
93
+ title: 'Dependencies and build output in your projects',
94
+ async run(opts) {
95
+ const artifacts = await findArtifacts(opts.roots);
96
+ const findings = [];
97
+ const now = Date.now();
98
+ await Promise.all(artifacts.map(async (a) => {
99
+ const bytes = await sizeOf(a.dir, opts.detectorTimeoutMs);
100
+ if (bytes === undefined || bytes < opts.minBytes)
101
+ return;
102
+ const project = path.dirname(a.dir);
103
+ const activity = (await newestMtime([
104
+ path.join(project, 'package.json'),
105
+ path.join(project, 'package-lock.json'),
106
+ path.join(project, 'pnpm-lock.yaml'),
107
+ path.join(project, 'yarn.lock'),
108
+ path.join(project, 'Cargo.lock'),
109
+ path.join(project, 'Podfile.lock'),
110
+ path.join(project, '.git/HEAD'),
111
+ path.join(project, '.git/index'),
112
+ ])) ?? (await newestMtime([a.dir]));
113
+ const idle = activity === undefined ? undefined : daysAgo(activity, now);
114
+ const stale = idle !== undefined && idle >= opts.staleDays;
115
+ const id = `projects:${a.dir}`;
116
+ const risk = stale ? 'safe' : 'review';
117
+ findings.push({
118
+ id,
119
+ detector: 'projects',
120
+ category: 'projects',
121
+ title: `${a.kind} in ${project.replace(opts.home, '~')}${idle !== undefined ? ` (idle ${idle}d)` : ''}`,
122
+ path: a.dir,
123
+ bytes,
124
+ risk,
125
+ regenerable: true,
126
+ why: `${a.why} Rebuilt with ${a.rebuild}.${stale ? ' The project has not been touched recently.' : ' The project was active recently, so you will likely need this soon.'}`,
127
+ actions: [removeAction(id, a.dir, risk, bytes, stale ? undefined : 'Active project: the next run will need to reinstall or rebuild.')],
128
+ meta: { project, idleDays: idle, stale },
129
+ });
130
+ }));
131
+ return findings;
132
+ },
133
+ };
@@ -0,0 +1,102 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { run } from '../core/exec.js';
4
+ import { sizeOf } from '../core/size.js';
5
+ import { daysAgo, listDir } from '../core/util.js';
6
+ import { manualAction, pathFinding, removeAction } from './helpers.js';
7
+ export const system = {
8
+ id: 'system',
9
+ title: 'Trash, Downloads, logs, backups, snapshots',
10
+ async run(opts) {
11
+ const h = opts.home;
12
+ const findings = [];
13
+ const trash = await pathFinding({
14
+ detector: 'system',
15
+ key: 'trash',
16
+ category: 'system',
17
+ title: 'Trash',
18
+ path: path.join(h, '.Trash'),
19
+ risk: 'review',
20
+ regenerable: false,
21
+ why: 'Deleted files still occupy disk until the Trash is emptied.',
22
+ actions: (id) => [manualAction(id, 'empty', 'Empty the Trash in Finder', 'Finder → Empty Trash', 'review', 'Permanent. Look through it first.')],
23
+ }, opts);
24
+ if (trash)
25
+ findings.push(trash);
26
+ const logs = await pathFinding({
27
+ detector: 'system',
28
+ key: 'logs',
29
+ category: 'system',
30
+ title: 'User logs (~/Library/Logs)',
31
+ path: path.join(h, 'Library/Logs'),
32
+ risk: 'safe',
33
+ regenerable: true,
34
+ why: 'Application and crash logs. Apps recreate them.',
35
+ }, opts);
36
+ if (logs)
37
+ findings.push(logs);
38
+ const backups = await pathFinding({
39
+ detector: 'system',
40
+ key: 'ios-backups',
41
+ category: 'system',
42
+ title: 'iPhone and iPad backups',
43
+ path: path.join(h, 'Library/Application Support/MobileSync/Backup'),
44
+ risk: 'danger',
45
+ regenerable: false,
46
+ why: 'Local device backups made by Finder. They may be the only copy of data from an old device.',
47
+ actions: (id) => [manualAction(id, 'manage', 'Manage backups in Finder', 'Finder → device → Manage Backups…', 'danger', 'Deleting a backup is permanent.')],
48
+ }, opts);
49
+ if (backups)
50
+ findings.push(backups);
51
+ // Large files in Downloads.
52
+ const downloads = path.join(h, 'Downloads');
53
+ const bigFloor = Math.max(opts.minBytes, 500 * 1024 * 1024);
54
+ for (const name of await listDir(downloads)) {
55
+ const p = path.join(downloads, name);
56
+ let st;
57
+ try {
58
+ st = await fs.lstat(p);
59
+ }
60
+ catch {
61
+ continue;
62
+ }
63
+ const bytes = (await sizeOf(p, opts.detectorTimeoutMs)) ?? 0;
64
+ if (bytes < bigFloor)
65
+ continue;
66
+ const id = `system:download:${name}`;
67
+ findings.push({
68
+ id,
69
+ detector: 'system',
70
+ category: 'system',
71
+ title: `Large download: ${name}`,
72
+ path: p,
73
+ bytes,
74
+ risk: 'review',
75
+ regenerable: false,
76
+ why: `Sits in Downloads, last modified ${daysAgo(st.mtimeMs)} days ago. Installers and disk images are usually safe to delete once installed.`,
77
+ actions: [removeAction(id, p, 'review', bytes, 'This is a personal file, not a cache. Check what it is first.', 'Delete this download')],
78
+ });
79
+ }
80
+ if (opts.platform === 'darwin') {
81
+ const snap = await run('tmutil', ['listlocalsnapshots', '/'], { timeoutMs: 15_000 });
82
+ const count = snap.ok ? snap.stdout.split('\n').filter((l) => l.includes('com.apple.TimeMachine')).length : 0;
83
+ if (count > 0) {
84
+ const id = 'system:tm-snapshots';
85
+ findings.push({
86
+ id,
87
+ detector: 'system',
88
+ category: 'system',
89
+ title: `Time Machine local snapshots (${count})`,
90
+ bytes: 0,
91
+ risk: 'review',
92
+ regenerable: true,
93
+ why: 'macOS keeps local snapshots on this disk and normally frees them when space runs low, but they can inflate "used" space. Their size is not reported by du.',
94
+ actions: [
95
+ manualAction(id, 'thin', 'Thin local snapshots', 'sudo tmutil thinlocalsnapshots / 50000000000 4', 'review', 'Needs sudo. Snapshots are only a local convenience; your real backup is on the backup disk.'),
96
+ ],
97
+ });
98
+ }
99
+ }
100
+ return findings;
101
+ },
102
+ };
@@ -0,0 +1,77 @@
1
+ import path from 'node:path';
2
+ import { execAction, manualAction, pathFindings } from './helpers.js';
3
+ import { sizeOf } from '../core/size.js';
4
+ export const xcode = {
5
+ id: 'xcode',
6
+ title: 'Xcode and iOS Simulator',
7
+ async run(opts) {
8
+ if (opts.platform !== 'darwin')
9
+ return [];
10
+ const dev = path.join(opts.home, 'Library/Developer');
11
+ const findings = await pathFindings([
12
+ {
13
+ detector: 'xcode',
14
+ key: 'derived-data',
15
+ category: 'mobile',
16
+ title: 'Xcode DerivedData',
17
+ path: path.join(dev, 'Xcode/DerivedData'),
18
+ risk: 'safe',
19
+ regenerable: true,
20
+ why: 'Build products and indexes. Xcode rebuilds them; the next build is just slower.',
21
+ warning: 'Quit Xcode first.',
22
+ },
23
+ {
24
+ detector: 'xcode',
25
+ key: 'device-support',
26
+ category: 'mobile',
27
+ title: 'Xcode iOS DeviceSupport',
28
+ path: path.join(dev, 'Xcode/iOS DeviceSupport'),
29
+ risk: 'safe',
30
+ regenerable: true,
31
+ why: 'Debug symbols copied from every iPhone or iPad you ever plugged in, one folder per iOS version. Recreated the next time that device connects.',
32
+ },
33
+ {
34
+ detector: 'xcode',
35
+ key: 'archives',
36
+ category: 'mobile',
37
+ title: 'Xcode Archives',
38
+ path: path.join(dev, 'Xcode/Archives'),
39
+ risk: 'review',
40
+ regenerable: false,
41
+ why: 'Past app builds, including the dSYMs needed to symbolicate crash reports for released versions.',
42
+ removable: false,
43
+ },
44
+ {
45
+ detector: 'xcode',
46
+ key: 'simulator-caches',
47
+ category: 'mobile',
48
+ title: 'CoreSimulator caches',
49
+ path: path.join(dev, 'CoreSimulator/Caches'),
50
+ risk: 'safe',
51
+ regenerable: true,
52
+ why: 'Simulator dyld and runtime caches. Rebuilt on the next simulator boot.',
53
+ },
54
+ ], opts);
55
+ const sims = path.join(dev, 'CoreSimulator/Devices');
56
+ const simBytes = await sizeOf(sims, opts.detectorTimeoutMs);
57
+ if (simBytes !== undefined && simBytes >= opts.minBytes) {
58
+ const id = 'xcode:simulators';
59
+ findings.push({
60
+ id,
61
+ detector: 'xcode',
62
+ category: 'mobile',
63
+ title: 'iOS Simulator devices and their data',
64
+ path: sims,
65
+ bytes: simBytes,
66
+ risk: 'review',
67
+ regenerable: false,
68
+ why: 'Every simulator keeps its own installed apps and data. Simulators for runtimes you no longer have are dead weight.',
69
+ actions: [
70
+ execAction(id, 'delete-unavailable', 'Delete simulators whose runtime is gone', 'xcrun', ['simctl', 'delete', 'unavailable'], 'safe'),
71
+ manualAction(id, 'list', 'See which simulators exist', 'xcrun simctl list devices', 'safe'),
72
+ ],
73
+ });
74
+ }
75
+ return findings;
76
+ },
77
+ };
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { z } from 'zod';
6
+ import { applyAction } from './core/fix.js';
7
+ import { buildOptions, scan } from './core/scan.js';
8
+ import { fmtBytes } from './core/util.js';
9
+ import { version } from './core/version.js';
10
+ import { auditWorktrees } from './worktrees/audit.js';
11
+ import { auditToFindings } from './worktrees/detector.js';
12
+ const text = (summary, data) => ({
13
+ content: [
14
+ { type: 'text', text: summary },
15
+ { type: 'text', text: JSON.stringify(data) },
16
+ ],
17
+ });
18
+ const scanShape = {
19
+ roots: z.array(z.string()).optional().describe('Workspace folders to search for projects and repos. Default: ~/dev, ~/code, ~/src, ~/projects, ~/work… whichever exist.'),
20
+ min_size_mb: z.number().optional().describe('Ignore items smaller than this. Default 100.'),
21
+ stale_days: z.number().optional().describe('Days idle before a project or worktree is stale. Default 30.'),
22
+ };
23
+ export async function startMcp() {
24
+ const server = new McpServer({ name: 'dev-disk-doctor', version: version() });
25
+ server.registerTool('disk_scan', {
26
+ title: 'Scan for disk space hogs',
27
+ description: 'Read-only scan of a developer machine for what is using disk space: Docker, Android SDK/emulators, Xcode, package caches, test browsers, AI models, node_modules and build output, git worktrees, Trash and Downloads. Returns ranked findings, each with a plain-English explanation, a risk level (safe/review/danger) and the actions available. Present the findings to the user and let them choose. Never apply an action without the user agreeing to that specific item.',
28
+ inputSchema: {
29
+ ...scanShape,
30
+ only: z.array(z.string()).optional().describe('Restrict to detectors: docker, android, xcode, pkgcaches, browsers, models, projects, worktrees, system, biggest.'),
31
+ deep: z.boolean().optional().describe('Also sweep the whole home folder for unknown hogs. Slow (minutes).'),
32
+ limit: z.number().optional().describe('Return only the N largest findings. Default 25.'),
33
+ },
34
+ annotations: { readOnlyHint: true, openWorldHint: false },
35
+ }, async (a) => {
36
+ const opts = await buildOptions({
37
+ roots: a.roots,
38
+ minBytes: a.min_size_mb !== undefined ? a.min_size_mb * 1024 * 1024 : undefined,
39
+ staleDays: a.stale_days,
40
+ deep: a.deep,
41
+ only: a.only,
42
+ });
43
+ const result = await scan(opts);
44
+ const limit = a.limit ?? 25;
45
+ const shown = { ...result, findings: result.findings.slice(0, limit) };
46
+ const free = result.disk ? `${fmtBytes(result.disk.freeBytes)} free of ${fmtBytes(result.disk.totalBytes)}. ` : '';
47
+ return text(`${free}Found ${fmtBytes(result.totals.bytes)} in ${result.totals.findings} items; ${fmtBytes(result.totals.reclaimableBytes)} reclaimable with actions the tool can run. Showing ${shown.findings.length}.`, shown);
48
+ });
49
+ server.registerTool('worktree_audit', {
50
+ title: 'Audit git worktrees across all repos',
51
+ description: 'Read-only. Finds every git repo under the workspace roots and every linked worktree (including ones created by Claude Code, Codex, Cursor and Conductor), and classifies each as active, stale, dangling, locked, or at-risk (uncommitted or unpushed work). Also finds orphaned worktree folders whose repo no longer knows them. Use this to answer "how many worktrees do I have and which are leftovers?".',
52
+ inputSchema: scanShape,
53
+ annotations: { readOnlyHint: true, openWorldHint: false },
54
+ }, async (a) => {
55
+ const opts = await buildOptions({
56
+ roots: a.roots,
57
+ minBytes: a.min_size_mb !== undefined ? a.min_size_mb * 1024 * 1024 : undefined,
58
+ staleDays: a.stale_days,
59
+ });
60
+ const audit = await auditWorktrees(opts);
61
+ const t = audit.totals;
62
+ return text(`${t.repos} repos, ${t.linkedWorktrees} linked worktrees in ${t.reposWithLinkedWorktrees} repos (${t.byStatus.stale} stale, ${t.byStatus['at-risk']} at risk, ${t.byStatus.prunable} dangling, ${t.orphans} orphaned folders), ${fmtBytes(t.bytes)} total.`, { audit, findings: auditToFindings(audit, opts) });
63
+ });
64
+ server.registerTool('explain_finding', {
65
+ title: 'Explain one finding and its actions',
66
+ description: 'Re-checks a single finding by id (from disk_scan) and returns its current size, explanation, risks and actions. Read-only.',
67
+ inputSchema: { finding_id: z.string().describe('e.g. "docker:images" or "android:image:android-34/android-tv/arm64-v8a"'), ...scanShape },
68
+ annotations: { readOnlyHint: true, openWorldHint: false },
69
+ }, async (a) => {
70
+ const detector = a.finding_id.split(':')[0];
71
+ const opts = await buildOptions({ roots: a.roots, only: [detector], minBytes: 0 });
72
+ const result = await scan(opts);
73
+ const f = result.findings.find((x) => x.id === a.finding_id);
74
+ return f ? text(`${f.title}: ${fmtBytes(f.bytes)}, risk ${f.risk}.`, f) : text(`No finding "${a.finding_id}" right now. It may already be gone.`, null);
75
+ });
76
+ server.registerTool('apply_action', {
77
+ title: 'Apply one cleanup action',
78
+ description: 'Runs ONE allowlisted action from a finding. Dry run by default: it returns the exact command and warning without changing anything. Only set confirm=true after the user has explicitly agreed to this specific action, and read the action\'s warning to them first. Actions with risk "danger" and manual actions are always refused. State is re-checked before running, so a stale id fails safely.',
79
+ inputSchema: {
80
+ action_id: z.string().describe('The action id from a finding, e.g. "docker:build-cache#prune".'),
81
+ confirm: z.boolean().optional().describe('true = actually run it. Omit for a dry run.'),
82
+ roots: scanShape.roots,
83
+ },
84
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
85
+ }, async (a) => {
86
+ const opts = await buildOptions({ roots: a.roots });
87
+ const res = await applyAction(opts, a.action_id, a.confirm === true);
88
+ return text(res.message, res);
89
+ });
90
+ await server.connect(new StdioServerTransport());
91
+ }
92
+ // Run directly as `dev-disk-doctor-mcp`.
93
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
94
+ startMcp().catch((e) => {
95
+ console.error(e);
96
+ process.exit(1);
97
+ });
98
+ }
@@ -0,0 +1,83 @@
1
+ import { fmtBytes } from '../core/util.js';
2
+ function paint(style) {
3
+ const c = (code) => (s) => (style.color ? `\u001b[${code}m${s}\u001b[0m` : s);
4
+ return { bold: c('1'), dim: c('2'), red: c('31'), yellow: c('33'), green: c('32'), cyan: c('36') };
5
+ }
6
+ const RISK_LABEL = { safe: 'SAFE', review: 'REVIEW', danger: 'CAREFUL' };
7
+ export function renderScan(r, style, opts = {}) {
8
+ const p = paint(style);
9
+ const riskTag = (risk) => (risk === 'safe' ? p.green : risk === 'review' ? p.yellow : p.red)(`[${RISK_LABEL[risk]}]`);
10
+ const out = [];
11
+ out.push(p.bold('dev-disk-doctor'));
12
+ if (r.disk) {
13
+ const used = r.disk.totalBytes - r.disk.freeBytes;
14
+ out.push(`Disk: ${fmtBytes(r.disk.totalBytes)} total, ${fmtBytes(used)} used, ${p.bold(fmtBytes(r.disk.freeBytes))} free`);
15
+ }
16
+ out.push(`Found ${p.bold(fmtBytes(r.totals.bytes))} across ${r.totals.findings} items; ` +
17
+ `${p.bold(fmtBytes(r.totals.reclaimableBytes))} could be reclaimed with actions this tool can run ` +
18
+ `(${fmtBytes(r.totals.bytesByRisk.safe)} safe, ${fmtBytes(r.totals.bytesByRisk.review)} needs a look, ${fmtBytes(r.totals.bytesByRisk.danger)} careful).`);
19
+ out.push(p.dim(`Searched for projects in: ${r.options.roots.map((x) => x.replace(r.host.home, '~')).join(', ') || 'no workspace folders found (use --root <dir>)'}`));
20
+ out.push('');
21
+ const limit = opts.limit ?? 25;
22
+ const shown = r.findings.slice(0, limit);
23
+ shown.forEach((f, i) => out.push(renderFinding(f, i + 1, p, riskTag)));
24
+ if (r.findings.length > shown.length)
25
+ out.push(p.dim(`… ${r.findings.length - shown.length} smaller items hidden. Use --all to see everything.`));
26
+ if (r.skipped.length) {
27
+ out.push('');
28
+ for (const s of r.skipped)
29
+ out.push(p.dim(`Skipped ${s.detector}: ${s.reason}`));
30
+ }
31
+ out.push('');
32
+ out.push(p.dim('Nothing was changed. To act on an item: dev-disk-doctor fix "<action id>" (dry run), then add --yes.'));
33
+ return out.join('\n');
34
+ }
35
+ function renderFinding(f, n, p, tag) {
36
+ const lines = [];
37
+ lines.push(`${String(n).padStart(2)}. ${p.bold(fmtBytes(f.bytes).padStart(8))} ${tag(f.risk)} ${f.title}`);
38
+ lines.push(` ${p.dim(f.why)}`);
39
+ if (f.path)
40
+ lines.push(` ${p.dim(f.path)}`);
41
+ for (const a of f.actions) {
42
+ const mark = a.kind.type === 'manual' ? 'manual' : a.risk === 'safe' ? 'can run' : 'asks first';
43
+ lines.push(` ${p.cyan('→')} ${a.label} ${p.dim(`(${mark})`)}`);
44
+ lines.push(` ${p.dim('$')} ${a.display}`);
45
+ if (a.warning)
46
+ lines.push(` ${p.yellow('! ' + a.warning)}`);
47
+ lines.push(` ${p.dim('id: ' + a.id)}`);
48
+ }
49
+ lines.push('');
50
+ return lines.join('\n');
51
+ }
52
+ const STATUS_ORDER = { 'at-risk': 0, stale: 1, prunable: 2, locked: 3, active: 4 };
53
+ export function renderWorktrees(a, style, home) {
54
+ const p = paint(style);
55
+ const short = (s) => s.replace(home, '~');
56
+ const t = a.totals;
57
+ const out = [];
58
+ out.push(p.bold('Git worktree audit'));
59
+ out.push(`${t.repos} repos found; ${p.bold(String(t.linkedWorktrees))} linked worktrees across ${t.reposWithLinkedWorktrees} of them, using ${fmtBytes(t.bytes)}.`);
60
+ out.push(`${t.byStatus.active} active · ${p.yellow(String(t.byStatus.stale))} stale · ${p.red(String(t.byStatus['at-risk']))} at risk · ${t.byStatus.prunable} dangling records · ${t.byStatus.locked} locked · ${t.orphans} orphaned folders`);
61
+ out.push(p.dim(`Searched: ${a.rootsScanned.map(short).join(', ') || '(no workspace roots found; pass --root)'}`));
62
+ out.push('');
63
+ for (const repo of a.repos.filter((r) => r.worktrees.some((w) => !w.isMain))) {
64
+ const linked = repo.worktrees.filter((w) => !w.isMain).sort((x, y) => STATUS_ORDER[x.status] - STATUS_ORDER[y.status] || (y.bytes ?? 0) - (x.bytes ?? 0));
65
+ out.push(p.bold(`${short(repo.repo)} (${linked.length} linked)`));
66
+ for (const w of linked)
67
+ out.push(worktreeLine(w, p, short));
68
+ out.push('');
69
+ }
70
+ if (a.orphans.length) {
71
+ out.push(p.bold('Orphaned folders (no repository owns them any more)'));
72
+ for (const o of a.orphans)
73
+ out.push(` ${p.yellow('orphan')} ${fmtBytes(o.bytes ?? 0).padStart(8)} ${short(o.path)}${o.idleDays !== undefined ? ` idle ${o.idleDays}d` : ''}`);
74
+ out.push('');
75
+ }
76
+ return out.join('\n');
77
+ }
78
+ function worktreeLine(w, p, short) {
79
+ const color = w.status === 'at-risk' ? p.red : w.status === 'stale' ? p.yellow : w.status === 'active' ? p.green : p.dim;
80
+ const who = w.toolOwned ? ` ${p.dim('(' + w.toolOwned + ')')}` : '';
81
+ const br = w.branch ?? (w.detached ? 'detached' : '?');
82
+ return ` ${color(w.status.padEnd(8))} ${fmtBytes(w.bytes ?? 0).padStart(8)} ${short(w.path)} [${br}]${who}\n ${p.dim(w.reason)}`;
83
+ }