@voidmatcha/d4c 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.
Files changed (3) hide show
  1. package/README.md +73 -0
  2. package/dist/index.js +854 -0
  3. package/package.json +37 -0
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # d4c
2
+
3
+ **Find and reclaim `node_modules` trees you stopped using.**
4
+
5
+ Dry-run by default. Never deletes a tree it cannot restore.
6
+
7
+ ```bash
8
+ npx d4c gc # what could be reclaimed
9
+ npx d4c gc --yes # actually delete
10
+ npx d4c history # what was deleted, and how to restore it
11
+ ```
12
+
13
+ ## Why
14
+
15
+ On the machine this was built for, 976 `node_modules` trees held 53.6 GB
16
+ that had seen no activity in 30 days — more than the free space left on
17
+ the disk.
18
+
19
+ Old worktrees, benchmark runs, abandoned experiments, PR checkouts. Every
20
+ one of them regenerable from a lockfile in about a second.
21
+
22
+ ## What it will not touch
23
+
24
+ - Trees with no lockfile anywhere that could rebuild them. A lockfile in a
25
+ parent directory only counts if that directory declares the tree as a
26
+ workspace member.
27
+ - Trees with recent activity — measured from `node_modules` mtime and git
28
+ index mtime, whichever is more recent.
29
+ - Symlinked `node_modules`. The real tree lives elsewhere.
30
+ - Anything spanning a mount boundary.
31
+ - Anything matched by `--exclude`.
32
+
33
+ Every guard is re-checked immediately before deletion, not trusted from
34
+ the earlier scan.
35
+
36
+ ## Options
37
+
38
+ ```
39
+ d4c gc [options]
40
+
41
+ --days N Idle threshold in days (default 30, minimum 1)
42
+ --exclude PATTERN Protect matching paths. Repeatable. Full match;
43
+ use a/** for a subtree, **/a for any depth.
44
+ --min-size SIZE Ignore trees smaller than this (e.g. 10M, 1G)
45
+ --depth N How deep to search (default 8)
46
+ --yes Actually delete. Without it nothing is removed.
47
+ --json Machine-readable output on stdout only.
48
+
49
+ d4c history [--limit N] [--json]
50
+ ```
51
+
52
+ ## The number is a lower bound
53
+
54
+ Idle age comes from mtime, which records the last install or git
55
+ operation — not the last read. **A project you still use can look idle,
56
+ and a long-running process leaves no trace at all.** Check the list before
57
+ passing `--yes`.
58
+
59
+ Sizes come from `st_blocks`, so they track what deletion actually frees
60
+ (measured within ~3% of real freed space). Trees whose files are shared
61
+ through APFS clones will be over-reported, since clone sharing is
62
+ invisible to `st_blocks`.
63
+
64
+ ## Recovery
65
+
66
+ Every deletion is appended to `~/.local/state/d4c/deletions.jsonl` the
67
+ moment it happens — so an interrupted run is still fully recorded.
68
+ `d4c history` prints each project with the install command that rebuilds
69
+ it, matched to the lockfile that was there.
70
+
71
+ ## License
72
+
73
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,854 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ../core/src/reap/index.ts
4
+ import { lstat as lstat2, stat as stat2, rm } from "node:fs/promises";
5
+ import { basename, join as join3 } from "node:path";
6
+
7
+ // ../core/src/fs/trees.ts
8
+ import { readdir, lstat, stat, readFile } from "node:fs/promises";
9
+ import { dirname, join } from "node:path";
10
+ var LOCKFILES = [
11
+ "npm-shrinkwrap.json",
12
+ "package-lock.json",
13
+ "pnpm-lock.yaml",
14
+ "yarn.lock",
15
+ "bun.lock",
16
+ "bun.lockb"
17
+ ];
18
+ var SKIP_DIRS = new Set([
19
+ ".git",
20
+ ".hg",
21
+ ".svn",
22
+ "node_modules",
23
+ ".Trash",
24
+ "Library"
25
+ ]);
26
+ var DAY_MS = 86400000;
27
+ var ACTIVITY_FILES = [".git/index", ".git/HEAD", ".git/FETCH_HEAD"];
28
+ async function lastActivityMs(projectRoot, nodeModulesMtimeMs) {
29
+ let latest = nodeModulesMtimeMs;
30
+ for (const rel of ACTIVITY_FILES) {
31
+ try {
32
+ const st = await lstat(join(projectRoot, rel));
33
+ if (st.mtimeMs > latest)
34
+ latest = st.mtimeMs;
35
+ } catch {}
36
+ }
37
+ return latest;
38
+ }
39
+ function globMatches(pattern, rel) {
40
+ let re = "";
41
+ for (let i = 0;i < pattern.length; i++) {
42
+ const ch = pattern[i];
43
+ if (ch === "*") {
44
+ if (pattern[i + 1] === "*") {
45
+ re += ".*";
46
+ i++;
47
+ } else
48
+ re += "[^/]*";
49
+ continue;
50
+ }
51
+ re += ch.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
52
+ }
53
+ return new RegExp("^" + re + "$").test(rel);
54
+ }
55
+ async function declaresWorkspaceMember(dir, projectRoot) {
56
+ const rel = projectRoot.slice(dir.length + 1);
57
+ if (rel === "" || rel.startsWith("/"))
58
+ return false;
59
+ const globs = [];
60
+ try {
61
+ const pkg = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
62
+ const w = pkg.workspaces;
63
+ if (Array.isArray(w))
64
+ globs.push(...w);
65
+ else if (w && Array.isArray(w.packages))
66
+ globs.push(...w.packages);
67
+ } catch {}
68
+ try {
69
+ const yaml = await readFile(join(dir, "pnpm-workspace.yaml"), "utf8");
70
+ for (const line of yaml.split(`
71
+ `)) {
72
+ const m = /^\s*-\s*['"]?([^'"#]+?)['"]?\s*$/.exec(line);
73
+ if (m)
74
+ globs.push(m[1].trim());
75
+ }
76
+ } catch {}
77
+ return globs.some((g) => globMatches(g, rel));
78
+ }
79
+ async function detectLockfile(projectRoot, scanRoot) {
80
+ for (const name of LOCKFILES) {
81
+ try {
82
+ const st = await lstat(join(projectRoot, name));
83
+ if (st.isFile())
84
+ return { lockfile: name, lockfileDir: projectRoot };
85
+ } catch {}
86
+ }
87
+ let dir = dirname(projectRoot);
88
+ for (;; ) {
89
+ if (!dir.startsWith(scanRoot))
90
+ break;
91
+ for (const name of LOCKFILES) {
92
+ try {
93
+ const st = await lstat(join(dir, name));
94
+ if (!st.isFile())
95
+ continue;
96
+ if (await declaresWorkspaceMember(dir, projectRoot)) {
97
+ return { lockfile: name, lockfileDir: dir };
98
+ }
99
+ } catch {}
100
+ }
101
+ if (dir === scanRoot)
102
+ break;
103
+ const parent = dirname(dir);
104
+ if (parent === dir)
105
+ break;
106
+ dir = parent;
107
+ }
108
+ return { lockfile: null, lockfileDir: null };
109
+ }
110
+ function allocatedSize(st) {
111
+ return st.blocks * 512;
112
+ }
113
+ async function measureTree(tree, opts = {}) {
114
+ const m = await measure(tree.path, opts.statBatchSize ?? 256, opts.onUnreadable);
115
+ tree.sizeBytes = m.sizeBytes;
116
+ tree.externallyLinkedBytes = m.externallyLinkedBytes;
117
+ tree.fileCount = m.fileCount;
118
+ tree.crossesMountBoundary = m.crossesMountBoundary;
119
+ return tree;
120
+ }
121
+ async function measure(dir, batchSize, onUnreadable) {
122
+ let fileCount = 0;
123
+ let rootDev = null;
124
+ let crossesMountBoundary = false;
125
+ const inodes = new Map;
126
+ const stack = [dir];
127
+ while (stack.length > 0) {
128
+ const cur = stack.pop();
129
+ let entries;
130
+ try {
131
+ entries = await readdir(cur, { withFileTypes: true });
132
+ } catch {
133
+ onUnreadable?.(cur);
134
+ continue;
135
+ }
136
+ const files = [];
137
+ for (const e of entries) {
138
+ const p = join(cur, e.name);
139
+ if (e.isDirectory())
140
+ stack.push(p);
141
+ else if (e.isSymbolicLink())
142
+ fileCount++;
143
+ else if (e.isFile())
144
+ files.push(p);
145
+ }
146
+ for (let i = 0;i < files.length; i += batchSize) {
147
+ const stats = await Promise.all(files.slice(i, i + batchSize).map((f) => lstat(f).catch(() => null)));
148
+ for (const st of stats) {
149
+ if (st === null)
150
+ continue;
151
+ if (rootDev === null)
152
+ rootDev = st.dev;
153
+ else if (st.dev !== rootDev)
154
+ crossesMountBoundary = true;
155
+ fileCount++;
156
+ const key = `${st.dev}:${st.ino}`;
157
+ const seen = inodes.get(key);
158
+ if (seen === undefined)
159
+ inodes.set(key, { size: allocatedSize(st), nlink: st.nlink, seen: 1 });
160
+ else
161
+ seen.seen++;
162
+ }
163
+ }
164
+ }
165
+ let sizeBytes = 0;
166
+ let externallyLinkedBytes = 0;
167
+ for (const { size, nlink, seen } of inodes.values()) {
168
+ if (seen < nlink)
169
+ externallyLinkedBytes += size;
170
+ else
171
+ sizeBytes += size;
172
+ }
173
+ return { sizeBytes, externallyLinkedBytes, fileCount, crossesMountBoundary };
174
+ }
175
+ async function findDependencyTrees(root, opts = {}) {
176
+ const maxDepth = opts.maxDepth ?? 8;
177
+ const batchSize = opts.statBatchSize ?? 256;
178
+ const dirConcurrency = opts.dirConcurrency ?? 16;
179
+ const skip = new Set([...SKIP_DIRS, ...opts.skip ?? []]);
180
+ const out = [];
181
+ const now = Date.now();
182
+ const queue = [{ dir: root, depth: 0 }];
183
+ const visit = async (dir, depth) => {
184
+ if (depth > maxDepth) {
185
+ opts.onDepthLimit?.(dir);
186
+ return;
187
+ }
188
+ let entries;
189
+ try {
190
+ entries = await readdir(dir, { withFileTypes: true });
191
+ } catch {
192
+ opts.onUnreadable?.(dir);
193
+ return;
194
+ }
195
+ const nm = entries.find((e) => e.name === "node_modules");
196
+ if (nm) {
197
+ const nmPath = join(dir, "node_modules");
198
+ if (nm.isDirectory() && !nm.isSymbolicLink()) {
199
+ try {
200
+ const st = await stat(nmPath);
201
+ const tree = {
202
+ path: nmPath,
203
+ projectRoot: dir,
204
+ sizeBytes: null,
205
+ externallyLinkedBytes: null,
206
+ fileCount: null,
207
+ crossesMountBoundary: false,
208
+ idleDays: Math.max(0, Math.floor((now - await lastActivityMs(dir, st.mtimeMs)) / DAY_MS)),
209
+ ...await detectLockfile(dir, root)
210
+ };
211
+ out.push(tree);
212
+ opts.onTree?.(tree);
213
+ } catch {}
214
+ }
215
+ }
216
+ for (const e of entries) {
217
+ if (!e.isDirectory() || e.isSymbolicLink())
218
+ continue;
219
+ if (skip.has(e.name))
220
+ continue;
221
+ queue.push({ dir: join(dir, e.name), depth: depth + 1 });
222
+ }
223
+ };
224
+ let active = 0;
225
+ const workers = Array.from({ length: dirConcurrency }, async () => {
226
+ for (;; ) {
227
+ if (opts.signal?.aborted === true)
228
+ return;
229
+ const next = queue.pop();
230
+ if (next === undefined) {
231
+ if (active === 0)
232
+ return;
233
+ await new Promise((r) => setTimeout(r, 1));
234
+ continue;
235
+ }
236
+ active++;
237
+ try {
238
+ await visit(next.dir, next.depth);
239
+ } finally {
240
+ active--;
241
+ }
242
+ }
243
+ });
244
+ await Promise.all(workers);
245
+ return out;
246
+ }
247
+
248
+ // ../core/src/reap/filter.ts
249
+ function toRegExp(pattern) {
250
+ let out = "";
251
+ for (let i = 0;i < pattern.length; i++) {
252
+ const ch = pattern[i];
253
+ if (ch === "*") {
254
+ if (pattern[i + 1] === "*") {
255
+ out += ".*";
256
+ i++;
257
+ } else {
258
+ out += "[^/]*";
259
+ }
260
+ continue;
261
+ }
262
+ out += ch.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
263
+ }
264
+ return new RegExp("^" + out + "$");
265
+ }
266
+ function matchesAny(path, patterns) {
267
+ return patterns.some((p) => toRegExp(p).test(path));
268
+ }
269
+ var UNITS = {
270
+ "": 1,
271
+ b: 1,
272
+ k: 1024,
273
+ kb: 1024,
274
+ kib: 1024,
275
+ m: 1024 ** 2,
276
+ mb: 1024 ** 2,
277
+ mib: 1024 ** 2,
278
+ g: 1024 ** 3,
279
+ gb: 1024 ** 3,
280
+ gib: 1024 ** 3,
281
+ t: 1024 ** 4,
282
+ tb: 1024 ** 4,
283
+ tib: 1024 ** 4
284
+ };
285
+ function parseSize(input) {
286
+ const m = /^(\d+(?:\.\d+)?)\s*([a-zA-Z]*)$/.exec(input.trim());
287
+ if (m === null)
288
+ return null;
289
+ const unit = UNITS[m[2].toLowerCase()];
290
+ if (unit === undefined)
291
+ return null;
292
+ const n = Number(m[1]) * unit;
293
+ return Number.isFinite(n) && n >= 0 ? Math.round(n) : null;
294
+ }
295
+
296
+ // ../core/src/reap/log.ts
297
+ import { appendFile, mkdir, readFile as readFile2 } from "node:fs/promises";
298
+ import { dirname as dirname2, join as join2 } from "node:path";
299
+ function defaultLogPath(env = process.env) {
300
+ const state = env.XDG_STATE_HOME ?? join2(env.HOME ?? ".", ".local", "state");
301
+ return join2(state, "d4c", "deletions.jsonl");
302
+ }
303
+ async function appendDeletion(logPath, input) {
304
+ const rec = {
305
+ at: new Date().toISOString(),
306
+ runId: input.runId,
307
+ root: input.root,
308
+ projectRoot: input.tree.projectRoot,
309
+ path: input.tree.path,
310
+ freedBytes: input.freedBytes,
311
+ idleDays: input.tree.idleDays,
312
+ lockfile: input.tree.lockfile,
313
+ lockfileDir: input.tree.lockfileDir
314
+ };
315
+ await mkdir(dirname2(logPath), { recursive: true });
316
+ await appendFile(logPath, JSON.stringify(rec) + `
317
+ `, "utf8");
318
+ }
319
+ async function readHistory(logPath) {
320
+ let raw;
321
+ try {
322
+ raw = await readFile2(logPath, "utf8");
323
+ } catch {
324
+ return [];
325
+ }
326
+ const out = [];
327
+ for (const line of raw.split(`
328
+ `)) {
329
+ if (line.trim() === "")
330
+ continue;
331
+ try {
332
+ const v = JSON.parse(line);
333
+ if (typeof v.projectRoot === "string")
334
+ out.push(v);
335
+ } catch {}
336
+ }
337
+ return out;
338
+ }
339
+ function restoreCommand(lockfile) {
340
+ switch (lockfile) {
341
+ case "yarn.lock":
342
+ return "yarn install";
343
+ case "pnpm-lock.yaml":
344
+ return "pnpm install";
345
+ case "bun.lock":
346
+ case "bun.lockb":
347
+ return "bun install";
348
+ default:
349
+ return "npm install";
350
+ }
351
+ }
352
+
353
+ // ../core/src/reap/index.ts
354
+ function relativeTo(path, root) {
355
+ if (path === root)
356
+ return ".";
357
+ const prefix = root.endsWith("/") ? root : root + "/";
358
+ return path.startsWith(prefix) ? path.slice(prefix.length) : path;
359
+ }
360
+ var DEFAULT_IDLE_THRESHOLD_DAYS = 30;
361
+ async function planReap(root, opts = {}) {
362
+ const idleThresholdDays = opts.idleThresholdDays ?? DEFAULT_IDLE_THRESHOLD_DAYS;
363
+ if (idleThresholdDays < 1) {
364
+ throw new RangeError("idleThresholdDays must be at least 1");
365
+ }
366
+ const diagnostics = { unreadableDirs: 0, depthLimited: 0 };
367
+ const trees = await findDependencyTrees(root, {
368
+ ...opts,
369
+ signal: opts.signal,
370
+ onUnreadable: (p) => {
371
+ diagnostics.unreadableDirs++;
372
+ opts.onUnreadable?.(p);
373
+ },
374
+ onDepthLimit: (p) => {
375
+ diagnostics.depthLimited++;
376
+ opts.onDepthLimit?.(p);
377
+ }
378
+ });
379
+ const candidates = [];
380
+ const skipped = [];
381
+ let totalBytes = 0;
382
+ const exclude = opts.exclude ?? [];
383
+ const minSizeBytes = opts.minSizeBytes ?? 0;
384
+ for (const tree of trees) {
385
+ if (matchesAny(relativeTo(tree.projectRoot, root), exclude)) {
386
+ skipped.push({ tree, reason: "excluded" });
387
+ continue;
388
+ }
389
+ if (tree.idleDays < idleThresholdDays) {
390
+ skipped.push({ tree, reason: "recently-modified" });
391
+ continue;
392
+ }
393
+ if (tree.lockfile === null) {
394
+ skipped.push({ tree, reason: "no-lockfile" });
395
+ continue;
396
+ }
397
+ let plannedMtimeMs = 0;
398
+ try {
399
+ plannedMtimeMs = (await lstat2(tree.path)).mtimeMs;
400
+ } catch {
401
+ continue;
402
+ }
403
+ candidates.push({ tree, plannedMtimeMs });
404
+ }
405
+ let interrupted = opts.signal?.aborted === true;
406
+ for (const c of candidates) {
407
+ if (opts.signal?.aborted === true) {
408
+ interrupted = true;
409
+ break;
410
+ }
411
+ await measureTree(c.tree, { statBatchSize: opts.statBatchSize, onUnreadable: opts.onUnreadable });
412
+ totalBytes += c.tree.sizeBytes ?? 0;
413
+ opts.onMeasured?.(c.tree);
414
+ }
415
+ const tooSmall = candidates.filter((c) => (c.tree.sizeBytes ?? 0) < minSizeBytes);
416
+ for (const c of tooSmall)
417
+ skipped.push({ tree: c.tree, reason: "below-min-size" });
418
+ const kept = candidates.filter((c) => (c.tree.sizeBytes ?? 0) >= minSizeBytes);
419
+ candidates.length = 0;
420
+ candidates.push(...kept);
421
+ candidates.sort((a, b) => (b.tree.sizeBytes ?? 0) - (a.tree.sizeBytes ?? 0));
422
+ return {
423
+ root,
424
+ idleThresholdDays,
425
+ exclude,
426
+ minSizeBytes,
427
+ candidates,
428
+ skipped,
429
+ reclaimableBytes: candidates.reduce((s, c) => s + (c.tree.sizeBytes ?? 0), 0),
430
+ totalBytes,
431
+ treeCount: trees.length,
432
+ diagnostics,
433
+ interrupted
434
+ };
435
+ }
436
+ async function verifyStillReapable(c) {
437
+ const { tree, plannedMtimeMs } = c;
438
+ if (basename(tree.path) !== "node_modules")
439
+ return "not-a-node-modules-path";
440
+ let st;
441
+ try {
442
+ st = await lstat2(tree.path);
443
+ } catch {
444
+ return "vanished";
445
+ }
446
+ if (st.isSymbolicLink())
447
+ return "became-symlink";
448
+ if (!st.isDirectory())
449
+ return "not-a-node-modules-path";
450
+ if (st.mtimeMs !== plannedMtimeMs)
451
+ return "changed-since-plan";
452
+ if (c.tree.crossesMountBoundary)
453
+ return "crosses-mount-boundary";
454
+ if (tree.lockfile === null || tree.lockfileDir === null)
455
+ return "lockfile-vanished";
456
+ for (const f of [tree.lockfile, "package.json"]) {
457
+ try {
458
+ const st2 = await stat2(join3(tree.lockfileDir, f));
459
+ if (!st2.isFile())
460
+ return "lockfile-vanished";
461
+ } catch {
462
+ return "lockfile-vanished";
463
+ }
464
+ }
465
+ return null;
466
+ }
467
+ async function executeReap(plan, opts = {}) {
468
+ const apply = opts.apply === true;
469
+ const runId = opts.runId ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
470
+ const logPath = !apply ? null : opts.logPath === null ? null : opts.logPath ?? defaultLogPath();
471
+ const deleted = [];
472
+ const aborted = [];
473
+ let interrupted = false;
474
+ for (const c of plan.candidates) {
475
+ if (opts.signal?.aborted === true) {
476
+ interrupted = true;
477
+ break;
478
+ }
479
+ const problem = await verifyStillReapable(c);
480
+ if (problem !== null) {
481
+ aborted.push({ tree: c.tree, reason: problem });
482
+ opts.onProgress?.(c.tree, "aborted");
483
+ continue;
484
+ }
485
+ if (!apply)
486
+ continue;
487
+ try {
488
+ await rm(c.tree.path, { recursive: true, force: false });
489
+ const freedBytes = c.tree.sizeBytes ?? 0;
490
+ deleted.push({ tree: c.tree, freedBytes });
491
+ if (logPath !== null) {
492
+ try {
493
+ await appendDeletion(logPath, { tree: c.tree, freedBytes, runId, root: plan.root });
494
+ } catch {}
495
+ }
496
+ opts.onProgress?.(c.tree, "deleted");
497
+ } catch (e) {
498
+ aborted.push({ tree: c.tree, reason: "delete-failed", detail: String(e) });
499
+ opts.onProgress?.(c.tree, "aborted");
500
+ }
501
+ }
502
+ return {
503
+ dryRun: !apply,
504
+ deleted,
505
+ aborted,
506
+ freedBytes: deleted.reduce((s, d) => s + d.freedBytes, 0),
507
+ interrupted,
508
+ runId,
509
+ logPath
510
+ };
511
+ }
512
+
513
+ // src/render.ts
514
+ function humanBytes(n) {
515
+ const u = ["B", "KiB", "MiB", "GiB", "TiB"];
516
+ let v = n, i = 0;
517
+ while (v >= 1024 && i < u.length - 1) {
518
+ v /= 1024;
519
+ i++;
520
+ }
521
+ return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${u[i]}`;
522
+ }
523
+ function relativize(path, root) {
524
+ return path.startsWith(root) ? "." + path.slice(root.length) : path;
525
+ }
526
+
527
+ // src/commands/gc.ts
528
+ async function gc(args) {
529
+ const showProgress = process.stderr.isTTY === true;
530
+ let found = 0;
531
+ let measured = 0;
532
+ let measuredBytes = 0;
533
+ let lastDraw = 0;
534
+ const draw = (line) => {
535
+ const now = Date.now();
536
+ if (now - lastDraw < 100)
537
+ return;
538
+ lastDraw = now;
539
+ process.stderr.write(`\r${line.padEnd(60)}`);
540
+ };
541
+ const onTree = showProgress ? () => {
542
+ found++;
543
+ draw(` finding trees… ${found}`);
544
+ } : undefined;
545
+ const onMeasured = showProgress ? (t) => {
546
+ measured++;
547
+ measuredBytes += t.sizeBytes ?? 0;
548
+ draw(` measuring… ${measured} candidate(s), ${humanBytes(measuredBytes)}`);
549
+ } : undefined;
550
+ const ac = new AbortController;
551
+ let interrupts = 0;
552
+ const onSigint = () => {
553
+ interrupts++;
554
+ if (interrupts === 1) {
555
+ ac.abort();
556
+ process.stderr.write(`
557
+ stopping after the current tree… (Ctrl+C again to force)
558
+ `);
559
+ } else {
560
+ process.exit(130);
561
+ }
562
+ };
563
+ process.on("SIGINT", onSigint);
564
+ const plan = await planReap(args.cwd, {
565
+ idleThresholdDays: args.days,
566
+ maxDepth: args.depth,
567
+ signal: ac.signal,
568
+ exclude: args.exclude,
569
+ minSizeBytes: args.minSizeBytes,
570
+ onTree,
571
+ onMeasured
572
+ });
573
+ if (showProgress)
574
+ process.stderr.write("\r" + " ".repeat(62) + "\r");
575
+ const result = await executeReap(plan, { apply: args.yes, logPath: args.logPath, signal: ac.signal });
576
+ process.off("SIGINT", onSigint);
577
+ if (args.json) {
578
+ process.stdout.write(JSON.stringify({
579
+ schemaVersion: 1,
580
+ root: args.cwd,
581
+ idleThresholdDays: plan.idleThresholdDays,
582
+ exclude: plan.exclude,
583
+ minSizeBytes: plan.minSizeBytes,
584
+ dryRun: result.dryRun,
585
+ interrupted: plan.interrupted || result.interrupted,
586
+ totalBytes: plan.totalBytes,
587
+ treeCount: plan.treeCount,
588
+ diagnostics: plan.diagnostics,
589
+ reclaimableBytes: plan.reclaimableBytes,
590
+ freedBytes: result.freedBytes,
591
+ candidates: plan.candidates.map((c) => ({
592
+ path: c.tree.path,
593
+ projectRoot: c.tree.projectRoot,
594
+ sizeBytes: c.tree.sizeBytes ?? 0,
595
+ externallyLinkedBytes: c.tree.externallyLinkedBytes ?? 0,
596
+ idleDays: c.tree.idleDays,
597
+ lockfile: c.tree.lockfile
598
+ })),
599
+ skipped: plan.skipped.map((s) => ({
600
+ projectRoot: s.tree.projectRoot,
601
+ sizeBytes: s.tree.sizeBytes,
602
+ idleDays: s.tree.idleDays,
603
+ reason: s.reason
604
+ })),
605
+ runId: result.runId,
606
+ logPath: result.logPath,
607
+ deleted: result.deleted.map((d2) => ({ path: d2.tree.path, freedBytes: d2.freedBytes })),
608
+ aborted: result.aborted.map((a) => ({ path: a.tree.path, reason: a.reason }))
609
+ }) + `
610
+ `);
611
+ return 0;
612
+ }
613
+ const out = [];
614
+ out.push("D4C — dependency garbage collection", "");
615
+ if (plan.candidates.length === 0) {
616
+ out.push(`Nothing to reclaim. Scanned ${plan.treeCount} tree(s);`);
617
+ out.push(`none has been untouched for ${plan.idleThresholdDays}+ days with a lockfile present.`);
618
+ const noLock = plan.skipped.filter((s) => s.reason === "no-lockfile");
619
+ if (noLock.length > 0) {
620
+ out.push("", `${noLock.length} idle tree(s) skipped: no lockfile, so they could not be regenerated.`);
621
+ }
622
+ process.stdout.write(out.join(`
623
+ `) + `
624
+ `);
625
+ return 0;
626
+ }
627
+ const verb = result.dryRun ? "Would reclaim" : "Reclaimed";
628
+ const bytes = result.dryRun ? plan.reclaimableBytes : result.freedBytes;
629
+ out.push(`${verb} ${humanBytes(bytes)} from ${plan.candidates.length} tree(s)`);
630
+ out.push(`untouched for ${plan.idleThresholdDays}+ days, from ${plan.treeCount} tree(s) scanned.`, "");
631
+ for (const c of plan.candidates.slice(0, 20)) {
632
+ out.push(` ${humanBytes(c.tree.sizeBytes ?? 0).padStart(9)} ${String(c.tree.idleDays).padStart(4)}d ${relativize(c.tree.projectRoot, args.cwd)}`);
633
+ }
634
+ if (plan.candidates.length > 20)
635
+ out.push(` ... and ${plan.candidates.length - 20} more`);
636
+ if (result.aborted.length > 0) {
637
+ out.push("", `${result.aborted.length} skipped at the last check:`);
638
+ for (const a of result.aborted.slice(0, 10)) {
639
+ out.push(` ${a.reason.padEnd(24)} ${relativize(a.tree.projectRoot, args.cwd)}`);
640
+ }
641
+ }
642
+ const externallyLinked = plan.candidates.reduce((n, c) => n + (c.tree.externallyLinkedBytes ?? 0), 0);
643
+ if (externallyLinked > 0) {
644
+ out.push("", `${humanBytes(externallyLinked)} in these trees is hardlinked from outside`);
645
+ out.push("and will not be freed by deleting them. It is excluded from the total above.");
646
+ }
647
+ const excluded = plan.skipped.filter((s) => s.reason === "excluded");
648
+ if (excluded.length > 0) {
649
+ out.push("", `${excluded.length} tree(s) protected by --exclude.`);
650
+ }
651
+ const d = plan.diagnostics;
652
+ if (d.unreadableDirs > 0 || d.depthLimited > 0) {
653
+ out.push("");
654
+ if (d.unreadableDirs > 0) {
655
+ out.push(`${d.unreadableDirs} director${d.unreadableDirs === 1 ? "y" : "ies"} could not be read;`);
656
+ out.push("trees inside them are missing from these totals.");
657
+ }
658
+ if (d.depthLimited > 0) {
659
+ out.push(`${d.depthLimited} path(s) hit the depth limit and were not searched further.`);
660
+ }
661
+ }
662
+ if (plan.interrupted || result.interrupted) {
663
+ out.push("");
664
+ out.push("Interrupted. The trees listed above are what was handled before stopping;");
665
+ out.push("anything else was left untouched.");
666
+ }
667
+ out.push("");
668
+ out.push("Idle age is measured from the last install or modification, not the");
669
+ out.push("last time the tree was read. A project you still use can look idle.");
670
+ out.push("");
671
+ out.push("Restore any of these by running `npm install` in the project.");
672
+ if (result.logPath !== null && result.deleted.length > 0) {
673
+ out.push(`Recorded in ${result.logPath} — see \`d4c history\`.`);
674
+ }
675
+ if (result.dryRun)
676
+ out.push("", "Nothing was deleted. Re-run with --yes to apply.");
677
+ process.stdout.write(out.join(`
678
+ `) + `
679
+ `);
680
+ return plan.interrupted || result.interrupted ? 130 : 0;
681
+ }
682
+ var DEFAULT_DAYS = DEFAULT_IDLE_THRESHOLD_DAYS;
683
+
684
+ // src/commands/history.ts
685
+ function groupByRun(records) {
686
+ const runs = new Map;
687
+ for (const r of records) {
688
+ const list = runs.get(r.runId) ?? [];
689
+ list.push(r);
690
+ runs.set(r.runId, list);
691
+ }
692
+ return runs;
693
+ }
694
+ async function history(args) {
695
+ const path = args.logPath ?? defaultLogPath();
696
+ const records = await readHistory(path);
697
+ if (args.json) {
698
+ process.stdout.write(JSON.stringify({ schemaVersion: 1, logPath: path, records }) + `
699
+ `);
700
+ return 0;
701
+ }
702
+ if (records.length === 0) {
703
+ process.stdout.write(`No deletions recorded.
704
+ Log would be at ${path}
705
+ `);
706
+ return 0;
707
+ }
708
+ const runs = [...groupByRun(records).entries()].reverse().slice(0, args.limit);
709
+ const out = [`D4C — deletion history (${path})`, ""];
710
+ for (const [runId, recs] of runs) {
711
+ const freed = recs.reduce((s, r) => s + r.freedBytes, 0);
712
+ out.push(`${recs[0].at} run ${runId}`);
713
+ out.push(` ${recs.length} tree(s), ${humanBytes(freed)} freed, scanned from ${recs[0].root}`);
714
+ for (const r of recs.slice(0, 10)) {
715
+ out.push(` ${humanBytes(r.freedBytes).padStart(9)} ${r.projectRoot}`);
716
+ }
717
+ if (recs.length > 10)
718
+ out.push(` ... and ${recs.length - 10} more`);
719
+ out.push("");
720
+ }
721
+ const cmds = new Set(records.map((r) => restoreCommand(r.lockfile)));
722
+ out.push("Restore any project by running its install command in that directory:");
723
+ for (const c of cmds)
724
+ out.push(` ${c}`);
725
+ out.push("", "Use --json for the full list.");
726
+ process.stdout.write(out.join(`
727
+ `) + `
728
+ `);
729
+ return 0;
730
+ }
731
+
732
+ // src/args.ts
733
+ var VALUE_FLAGS = new Set(["--days", "--exclude", "--min-size", "--limit", "--log", "--depth"]);
734
+ function parseArgs(argv) {
735
+ const flags = new Set;
736
+ const values = new Map;
737
+ const errors = [];
738
+ let command;
739
+ for (let i = 0;i < argv.length; i++) {
740
+ const a = argv[i];
741
+ if (!a.startsWith("--")) {
742
+ if (command === undefined)
743
+ command = a;
744
+ else
745
+ errors.push(`unexpected argument: ${a}`);
746
+ continue;
747
+ }
748
+ const eq = a.indexOf("=");
749
+ const key = eq >= 0 ? a.slice(0, eq) : a;
750
+ if (VALUE_FLAGS.has(key)) {
751
+ const v = eq >= 0 ? a.slice(eq + 1) : argv[++i];
752
+ if (v === undefined) {
753
+ errors.push(`${key} requires a value`);
754
+ continue;
755
+ }
756
+ const list = values.get(key) ?? [];
757
+ list.push(v);
758
+ values.set(key, list);
759
+ } else {
760
+ flags.add(key);
761
+ }
762
+ }
763
+ return { command, flags, values, errors };
764
+ }
765
+ function firstValue(p, key) {
766
+ return p.values.get(key)?.[0];
767
+ }
768
+
769
+ // src/index.ts
770
+ var USAGE = `d4c — dependency storage tooling for Git worktrees
771
+
772
+ Usage:
773
+ d4c gc [options]
774
+ d4c history [--limit N] [--json]
775
+
776
+ gc Report node_modules trees idle long enough to delete safely,
777
+ and delete them with --yes. Dry-run by default.
778
+ history Show what previous runs deleted, and how to restore it.
779
+
780
+ Options:
781
+ --days N Idle threshold in days (default ${DEFAULT_DAYS})
782
+ --exclude PATTERN Protect matching paths. Repeatable. Supports * and **.
783
+ --min-size SIZE Ignore trees smaller than this (e.g. 10M, 1G)
784
+ --depth N How deep to search below the current directory (default 8)
785
+ --yes Actually delete. Without it nothing is removed.
786
+ --json Machine-readable output on stdout only.
787
+ --limit N history: how many runs to show (default 5)
788
+ `;
789
+ async function main(argv) {
790
+ const p = parseArgs(argv);
791
+ if (p.command === undefined || p.flags.has("--help") || p.command === "help") {
792
+ process.stdout.write(USAGE);
793
+ return 0;
794
+ }
795
+ if (p.errors.length > 0) {
796
+ for (const e of p.errors)
797
+ process.stderr.write(`${e}
798
+ `);
799
+ return 70;
800
+ }
801
+ if (p.command === "gc") {
802
+ const daysRaw = firstValue(p, "--days");
803
+ const days = daysRaw === undefined ? DEFAULT_DAYS : Number(daysRaw);
804
+ if (!Number.isFinite(days) || days < 1) {
805
+ process.stderr.write(`--days must be at least 1
806
+ `);
807
+ return 70;
808
+ }
809
+ const depthRaw = firstValue(p, "--depth");
810
+ if (depthRaw !== undefined && (!Number.isFinite(Number(depthRaw)) || Number(depthRaw) < 1)) {
811
+ process.stderr.write(`--depth must be a positive number
812
+ `);
813
+ return 70;
814
+ }
815
+ const sizeRaw = firstValue(p, "--min-size");
816
+ const minSizeBytes = sizeRaw === undefined ? 0 : parseSize(sizeRaw) ?? -1;
817
+ if (minSizeBytes < 0) {
818
+ process.stderr.write(`--min-size: cannot parse ${JSON.stringify(sizeRaw)}
819
+ `);
820
+ return 70;
821
+ }
822
+ return gc({
823
+ json: p.flags.has("--json"),
824
+ yes: p.flags.has("--yes"),
825
+ days,
826
+ cwd: process.cwd(),
827
+ exclude: p.values.get("--exclude") ?? [],
828
+ minSizeBytes,
829
+ logPath: firstValue(p, "--log"),
830
+ depth: depthRaw === undefined ? undefined : Number(depthRaw)
831
+ });
832
+ }
833
+ if (p.command === "history") {
834
+ const limitRaw = firstValue(p, "--limit");
835
+ const limit = limitRaw === undefined ? 5 : Number(limitRaw);
836
+ if (!Number.isFinite(limit) || limit < 1) {
837
+ process.stderr.write(`--limit must be a positive number
838
+ `);
839
+ return 70;
840
+ }
841
+ return history({ json: p.flags.has("--json"), limit, logPath: firstValue(p, "--log") });
842
+ }
843
+ process.stderr.write(`unknown command: ${p.command}
844
+
845
+ ${USAGE}`);
846
+ return 70;
847
+ }
848
+ main(process.argv.slice(2)).then((code) => {
849
+ process.exitCode = code;
850
+ }).catch((err) => {
851
+ process.stderr.write(`internal error: ${err?.stack ?? err}
852
+ `);
853
+ process.exitCode = 70;
854
+ });
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@voidmatcha/d4c",
3
+ "version": "0.1.1",
4
+ "description": "Reclaim node_modules disk space safely. Deletes only what a lockfile can rebuild, records everything it removes.",
5
+ "keywords": [
6
+ "node_modules",
7
+ "disk-space",
8
+ "cleanup",
9
+ "gc",
10
+ "monorepo",
11
+ "worktree",
12
+ "npm",
13
+ "pnpm",
14
+ "yarn",
15
+ "bun"
16
+ ],
17
+ "license": "MIT",
18
+ "author": "voidmatcha",
19
+ "type": "module",
20
+ "bin": {
21
+ "d4c": "dist/index.js"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "README.md"
26
+ ],
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "scripts": {
31
+ "build": "bun build ../../packages/cli/src/index.ts --target=node --outfile dist/index.js --minify"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/voidmatcha/d4c"
36
+ }
37
+ }