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,231 @@
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, isDir, pathExists } from '../core/util.js';
6
+ import { discover, realpathSafe, repoFromGitdir, toolWorktreeRoots, parseGitFile } from './discover.js';
7
+ export function parseWorktreeList(porcelain) {
8
+ const out = [];
9
+ for (const block of porcelain.split(/\n\s*\n/)) {
10
+ const lines = block.split('\n').filter(Boolean);
11
+ if (!lines.length)
12
+ continue;
13
+ const w = { path: '', detached: false, locked: false, prunable: false, bare: false };
14
+ for (const line of lines) {
15
+ const [key, ...rest] = line.split(' ');
16
+ const val = rest.join(' ');
17
+ if (key === 'worktree')
18
+ w.path = val;
19
+ else if (key === 'HEAD')
20
+ w.head = val;
21
+ else if (key === 'branch')
22
+ w.branch = val.replace(/^refs\/heads\//, '');
23
+ else if (key === 'detached')
24
+ w.detached = true;
25
+ else if (key === 'bare')
26
+ w.bare = true;
27
+ else if (key === 'locked') {
28
+ w.locked = true;
29
+ w.lockReason = val || undefined;
30
+ }
31
+ else if (key === 'prunable')
32
+ w.prunable = true;
33
+ }
34
+ if (w.path)
35
+ out.push(w);
36
+ }
37
+ return out;
38
+ }
39
+ function toolOwner(p) {
40
+ const s = p.split(path.sep).join('/');
41
+ if (s.includes('/.claude/worktrees/'))
42
+ return 'claude';
43
+ if (s.includes('/.codex/worktrees/'))
44
+ return 'codex';
45
+ if (s.includes('/.cursor/worktrees/'))
46
+ return 'cursor';
47
+ if (s.includes('/conductor/'))
48
+ return 'conductor';
49
+ if (s.includes('/.worktrees/'))
50
+ return 'worktrees-dir';
51
+ return undefined;
52
+ }
53
+ async function git(cwd, args, timeoutMs = 20_000) {
54
+ return run('git', ['-C', cwd, ...args], { timeoutMs });
55
+ }
56
+ async function defaultRef(repo) {
57
+ const head = await git(repo, ['symbolic-ref', '-q', '--short', 'refs/remotes/origin/HEAD']);
58
+ if (head.ok && head.stdout.trim())
59
+ return head.stdout.trim();
60
+ for (const ref of ['origin/main', 'origin/master', 'main', 'master']) {
61
+ const r = await git(repo, ['rev-parse', '--verify', '-q', ref]);
62
+ if (r.ok)
63
+ return ref;
64
+ }
65
+ return undefined;
66
+ }
67
+ async function mtimeMs(p) {
68
+ try {
69
+ return (await fs.stat(p)).mtimeMs;
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ }
75
+ async function inspectLinked(l, repo, defRef, opts) {
76
+ const info = {
77
+ repo,
78
+ path: l.path,
79
+ isMain: false,
80
+ branch: l.branch,
81
+ head: l.head,
82
+ detached: l.detached,
83
+ locked: l.locked,
84
+ lockReason: l.lockReason,
85
+ exists: await isDir(l.path),
86
+ prunable: l.prunable,
87
+ toolOwned: toolOwner(l.path),
88
+ status: 'active',
89
+ reason: '',
90
+ };
91
+ if (!info.exists || info.prunable) {
92
+ info.status = 'prunable';
93
+ info.reason = 'The folder is gone but git still has a record of it.';
94
+ return info;
95
+ }
96
+ const [status, unpushed, merged, commitTime, size] = await Promise.all([
97
+ git(l.path, ['status', '--porcelain']),
98
+ git(l.path, ['rev-list', '--count', 'HEAD', '--not', '--remotes']),
99
+ defRef ? git(l.path, ['merge-base', '--is-ancestor', 'HEAD', defRef]) : Promise.resolve(undefined),
100
+ git(l.path, ['log', '-1', '--format=%ct']),
101
+ sizeOf(l.path, opts.detectorTimeoutMs),
102
+ ]);
103
+ info.dirty = status.ok ? status.stdout.trim().length > 0 : undefined;
104
+ info.unpushed = unpushed.ok ? Number(unpushed.stdout.trim()) : undefined;
105
+ info.merged = merged ? merged.ok : undefined;
106
+ info.bytes = size;
107
+ const gitdir = await parseGitFile(l.path);
108
+ const times = [
109
+ commitTime.ok ? Number(commitTime.stdout.trim()) * 1000 : undefined,
110
+ gitdir ? await mtimeMs(path.join(gitdir, 'index')) : undefined,
111
+ gitdir ? await mtimeMs(path.join(gitdir, 'HEAD')) : undefined,
112
+ await mtimeMs(l.path),
113
+ ].filter((t) => t !== undefined && Number.isFinite(t));
114
+ if (times.length) {
115
+ info.lastActivityMs = Math.max(...times);
116
+ info.idleDays = daysAgo(info.lastActivityMs);
117
+ }
118
+ Object.assign(info, classify(info, opts.staleDays));
119
+ return info;
120
+ }
121
+ export function classify(w, staleDays) {
122
+ if (w.locked)
123
+ return { status: 'locked', reason: `Locked${w.lockReason ? ` (${w.lockReason})` : ''}; git will not remove it.` };
124
+ if (w.dirty === undefined || w.unpushed === undefined) {
125
+ return { status: 'at-risk', reason: 'Could not read its git state, so it is not safe to assume it is disposable.' };
126
+ }
127
+ const problems = [];
128
+ if (w.dirty)
129
+ problems.push('has uncommitted or untracked changes');
130
+ if (w.unpushed > 0 && !w.merged)
131
+ problems.push(`has ${w.unpushed} commit${w.unpushed === 1 ? '' : 's'} no remote has`);
132
+ if (problems.length)
133
+ return { status: 'at-risk', reason: `Work would be lost: it ${problems.join(' and ')}.` };
134
+ const idle = w.idleDays ?? 0;
135
+ if (idle >= staleDays) {
136
+ return {
137
+ status: 'stale',
138
+ reason: `Clean, ${w.merged ? 'merged into the default branch' : 'fully pushed'}, and idle for ${idle} days.`,
139
+ };
140
+ }
141
+ return { status: 'active', reason: `Touched ${idle} day${idle === 1 ? '' : 's'} ago.` };
142
+ }
143
+ async function pool(items, size, fn) {
144
+ const out = new Array(items.length);
145
+ let next = 0;
146
+ await Promise.all(Array.from({ length: Math.min(size, items.length) }, async () => {
147
+ while (next < items.length) {
148
+ const i = next++;
149
+ out[i] = await fn(items[i]);
150
+ }
151
+ }));
152
+ return out;
153
+ }
154
+ export async function auditWorktrees(opts) {
155
+ const roots = [...new Set([...opts.roots, ...toolWorktreeRoots(opts.home)])];
156
+ const found = await discover(roots);
157
+ // Repos come from two places: the walk, and the repos that stray worktrees point back to.
158
+ const repoSet = new Map(); // realpath → path
159
+ for (const r of found.repos)
160
+ repoSet.set(await realpathSafe(r), r);
161
+ for (const l of found.linked) {
162
+ const repo = repoFromGitdir(l.gitdir);
163
+ if (repo && (await pathExists(path.join(repo, '.git'))))
164
+ repoSet.set(await realpathSafe(repo), repo);
165
+ }
166
+ const known = new Set();
167
+ const audits = [];
168
+ await pool([...repoSet.values()], 4, async (repo) => {
169
+ const list = await git(repo, ['worktree', 'list', '--porcelain']);
170
+ if (!list.ok)
171
+ return;
172
+ const listed = parseWorktreeList(list.stdout).filter((w) => !w.bare);
173
+ const defRef = await defaultRef(repo);
174
+ const repoReal = await realpathSafe(repo);
175
+ const worktrees = [];
176
+ const linked = [];
177
+ for (const w of listed) {
178
+ known.add(await realpathSafe(w.path));
179
+ if ((await realpathSafe(w.path)) === repoReal) {
180
+ worktrees.push({
181
+ repo, path: w.path, isMain: true, branch: w.branch, head: w.head, detached: w.detached, locked: false,
182
+ exists: true, prunable: false, status: 'active', reason: 'Main working copy.',
183
+ });
184
+ }
185
+ else
186
+ linked.push(w);
187
+ }
188
+ worktrees.push(...(await pool(linked, 6, (w) => inspectLinked(w, repo, defRef, opts))));
189
+ audits.push({ repo, defaultBranch: defRef, worktrees });
190
+ });
191
+ // Folders carrying a worktree `.git` file that no repository lists any more.
192
+ const orphans = [];
193
+ for (const l of found.linked) {
194
+ if (known.has(await realpathSafe(l.dir)))
195
+ continue;
196
+ const gitdirExists = await pathExists(l.gitdir);
197
+ if (gitdirExists)
198
+ continue; // still registered somewhere we did not list; leave it alone
199
+ const bytes = await sizeOf(l.dir, opts.detectorTimeoutMs);
200
+ const mt = await mtimeMs(l.dir);
201
+ orphans.push({
202
+ path: l.dir,
203
+ gitdir: l.gitdir,
204
+ bytes,
205
+ idleDays: mt ? daysAgo(mt) : undefined,
206
+ reason: 'Its repository no longer knows about it (the repo was moved, deleted, or the worktree record was pruned). Git can no longer tell you if it holds unsaved work.',
207
+ });
208
+ }
209
+ audits.sort((a, b) => a.repo.localeCompare(b.repo));
210
+ const byStatus = { active: 0, stale: 0, prunable: 0, 'at-risk': 0, locked: 0 };
211
+ let linkedCount = 0;
212
+ let reposWith = 0;
213
+ let bytes = 0;
214
+ for (const a of audits) {
215
+ const l = a.worktrees.filter((w) => !w.isMain);
216
+ if (l.length)
217
+ reposWith++;
218
+ linkedCount += l.length;
219
+ for (const w of l) {
220
+ byStatus[w.status]++;
221
+ bytes += w.bytes ?? 0;
222
+ }
223
+ }
224
+ bytes += orphans.reduce((s, o) => s + (o.bytes ?? 0), 0);
225
+ return {
226
+ rootsScanned: roots,
227
+ repos: audits,
228
+ orphans,
229
+ totals: { repos: audits.length, reposWithLinkedWorktrees: reposWith, linkedWorktrees: linkedCount, byStatus, orphans: orphans.length, bytes },
230
+ };
231
+ }
@@ -0,0 +1,93 @@
1
+ import { execAction, manualAction, removeAction } from '../detectors/helpers.js';
2
+ import { auditWorktrees } from './audit.js';
3
+ /** Turn an audit into findings. Active worktrees are not findings; they only show in the audit. */
4
+ export function auditToFindings(audit, opts) {
5
+ const findings = [];
6
+ const short = (p) => p.replace(opts.home, '~');
7
+ for (const a of audit.repos) {
8
+ const prunable = a.worktrees.filter((w) => w.status === 'prunable');
9
+ if (prunable.length) {
10
+ const id = `worktrees:prune:${a.repo}`;
11
+ findings.push({
12
+ id,
13
+ detector: 'worktrees',
14
+ category: 'worktrees',
15
+ title: `${prunable.length} dangling worktree record${prunable.length === 1 ? '' : 's'} in ${short(a.repo)}`,
16
+ path: a.repo,
17
+ bytes: 0,
18
+ risk: 'safe',
19
+ regenerable: true,
20
+ why: `Git still lists worktrees whose folders were deleted: ${prunable.map((w) => short(w.path)).join(', ')}. Pruning removes only the stale records.`,
21
+ actions: [execAction(id, 'prune', 'Run git worktree prune', 'git', ['worktree', 'prune'], 'safe', { cwd: a.repo })],
22
+ });
23
+ }
24
+ for (const w of a.worktrees) {
25
+ if (w.isMain || (w.status !== 'stale' && w.status !== 'at-risk' && w.status !== 'locked'))
26
+ continue;
27
+ const id = `worktrees:wt:${w.path}`;
28
+ const label = `${short(w.path)}${w.branch ? ` [${w.branch}]` : w.detached ? ' [detached]' : ''}${w.toolOwned ? ` (${w.toolOwned})` : ''}`;
29
+ if (w.status === 'stale') {
30
+ findings.push({
31
+ id,
32
+ detector: 'worktrees',
33
+ category: 'worktrees',
34
+ title: `Stale worktree ${label} of ${short(a.repo)}`,
35
+ path: w.path,
36
+ bytes: w.bytes ?? 0,
37
+ risk: 'review',
38
+ regenerable: false,
39
+ why: w.reason,
40
+ actions: [
41
+ execAction(id, 'remove', 'Remove this worktree (git worktree remove)', 'git', ['worktree', 'remove', w.path], 'review', {
42
+ bytes: w.bytes,
43
+ cwd: a.repo,
44
+ warning: w.branch ? `The branch "${w.branch}" is kept; only the checkout folder is removed. Git refuses if anything changed since the scan.` : 'Git refuses if anything changed since the scan.',
45
+ }),
46
+ ],
47
+ meta: { repo: a.repo, branch: w.branch, idleDays: w.idleDays, toolOwned: w.toolOwned },
48
+ });
49
+ }
50
+ else {
51
+ findings.push({
52
+ id,
53
+ detector: 'worktrees',
54
+ category: 'worktrees',
55
+ title: `${w.status === 'locked' ? 'Locked' : 'At-risk'} worktree ${label} of ${short(a.repo)}`,
56
+ path: w.path,
57
+ bytes: w.bytes ?? 0,
58
+ risk: 'danger',
59
+ regenerable: false,
60
+ why: w.reason,
61
+ actions: [
62
+ manualAction(id, 'inspect', 'Look before deleting', `git -C ${JSON.stringify(w.path)} status && git -C ${JSON.stringify(w.path)} log --branches --not --remotes --oneline`, 'danger', 'This worktree may hold work that exists nowhere else. Commit and push it, or decide to discard it yourself.'),
63
+ ],
64
+ meta: { repo: a.repo, branch: w.branch, idleDays: w.idleDays, dirty: w.dirty, unpushed: w.unpushed, toolOwned: w.toolOwned },
65
+ });
66
+ }
67
+ }
68
+ }
69
+ for (const o of audit.orphans) {
70
+ const id = `worktrees:orphan:${o.path}`;
71
+ findings.push({
72
+ id,
73
+ detector: 'worktrees',
74
+ category: 'worktrees',
75
+ title: `Orphaned worktree folder ${short(o.path)}`,
76
+ path: o.path,
77
+ bytes: o.bytes ?? 0,
78
+ risk: 'review',
79
+ regenerable: false,
80
+ why: o.reason,
81
+ actions: [removeAction(id, o.path, 'review', o.bytes ?? 0, 'Open the folder first: files here are no longer tracked by git and cannot be recovered after deletion.')],
82
+ });
83
+ }
84
+ return findings;
85
+ }
86
+ export const worktrees = {
87
+ id: 'worktrees',
88
+ title: 'Git worktrees across all your repos',
89
+ async run(opts) {
90
+ const audit = await auditWorktrees(opts);
91
+ return auditToFindings(audit, opts);
92
+ },
93
+ };
@@ -0,0 +1,81 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { isDir, readText } from '../core/util.js';
4
+ const SKIP = new Set([
5
+ 'node_modules', '.git', 'Library', '.Trash', 'vendor', 'Pods', 'target', 'build', 'dist',
6
+ '.next', 'venv', '.venv', '__pycache__', '.gradle', '.cache', '.npm', '.cargo', '.rustup',
7
+ ]);
8
+ const HIDDEN_OK = new Set(['.claude', '.codex', '.cursor', '.worktrees', '.conductor']);
9
+ const MAX_DEPTH = 7;
10
+ const MAX_DIRS = 400_000;
11
+ export async function realpathSafe(p) {
12
+ try {
13
+ return await fs.realpath(p);
14
+ }
15
+ catch {
16
+ return path.resolve(p);
17
+ }
18
+ }
19
+ /** Where AI coding tools tend to park worktrees. Included automatically when present. */
20
+ export function toolWorktreeRoots(home) {
21
+ return [
22
+ path.join(home, '.codex/worktrees'),
23
+ path.join(home, '.cursor/worktrees'),
24
+ path.join(home, '.claude/worktrees'),
25
+ path.join(home, 'conductor'),
26
+ ];
27
+ }
28
+ export async function parseGitFile(dir) {
29
+ const text = await readText(path.join(dir, '.git'));
30
+ const m = text && /^gitdir:\s*(.+)$/m.exec(text);
31
+ if (!m)
32
+ return undefined;
33
+ const raw = m[1].trim();
34
+ return path.isAbsolute(raw) ? raw : path.resolve(dir, raw);
35
+ }
36
+ /** `<repo>/.git/worktrees/<name>` → `<repo>`; anything else (submodules etc.) → undefined. */
37
+ export function repoFromGitdir(gitdir) {
38
+ const parent = path.dirname(gitdir);
39
+ if (path.basename(parent) !== 'worktrees')
40
+ return undefined;
41
+ const dotGit = path.dirname(parent);
42
+ if (path.basename(dotGit) !== '.git')
43
+ return undefined;
44
+ return path.dirname(dotGit);
45
+ }
46
+ export async function discover(roots) {
47
+ const repos = new Set();
48
+ const linked = new Map();
49
+ let visited = 0;
50
+ async function walk(dir, depth) {
51
+ if (depth > MAX_DEPTH || visited++ > MAX_DIRS)
52
+ return;
53
+ let entries;
54
+ try {
55
+ entries = await fs.readdir(dir, { withFileTypes: true });
56
+ }
57
+ catch {
58
+ return;
59
+ }
60
+ const dotGit = entries.find((e) => e.name === '.git');
61
+ if (dotGit?.isDirectory())
62
+ repos.add(dir);
63
+ else if (dotGit?.isFile()) {
64
+ const gitdir = await parseGitFile(dir);
65
+ if (gitdir && gitdir.includes(`${path.sep}worktrees${path.sep}`))
66
+ linked.set(dir, gitdir);
67
+ return; // a linked worktree is a leaf; its content belongs to that worktree
68
+ }
69
+ for (const e of entries) {
70
+ if (!e.isDirectory() || SKIP.has(e.name))
71
+ continue;
72
+ if (e.name.startsWith('.') && !HIDDEN_OK.has(e.name))
73
+ continue;
74
+ await walk(path.join(dir, e.name), depth + 1);
75
+ }
76
+ }
77
+ for (const r of roots)
78
+ if (await isDir(r))
79
+ await walk(r, 0);
80
+ return { repos: [...repos], linked: [...linked].map(([dir, gitdir]) => ({ dir, gitdir })) };
81
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "dev-disk-doctor",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Why is my disk full? A read-only disk and git-worktree doctor for developer machines (Docker, Xcode, Android, node_modules, caches). CLI + MCP server for AI agents.",
5
+ "license": "MIT",
6
+ "author": "Prageeth Silva",
7
+ "type": "module",
8
+ "bin": {
9
+ "dev-disk-doctor": "dist/src/cli.js",
10
+ "dev-disk-doctor-mcp": "dist/src/mcp.js"
11
+ },
12
+ "files": [
13
+ "dist/src",
14
+ "skills",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.json",
23
+ "prepublishOnly": "npm test",
24
+ "test": "npm run build && node --test dist/test/*.test.js",
25
+ "scan": "npm run build && node dist/src/cli.js"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/pureartisan/dev-disk-doctor.git"
30
+ },
31
+ "homepage": "https://github.com/pureartisan/dev-disk-doctor#readme",
32
+ "bugs": {
33
+ "url": "https://github.com/pureartisan/dev-disk-doctor/issues"
34
+ },
35
+ "keywords": [
36
+ "disk-space",
37
+ "disk-usage",
38
+ "cleanup",
39
+ "macos",
40
+ "storage",
41
+ "docker",
42
+ "xcode",
43
+ "android-sdk",
44
+ "node_modules",
45
+ "git-worktree",
46
+ "mcp",
47
+ "mcp-server",
48
+ "developer-tools",
49
+ "cli"
50
+ ],
51
+ "dependencies": {
52
+ "@modelcontextprotocol/sdk": "^1.32.1",
53
+ "zod": "^4.6.7"
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^26.6.5",
57
+ "typescript": "^7.0.2"
58
+ }
59
+ }
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: dev-disk-doctor
3
+ description: Diagnose why a developer machine's disk is full and clean it up safely. Use when the user says their disk or Mac storage is full, "Documents" is huge, Docker/Android/Xcode/node_modules are eating space, or they want to find leftover git worktrees from Claude Code, Codex or Cursor.
4
+ ---
5
+
6
+ # dev-disk-doctor playbook
7
+
8
+ Goal: find what is using disk space, explain it in plain words, and let the user choose what to clean. Never delete anything the user has not agreed to, item by item.
9
+
10
+ ## 1. Scan (read-only)
11
+
12
+ Prefer the MCP tools if the `dev-disk-doctor` server is connected: `disk_scan`, `worktree_audit`, `explain_finding`, `apply_action`.
13
+
14
+ Otherwise use the CLI:
15
+
16
+ ```bash
17
+ npx -y dev-disk-doctor scan --json # takes about a minute
18
+ npx -y dev-disk-doctor worktrees --json # git worktree audit
19
+ npx -y dev-disk-doctor scan --deep --json # also sweeps the whole home folder (slow, ask first)
20
+ ```
21
+
22
+ Add `--root <dir>` if the user keeps projects somewhere unusual.
23
+
24
+ ## 2. Present options
25
+
26
+ Summarise: free space, total found, and how much is reclaimable. Then list the biggest items grouped by risk:
27
+
28
+ - **SAFE**: regenerates automatically (caches, build output). Offer these first.
29
+ - **REVIEW**: probably fine but check (old Docker images, AI models, stale worktrees, active-project build folders). Say what is lost and how to get it back.
30
+ - **CAREFUL** (`danger`): could lose data (Docker volumes, dirty worktrees, backups, emulator images in use). Explain; the tool will not run these.
31
+
32
+ For each item say: what it is, how big, what deleting it costs, and the exact command. Lead with the biggest wins. Do not read out the whole list.
33
+
34
+ ## 3. Apply only with explicit consent
35
+
36
+ For each item the user approves:
37
+
38
+ 1. Dry run first: `apply_action` without `confirm`, or `npx -y dev-disk-doctor fix "<action id>"`.
39
+ 2. Read the action's warning to the user (for example "quit Chrome first", "images that only exist locally are lost").
40
+ 3. Run for real only after they say yes to that item: `confirm: true`, or `--yes`.
41
+
42
+ One approval covers one action, not a batch and not later items.
43
+
44
+ ## Rules
45
+
46
+ - Never `rm -rf` anything yourself that the tool did not offer.
47
+ - Never remove a worktree that is `at-risk` (uncommitted changes or unpushed commits). Show the user `git status` and the unpushed log, and let them decide.
48
+ - For orphaned worktree folders, ask the user to look inside first: git can no longer say whether they hold unsaved work.
49
+ - Docker's `Docker.raw` never shrinks after pruning on its own. After pruning, tell the user to compact it in Docker Desktop → Settings → Resources.
50
+ - Android emulator images used by an AVD are flagged and must stay.
51
+ - After cleanup, run the scan again and report the free-space change.