d4c-pool 0.2.0
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.
- package/README.md +116 -0
- package/dist/index.js +1432 -0
- package/package.json +39 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1432 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
4
|
+
|
|
5
|
+
// ../core/src/reap/index.ts
|
|
6
|
+
import { lstat as lstat2, stat as stat2, rm } from "node:fs/promises";
|
|
7
|
+
import { basename, join as join3 } from "node:path";
|
|
8
|
+
|
|
9
|
+
// ../core/src/fs/trees.ts
|
|
10
|
+
import { readdir, lstat, stat, readFile } from "node:fs/promises";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
var LOCKFILES = [
|
|
13
|
+
"npm-shrinkwrap.json",
|
|
14
|
+
"package-lock.json",
|
|
15
|
+
"pnpm-lock.yaml",
|
|
16
|
+
"yarn.lock",
|
|
17
|
+
"bun.lock",
|
|
18
|
+
"bun.lockb"
|
|
19
|
+
];
|
|
20
|
+
var SKIP_DIRS = new Set([
|
|
21
|
+
".git",
|
|
22
|
+
".hg",
|
|
23
|
+
".svn",
|
|
24
|
+
"node_modules",
|
|
25
|
+
".Trash",
|
|
26
|
+
"Library"
|
|
27
|
+
]);
|
|
28
|
+
var DAY_MS = 86400000;
|
|
29
|
+
var ACTIVITY_FILES = [".git/index", ".git/HEAD", ".git/FETCH_HEAD"];
|
|
30
|
+
async function lastActivityMs(projectRoot, nodeModulesMtimeMs) {
|
|
31
|
+
let latest = nodeModulesMtimeMs;
|
|
32
|
+
for (const rel of ACTIVITY_FILES) {
|
|
33
|
+
try {
|
|
34
|
+
const st = await lstat(join(projectRoot, rel));
|
|
35
|
+
if (st.mtimeMs > latest)
|
|
36
|
+
latest = st.mtimeMs;
|
|
37
|
+
} catch {}
|
|
38
|
+
}
|
|
39
|
+
return latest;
|
|
40
|
+
}
|
|
41
|
+
function globMatches(pattern, rel) {
|
|
42
|
+
let re = "";
|
|
43
|
+
for (let i = 0;i < pattern.length; i++) {
|
|
44
|
+
const ch = pattern[i];
|
|
45
|
+
if (ch === "*") {
|
|
46
|
+
if (pattern[i + 1] === "*") {
|
|
47
|
+
re += ".*";
|
|
48
|
+
i++;
|
|
49
|
+
} else
|
|
50
|
+
re += "[^/]*";
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
re += ch.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
54
|
+
}
|
|
55
|
+
return new RegExp("^" + re + "$").test(rel);
|
|
56
|
+
}
|
|
57
|
+
async function declaresWorkspaceMember(dir, projectRoot) {
|
|
58
|
+
const rel = projectRoot.slice(dir.length + 1);
|
|
59
|
+
if (rel === "" || rel.startsWith("/"))
|
|
60
|
+
return false;
|
|
61
|
+
const globs = [];
|
|
62
|
+
try {
|
|
63
|
+
const pkg = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
|
|
64
|
+
const w = pkg.workspaces;
|
|
65
|
+
if (Array.isArray(w))
|
|
66
|
+
globs.push(...w);
|
|
67
|
+
else if (w && Array.isArray(w.packages))
|
|
68
|
+
globs.push(...w.packages);
|
|
69
|
+
} catch {}
|
|
70
|
+
try {
|
|
71
|
+
const yaml = await readFile(join(dir, "pnpm-workspace.yaml"), "utf8");
|
|
72
|
+
for (const line of yaml.split(`
|
|
73
|
+
`)) {
|
|
74
|
+
const m = /^\s*-\s*['"]?([^'"#]+?)['"]?\s*$/.exec(line);
|
|
75
|
+
if (m)
|
|
76
|
+
globs.push(m[1].trim());
|
|
77
|
+
}
|
|
78
|
+
} catch {}
|
|
79
|
+
return globs.some((g) => globMatches(g, rel));
|
|
80
|
+
}
|
|
81
|
+
async function detectLockfile(projectRoot, scanRoot) {
|
|
82
|
+
for (const name of LOCKFILES) {
|
|
83
|
+
try {
|
|
84
|
+
const st = await lstat(join(projectRoot, name));
|
|
85
|
+
if (st.isFile())
|
|
86
|
+
return { lockfile: name, lockfileDir: projectRoot };
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
let dir = dirname(projectRoot);
|
|
90
|
+
for (;; ) {
|
|
91
|
+
if (!dir.startsWith(scanRoot))
|
|
92
|
+
break;
|
|
93
|
+
for (const name of LOCKFILES) {
|
|
94
|
+
try {
|
|
95
|
+
const st = await lstat(join(dir, name));
|
|
96
|
+
if (!st.isFile())
|
|
97
|
+
continue;
|
|
98
|
+
if (await declaresWorkspaceMember(dir, projectRoot)) {
|
|
99
|
+
return { lockfile: name, lockfileDir: dir };
|
|
100
|
+
}
|
|
101
|
+
} catch {}
|
|
102
|
+
}
|
|
103
|
+
if (dir === scanRoot)
|
|
104
|
+
break;
|
|
105
|
+
const parent = dirname(dir);
|
|
106
|
+
if (parent === dir)
|
|
107
|
+
break;
|
|
108
|
+
dir = parent;
|
|
109
|
+
}
|
|
110
|
+
return { lockfile: null, lockfileDir: null };
|
|
111
|
+
}
|
|
112
|
+
function allocatedSize(st) {
|
|
113
|
+
return st.blocks * 512;
|
|
114
|
+
}
|
|
115
|
+
async function measureTree(tree, opts = {}) {
|
|
116
|
+
const m = await measure(tree.path, opts.statBatchSize ?? 256, opts.onUnreadable);
|
|
117
|
+
tree.sizeBytes = m.sizeBytes;
|
|
118
|
+
tree.externallyLinkedBytes = m.externallyLinkedBytes;
|
|
119
|
+
tree.fileCount = m.fileCount;
|
|
120
|
+
tree.crossesMountBoundary = m.crossesMountBoundary;
|
|
121
|
+
return tree;
|
|
122
|
+
}
|
|
123
|
+
async function measure(dir, batchSize, onUnreadable) {
|
|
124
|
+
let fileCount = 0;
|
|
125
|
+
let rootDev = null;
|
|
126
|
+
let crossesMountBoundary = false;
|
|
127
|
+
const inodes = new Map;
|
|
128
|
+
const stack = [dir];
|
|
129
|
+
while (stack.length > 0) {
|
|
130
|
+
const cur = stack.pop();
|
|
131
|
+
let entries;
|
|
132
|
+
try {
|
|
133
|
+
entries = await readdir(cur, { withFileTypes: true });
|
|
134
|
+
} catch {
|
|
135
|
+
onUnreadable?.(cur);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const files = [];
|
|
139
|
+
for (const e of entries) {
|
|
140
|
+
const p = join(cur, e.name);
|
|
141
|
+
if (e.isDirectory())
|
|
142
|
+
stack.push(p);
|
|
143
|
+
else if (e.isSymbolicLink())
|
|
144
|
+
fileCount++;
|
|
145
|
+
else if (e.isFile())
|
|
146
|
+
files.push(p);
|
|
147
|
+
}
|
|
148
|
+
for (let i = 0;i < files.length; i += batchSize) {
|
|
149
|
+
const stats = await Promise.all(files.slice(i, i + batchSize).map((f) => lstat(f).catch(() => null)));
|
|
150
|
+
for (const st of stats) {
|
|
151
|
+
if (st === null)
|
|
152
|
+
continue;
|
|
153
|
+
if (rootDev === null)
|
|
154
|
+
rootDev = st.dev;
|
|
155
|
+
else if (st.dev !== rootDev)
|
|
156
|
+
crossesMountBoundary = true;
|
|
157
|
+
fileCount++;
|
|
158
|
+
const key = `${st.dev}:${st.ino}`;
|
|
159
|
+
const seen = inodes.get(key);
|
|
160
|
+
if (seen === undefined)
|
|
161
|
+
inodes.set(key, { size: allocatedSize(st), nlink: st.nlink, seen: 1 });
|
|
162
|
+
else
|
|
163
|
+
seen.seen++;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
let sizeBytes = 0;
|
|
168
|
+
let externallyLinkedBytes = 0;
|
|
169
|
+
for (const { size, nlink, seen } of inodes.values()) {
|
|
170
|
+
if (seen < nlink)
|
|
171
|
+
externallyLinkedBytes += size;
|
|
172
|
+
else
|
|
173
|
+
sizeBytes += size;
|
|
174
|
+
}
|
|
175
|
+
return { sizeBytes, externallyLinkedBytes, fileCount, crossesMountBoundary };
|
|
176
|
+
}
|
|
177
|
+
async function findDependencyTrees(root, opts = {}) {
|
|
178
|
+
const maxDepth = opts.maxDepth ?? 8;
|
|
179
|
+
const batchSize = opts.statBatchSize ?? 256;
|
|
180
|
+
const dirConcurrency = opts.dirConcurrency ?? 16;
|
|
181
|
+
const skip = new Set([...SKIP_DIRS, ...opts.skip ?? []]);
|
|
182
|
+
const out = [];
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
const queue = [{ dir: root, depth: 0 }];
|
|
185
|
+
const visit = async (dir, depth) => {
|
|
186
|
+
if (depth > maxDepth) {
|
|
187
|
+
opts.onDepthLimit?.(dir);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
let entries;
|
|
191
|
+
try {
|
|
192
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
193
|
+
} catch {
|
|
194
|
+
opts.onUnreadable?.(dir);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const nm = entries.find((e) => e.name === "node_modules");
|
|
198
|
+
if (nm) {
|
|
199
|
+
const nmPath = join(dir, "node_modules");
|
|
200
|
+
if (nm.isDirectory() && !nm.isSymbolicLink()) {
|
|
201
|
+
try {
|
|
202
|
+
const st = await stat(nmPath);
|
|
203
|
+
const tree = {
|
|
204
|
+
path: nmPath,
|
|
205
|
+
projectRoot: dir,
|
|
206
|
+
sizeBytes: null,
|
|
207
|
+
externallyLinkedBytes: null,
|
|
208
|
+
fileCount: null,
|
|
209
|
+
crossesMountBoundary: false,
|
|
210
|
+
idleDays: Math.max(0, Math.floor((now - await lastActivityMs(dir, st.mtimeMs)) / DAY_MS)),
|
|
211
|
+
...await detectLockfile(dir, root)
|
|
212
|
+
};
|
|
213
|
+
out.push(tree);
|
|
214
|
+
opts.onTree?.(tree);
|
|
215
|
+
} catch {}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
for (const e of entries) {
|
|
219
|
+
if (!e.isDirectory() || e.isSymbolicLink())
|
|
220
|
+
continue;
|
|
221
|
+
if (skip.has(e.name))
|
|
222
|
+
continue;
|
|
223
|
+
queue.push({ dir: join(dir, e.name), depth: depth + 1 });
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
let active = 0;
|
|
227
|
+
const workers = Array.from({ length: dirConcurrency }, async () => {
|
|
228
|
+
for (;; ) {
|
|
229
|
+
if (opts.signal?.aborted === true)
|
|
230
|
+
return;
|
|
231
|
+
const next = queue.pop();
|
|
232
|
+
if (next === undefined) {
|
|
233
|
+
if (active === 0)
|
|
234
|
+
return;
|
|
235
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
active++;
|
|
239
|
+
try {
|
|
240
|
+
await visit(next.dir, next.depth);
|
|
241
|
+
} finally {
|
|
242
|
+
active--;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
await Promise.all(workers);
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ../core/src/reap/filter.ts
|
|
251
|
+
function toRegExp(pattern) {
|
|
252
|
+
let out = "";
|
|
253
|
+
for (let i = 0;i < pattern.length; i++) {
|
|
254
|
+
const ch = pattern[i];
|
|
255
|
+
if (ch === "*") {
|
|
256
|
+
if (pattern[i + 1] === "*") {
|
|
257
|
+
out += ".*";
|
|
258
|
+
i++;
|
|
259
|
+
} else {
|
|
260
|
+
out += "[^/]*";
|
|
261
|
+
}
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
out += ch.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
265
|
+
}
|
|
266
|
+
return new RegExp("^" + out + "$");
|
|
267
|
+
}
|
|
268
|
+
function matchesAny(path, patterns) {
|
|
269
|
+
return patterns.some((p) => toRegExp(p).test(path));
|
|
270
|
+
}
|
|
271
|
+
var UNITS = {
|
|
272
|
+
"": 1,
|
|
273
|
+
b: 1,
|
|
274
|
+
k: 1024,
|
|
275
|
+
kb: 1024,
|
|
276
|
+
kib: 1024,
|
|
277
|
+
m: 1024 ** 2,
|
|
278
|
+
mb: 1024 ** 2,
|
|
279
|
+
mib: 1024 ** 2,
|
|
280
|
+
g: 1024 ** 3,
|
|
281
|
+
gb: 1024 ** 3,
|
|
282
|
+
gib: 1024 ** 3,
|
|
283
|
+
t: 1024 ** 4,
|
|
284
|
+
tb: 1024 ** 4,
|
|
285
|
+
tib: 1024 ** 4
|
|
286
|
+
};
|
|
287
|
+
function parseSize(input) {
|
|
288
|
+
const m = /^(\d+(?:\.\d+)?)\s*([a-zA-Z]*)$/.exec(input.trim());
|
|
289
|
+
if (m === null)
|
|
290
|
+
return null;
|
|
291
|
+
const unit = UNITS[m[2].toLowerCase()];
|
|
292
|
+
if (unit === undefined)
|
|
293
|
+
return null;
|
|
294
|
+
const n = Number(m[1]) * unit;
|
|
295
|
+
return Number.isFinite(n) && n >= 0 ? Math.round(n) : null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ../core/src/reap/log.ts
|
|
299
|
+
import { appendFile, mkdir, readFile as readFile2 } from "node:fs/promises";
|
|
300
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
301
|
+
function defaultLogPath(env = process.env) {
|
|
302
|
+
const state = env.XDG_STATE_HOME ?? join2(env.HOME ?? ".", ".local", "state");
|
|
303
|
+
return join2(state, "d4c", "deletions.jsonl");
|
|
304
|
+
}
|
|
305
|
+
async function appendDeletion(logPath, input) {
|
|
306
|
+
const rec = {
|
|
307
|
+
at: new Date().toISOString(),
|
|
308
|
+
runId: input.runId,
|
|
309
|
+
root: input.root,
|
|
310
|
+
projectRoot: input.tree.projectRoot,
|
|
311
|
+
path: input.tree.path,
|
|
312
|
+
freedBytes: input.freedBytes,
|
|
313
|
+
idleDays: input.tree.idleDays,
|
|
314
|
+
lockfile: input.tree.lockfile,
|
|
315
|
+
lockfileDir: input.tree.lockfileDir
|
|
316
|
+
};
|
|
317
|
+
await mkdir(dirname2(logPath), { recursive: true });
|
|
318
|
+
await appendFile(logPath, JSON.stringify(rec) + `
|
|
319
|
+
`, "utf8");
|
|
320
|
+
}
|
|
321
|
+
async function readHistory(logPath) {
|
|
322
|
+
let raw;
|
|
323
|
+
try {
|
|
324
|
+
raw = await readFile2(logPath, "utf8");
|
|
325
|
+
} catch {
|
|
326
|
+
return [];
|
|
327
|
+
}
|
|
328
|
+
const out = [];
|
|
329
|
+
for (const line of raw.split(`
|
|
330
|
+
`)) {
|
|
331
|
+
if (line.trim() === "")
|
|
332
|
+
continue;
|
|
333
|
+
try {
|
|
334
|
+
const v = JSON.parse(line);
|
|
335
|
+
if (typeof v.projectRoot === "string")
|
|
336
|
+
out.push(v);
|
|
337
|
+
} catch {}
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
function restoreCommand(lockfile) {
|
|
342
|
+
switch (lockfile) {
|
|
343
|
+
case "yarn.lock":
|
|
344
|
+
return "yarn install";
|
|
345
|
+
case "pnpm-lock.yaml":
|
|
346
|
+
return "pnpm install";
|
|
347
|
+
case "bun.lock":
|
|
348
|
+
case "bun.lockb":
|
|
349
|
+
return "bun install";
|
|
350
|
+
default:
|
|
351
|
+
return "npm install";
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ../core/src/reap/index.ts
|
|
356
|
+
function relativeTo(path, root) {
|
|
357
|
+
if (path === root)
|
|
358
|
+
return ".";
|
|
359
|
+
const prefix = root.endsWith("/") ? root : root + "/";
|
|
360
|
+
return path.startsWith(prefix) ? path.slice(prefix.length) : path;
|
|
361
|
+
}
|
|
362
|
+
var DEFAULT_IDLE_THRESHOLD_DAYS = 30;
|
|
363
|
+
async function planReap(root, opts = {}) {
|
|
364
|
+
const idleThresholdDays = opts.idleThresholdDays ?? DEFAULT_IDLE_THRESHOLD_DAYS;
|
|
365
|
+
if (idleThresholdDays < 1) {
|
|
366
|
+
throw new RangeError("idleThresholdDays must be at least 1");
|
|
367
|
+
}
|
|
368
|
+
const diagnostics = { unreadableDirs: 0, depthLimited: 0 };
|
|
369
|
+
const trees = await findDependencyTrees(root, {
|
|
370
|
+
...opts,
|
|
371
|
+
signal: opts.signal,
|
|
372
|
+
onUnreadable: (p) => {
|
|
373
|
+
diagnostics.unreadableDirs++;
|
|
374
|
+
opts.onUnreadable?.(p);
|
|
375
|
+
},
|
|
376
|
+
onDepthLimit: (p) => {
|
|
377
|
+
diagnostics.depthLimited++;
|
|
378
|
+
opts.onDepthLimit?.(p);
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
const candidates = [];
|
|
382
|
+
const skipped = [];
|
|
383
|
+
let totalBytes = 0;
|
|
384
|
+
const exclude = opts.exclude ?? [];
|
|
385
|
+
const minSizeBytes = opts.minSizeBytes ?? 0;
|
|
386
|
+
for (const tree of trees) {
|
|
387
|
+
if (matchesAny(relativeTo(tree.projectRoot, root), exclude)) {
|
|
388
|
+
skipped.push({ tree, reason: "excluded" });
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (tree.idleDays < idleThresholdDays) {
|
|
392
|
+
skipped.push({ tree, reason: "recently-modified" });
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (tree.lockfile === null) {
|
|
396
|
+
skipped.push({ tree, reason: "no-lockfile" });
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
let plannedMtimeMs = 0;
|
|
400
|
+
try {
|
|
401
|
+
plannedMtimeMs = (await lstat2(tree.path)).mtimeMs;
|
|
402
|
+
} catch {
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
candidates.push({ tree, plannedMtimeMs });
|
|
406
|
+
}
|
|
407
|
+
let interrupted = opts.signal?.aborted === true;
|
|
408
|
+
for (const c of candidates) {
|
|
409
|
+
if (opts.signal?.aborted === true) {
|
|
410
|
+
interrupted = true;
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
await measureTree(c.tree, { statBatchSize: opts.statBatchSize, onUnreadable: opts.onUnreadable });
|
|
414
|
+
totalBytes += c.tree.sizeBytes ?? 0;
|
|
415
|
+
opts.onMeasured?.(c.tree);
|
|
416
|
+
}
|
|
417
|
+
const tooSmall = candidates.filter((c) => (c.tree.sizeBytes ?? 0) < minSizeBytes);
|
|
418
|
+
for (const c of tooSmall)
|
|
419
|
+
skipped.push({ tree: c.tree, reason: "below-min-size" });
|
|
420
|
+
const kept = candidates.filter((c) => (c.tree.sizeBytes ?? 0) >= minSizeBytes);
|
|
421
|
+
candidates.length = 0;
|
|
422
|
+
candidates.push(...kept);
|
|
423
|
+
candidates.sort((a, b) => (b.tree.sizeBytes ?? 0) - (a.tree.sizeBytes ?? 0));
|
|
424
|
+
return {
|
|
425
|
+
root,
|
|
426
|
+
idleThresholdDays,
|
|
427
|
+
exclude,
|
|
428
|
+
minSizeBytes,
|
|
429
|
+
candidates,
|
|
430
|
+
skipped,
|
|
431
|
+
reclaimableBytes: candidates.reduce((s, c) => s + (c.tree.sizeBytes ?? 0), 0),
|
|
432
|
+
totalBytes,
|
|
433
|
+
treeCount: trees.length,
|
|
434
|
+
diagnostics,
|
|
435
|
+
interrupted
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
async function verifyStillReapable(c) {
|
|
439
|
+
const { tree, plannedMtimeMs } = c;
|
|
440
|
+
if (basename(tree.path) !== "node_modules")
|
|
441
|
+
return "not-a-node-modules-path";
|
|
442
|
+
let st;
|
|
443
|
+
try {
|
|
444
|
+
st = await lstat2(tree.path);
|
|
445
|
+
} catch {
|
|
446
|
+
return "vanished";
|
|
447
|
+
}
|
|
448
|
+
if (st.isSymbolicLink())
|
|
449
|
+
return "became-symlink";
|
|
450
|
+
if (!st.isDirectory())
|
|
451
|
+
return "not-a-node-modules-path";
|
|
452
|
+
if (st.mtimeMs !== plannedMtimeMs)
|
|
453
|
+
return "changed-since-plan";
|
|
454
|
+
if (c.tree.crossesMountBoundary)
|
|
455
|
+
return "crosses-mount-boundary";
|
|
456
|
+
if (tree.lockfile === null || tree.lockfileDir === null)
|
|
457
|
+
return "lockfile-vanished";
|
|
458
|
+
for (const f of [tree.lockfile, "package.json"]) {
|
|
459
|
+
try {
|
|
460
|
+
const st2 = await stat2(join3(tree.lockfileDir, f));
|
|
461
|
+
if (!st2.isFile())
|
|
462
|
+
return "lockfile-vanished";
|
|
463
|
+
} catch {
|
|
464
|
+
return "lockfile-vanished";
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
async function executeReap(plan, opts = {}) {
|
|
470
|
+
const apply = opts.apply === true;
|
|
471
|
+
const runId = opts.runId ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
472
|
+
const logPath = !apply ? null : opts.logPath === null ? null : opts.logPath ?? defaultLogPath();
|
|
473
|
+
const deleted = [];
|
|
474
|
+
const aborted = [];
|
|
475
|
+
let interrupted = false;
|
|
476
|
+
for (const c of plan.candidates) {
|
|
477
|
+
if (opts.signal?.aborted === true) {
|
|
478
|
+
interrupted = true;
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
const problem = await verifyStillReapable(c);
|
|
482
|
+
if (problem !== null) {
|
|
483
|
+
aborted.push({ tree: c.tree, reason: problem });
|
|
484
|
+
opts.onProgress?.(c.tree, "aborted");
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (!apply)
|
|
488
|
+
continue;
|
|
489
|
+
try {
|
|
490
|
+
await rm(c.tree.path, { recursive: true, force: false });
|
|
491
|
+
const freedBytes = c.tree.sizeBytes ?? 0;
|
|
492
|
+
deleted.push({ tree: c.tree, freedBytes });
|
|
493
|
+
if (logPath !== null) {
|
|
494
|
+
try {
|
|
495
|
+
await appendDeletion(logPath, { tree: c.tree, freedBytes, runId, root: plan.root });
|
|
496
|
+
} catch {}
|
|
497
|
+
}
|
|
498
|
+
opts.onProgress?.(c.tree, "deleted");
|
|
499
|
+
} catch (e) {
|
|
500
|
+
aborted.push({ tree: c.tree, reason: "delete-failed", detail: String(e) });
|
|
501
|
+
opts.onProgress?.(c.tree, "aborted");
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
dryRun: !apply,
|
|
506
|
+
deleted,
|
|
507
|
+
aborted,
|
|
508
|
+
freedBytes: deleted.reduce((s, d) => s + d.freedBytes, 0),
|
|
509
|
+
interrupted,
|
|
510
|
+
runId,
|
|
511
|
+
logPath
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// src/render.ts
|
|
516
|
+
function humanBytes(n) {
|
|
517
|
+
const u = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
518
|
+
let v = n, i = 0;
|
|
519
|
+
while (v >= 1024 && i < u.length - 1) {
|
|
520
|
+
v /= 1024;
|
|
521
|
+
i++;
|
|
522
|
+
}
|
|
523
|
+
return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${u[i]}`;
|
|
524
|
+
}
|
|
525
|
+
function relativize(path, root) {
|
|
526
|
+
return path.startsWith(root) ? "." + path.slice(root.length) : path;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// src/commands/gc.ts
|
|
530
|
+
async function gc(args) {
|
|
531
|
+
const showProgress = process.stderr.isTTY === true;
|
|
532
|
+
let found = 0;
|
|
533
|
+
let measured = 0;
|
|
534
|
+
let measuredBytes = 0;
|
|
535
|
+
let lastDraw = 0;
|
|
536
|
+
const draw = (line) => {
|
|
537
|
+
const now = Date.now();
|
|
538
|
+
if (now - lastDraw < 100)
|
|
539
|
+
return;
|
|
540
|
+
lastDraw = now;
|
|
541
|
+
process.stderr.write(`\r${line.padEnd(60)}`);
|
|
542
|
+
};
|
|
543
|
+
const onTree = showProgress ? () => {
|
|
544
|
+
found++;
|
|
545
|
+
draw(` finding trees… ${found}`);
|
|
546
|
+
} : undefined;
|
|
547
|
+
const onMeasured = showProgress ? (t) => {
|
|
548
|
+
measured++;
|
|
549
|
+
measuredBytes += t.sizeBytes ?? 0;
|
|
550
|
+
draw(` measuring… ${measured} candidate(s), ${humanBytes(measuredBytes)}`);
|
|
551
|
+
} : undefined;
|
|
552
|
+
const ac = new AbortController;
|
|
553
|
+
let interrupts = 0;
|
|
554
|
+
const onSigint = () => {
|
|
555
|
+
interrupts++;
|
|
556
|
+
if (interrupts === 1) {
|
|
557
|
+
ac.abort();
|
|
558
|
+
process.stderr.write(`
|
|
559
|
+
stopping after the current tree… (Ctrl+C again to force)
|
|
560
|
+
`);
|
|
561
|
+
} else {
|
|
562
|
+
process.exit(130);
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
process.on("SIGINT", onSigint);
|
|
566
|
+
const plan = await planReap(args.cwd, {
|
|
567
|
+
idleThresholdDays: args.days,
|
|
568
|
+
maxDepth: args.depth,
|
|
569
|
+
signal: ac.signal,
|
|
570
|
+
exclude: args.exclude,
|
|
571
|
+
minSizeBytes: args.minSizeBytes,
|
|
572
|
+
onTree,
|
|
573
|
+
onMeasured
|
|
574
|
+
});
|
|
575
|
+
if (showProgress)
|
|
576
|
+
process.stderr.write("\r" + " ".repeat(62) + "\r");
|
|
577
|
+
const result = await executeReap(plan, { apply: args.yes, logPath: args.logPath, signal: ac.signal });
|
|
578
|
+
process.off("SIGINT", onSigint);
|
|
579
|
+
if (args.json) {
|
|
580
|
+
process.stdout.write(JSON.stringify({
|
|
581
|
+
schemaVersion: 1,
|
|
582
|
+
root: args.cwd,
|
|
583
|
+
idleThresholdDays: plan.idleThresholdDays,
|
|
584
|
+
exclude: plan.exclude,
|
|
585
|
+
minSizeBytes: plan.minSizeBytes,
|
|
586
|
+
dryRun: result.dryRun,
|
|
587
|
+
interrupted: plan.interrupted || result.interrupted,
|
|
588
|
+
totalBytes: plan.totalBytes,
|
|
589
|
+
treeCount: plan.treeCount,
|
|
590
|
+
diagnostics: plan.diagnostics,
|
|
591
|
+
reclaimableBytes: plan.reclaimableBytes,
|
|
592
|
+
freedBytes: result.freedBytes,
|
|
593
|
+
candidates: plan.candidates.map((c) => ({
|
|
594
|
+
path: c.tree.path,
|
|
595
|
+
projectRoot: c.tree.projectRoot,
|
|
596
|
+
sizeBytes: c.tree.sizeBytes ?? 0,
|
|
597
|
+
externallyLinkedBytes: c.tree.externallyLinkedBytes ?? 0,
|
|
598
|
+
idleDays: c.tree.idleDays,
|
|
599
|
+
lockfile: c.tree.lockfile
|
|
600
|
+
})),
|
|
601
|
+
skipped: plan.skipped.map((s) => ({
|
|
602
|
+
projectRoot: s.tree.projectRoot,
|
|
603
|
+
sizeBytes: s.tree.sizeBytes,
|
|
604
|
+
idleDays: s.tree.idleDays,
|
|
605
|
+
reason: s.reason
|
|
606
|
+
})),
|
|
607
|
+
runId: result.runId,
|
|
608
|
+
logPath: result.logPath,
|
|
609
|
+
deleted: result.deleted.map((d2) => ({ path: d2.tree.path, freedBytes: d2.freedBytes })),
|
|
610
|
+
aborted: result.aborted.map((a) => ({ path: a.tree.path, reason: a.reason }))
|
|
611
|
+
}) + `
|
|
612
|
+
`);
|
|
613
|
+
return 0;
|
|
614
|
+
}
|
|
615
|
+
const out = [];
|
|
616
|
+
out.push("D4C — dependency garbage collection", "");
|
|
617
|
+
if (plan.candidates.length === 0) {
|
|
618
|
+
out.push(`Nothing to reclaim. Scanned ${plan.treeCount} tree(s);`);
|
|
619
|
+
out.push(`none has been untouched for ${plan.idleThresholdDays}+ days with a lockfile present.`);
|
|
620
|
+
const noLock = plan.skipped.filter((s) => s.reason === "no-lockfile");
|
|
621
|
+
if (noLock.length > 0) {
|
|
622
|
+
out.push("", `${noLock.length} idle tree(s) skipped: no lockfile, so they could not be regenerated.`);
|
|
623
|
+
}
|
|
624
|
+
process.stdout.write(out.join(`
|
|
625
|
+
`) + `
|
|
626
|
+
`);
|
|
627
|
+
return 0;
|
|
628
|
+
}
|
|
629
|
+
const verb = result.dryRun ? "Would reclaim" : "Reclaimed";
|
|
630
|
+
const bytes = result.dryRun ? plan.reclaimableBytes : result.freedBytes;
|
|
631
|
+
out.push(`${verb} ${humanBytes(bytes)} from ${plan.candidates.length} tree(s)`);
|
|
632
|
+
out.push(`untouched for ${plan.idleThresholdDays}+ days, from ${plan.treeCount} tree(s) scanned.`, "");
|
|
633
|
+
for (const c of plan.candidates.slice(0, 20)) {
|
|
634
|
+
out.push(` ${humanBytes(c.tree.sizeBytes ?? 0).padStart(9)} ${String(c.tree.idleDays).padStart(4)}d ${relativize(c.tree.projectRoot, args.cwd)}`);
|
|
635
|
+
}
|
|
636
|
+
if (plan.candidates.length > 20)
|
|
637
|
+
out.push(` ... and ${plan.candidates.length - 20} more`);
|
|
638
|
+
if (result.aborted.length > 0) {
|
|
639
|
+
out.push("", `${result.aborted.length} skipped at the last check:`);
|
|
640
|
+
for (const a of result.aborted.slice(0, 10)) {
|
|
641
|
+
out.push(` ${a.reason.padEnd(24)} ${relativize(a.tree.projectRoot, args.cwd)}`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
const externallyLinked = plan.candidates.reduce((n, c) => n + (c.tree.externallyLinkedBytes ?? 0), 0);
|
|
645
|
+
if (externallyLinked > 0) {
|
|
646
|
+
out.push("", `${humanBytes(externallyLinked)} in these trees is hardlinked from outside`);
|
|
647
|
+
out.push("and will not be freed by deleting them. It is excluded from the total above.");
|
|
648
|
+
}
|
|
649
|
+
const excluded = plan.skipped.filter((s) => s.reason === "excluded");
|
|
650
|
+
if (excluded.length > 0) {
|
|
651
|
+
out.push("", `${excluded.length} tree(s) protected by --exclude.`);
|
|
652
|
+
}
|
|
653
|
+
const d = plan.diagnostics;
|
|
654
|
+
if (d.unreadableDirs > 0 || d.depthLimited > 0) {
|
|
655
|
+
out.push("");
|
|
656
|
+
if (d.unreadableDirs > 0) {
|
|
657
|
+
out.push(`${d.unreadableDirs} director${d.unreadableDirs === 1 ? "y" : "ies"} could not be read;`);
|
|
658
|
+
out.push("trees inside them are missing from these totals.");
|
|
659
|
+
}
|
|
660
|
+
if (d.depthLimited > 0) {
|
|
661
|
+
out.push(`${d.depthLimited} path(s) hit the depth limit and were not searched further.`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (plan.interrupted || result.interrupted) {
|
|
665
|
+
out.push("");
|
|
666
|
+
out.push("Interrupted. The trees listed above are what was handled before stopping;");
|
|
667
|
+
out.push("anything else was left untouched.");
|
|
668
|
+
}
|
|
669
|
+
out.push("");
|
|
670
|
+
out.push("Idle age is measured from the last install or modification, not the");
|
|
671
|
+
out.push("last time the tree was read. A project you still use can look idle.");
|
|
672
|
+
out.push("");
|
|
673
|
+
out.push("Restore any of these by running `npm install` in the project.");
|
|
674
|
+
if (result.logPath !== null && result.deleted.length > 0) {
|
|
675
|
+
out.push(`Recorded in ${result.logPath} — see \`d4c history\`.`);
|
|
676
|
+
}
|
|
677
|
+
if (result.dryRun)
|
|
678
|
+
out.push("", "Nothing was deleted. Re-run with --yes to apply.");
|
|
679
|
+
process.stdout.write(out.join(`
|
|
680
|
+
`) + `
|
|
681
|
+
`);
|
|
682
|
+
return plan.interrupted || result.interrupted ? 130 : 0;
|
|
683
|
+
}
|
|
684
|
+
var DEFAULT_DAYS = DEFAULT_IDLE_THRESHOLD_DAYS;
|
|
685
|
+
|
|
686
|
+
// ../core/src/dedupe/index.ts
|
|
687
|
+
import { lstat as lstat5, mkdir as mkdir2, appendFile as appendFile2, realpath as realpath2 } from "node:fs/promises";
|
|
688
|
+
import { dirname as dirname4 } from "node:path";
|
|
689
|
+
|
|
690
|
+
// ../core/src/dedupe/index-builder.ts
|
|
691
|
+
import { readdir as readdir2, lstat as lstat3, open } from "node:fs/promises";
|
|
692
|
+
import { join as join4 } from "node:path";
|
|
693
|
+
import { createHash } from "node:crypto";
|
|
694
|
+
var DEFAULT_MIN_FILE_BYTES = 65536;
|
|
695
|
+
async function hashFile(path) {
|
|
696
|
+
const fh = await open(path, "r");
|
|
697
|
+
try {
|
|
698
|
+
const h = createHash("sha512");
|
|
699
|
+
const buf = Buffer.allocUnsafe(1 << 20);
|
|
700
|
+
for (;; ) {
|
|
701
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, null);
|
|
702
|
+
if (bytesRead === 0)
|
|
703
|
+
break;
|
|
704
|
+
h.update(buf.subarray(0, bytesRead));
|
|
705
|
+
}
|
|
706
|
+
return h.digest("hex");
|
|
707
|
+
} finally {
|
|
708
|
+
await fh.close();
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
async function buildDuplicateIndex(treeRoots, opts = {}) {
|
|
712
|
+
const minFileBytes = opts.minFileBytes ?? DEFAULT_MIN_FILE_BYTES;
|
|
713
|
+
const byHash = new Map;
|
|
714
|
+
const seenInodes = new Set;
|
|
715
|
+
let hashed = 0;
|
|
716
|
+
let bytes = 0;
|
|
717
|
+
for (const root of treeRoots) {
|
|
718
|
+
const stack = [root];
|
|
719
|
+
while (stack.length > 0) {
|
|
720
|
+
if (opts.signal?.aborted === true)
|
|
721
|
+
return [];
|
|
722
|
+
const dir = stack.pop();
|
|
723
|
+
let entries;
|
|
724
|
+
try {
|
|
725
|
+
entries = await readdir2(dir, { withFileTypes: true });
|
|
726
|
+
} catch {
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
for (const e of entries) {
|
|
730
|
+
const p = join4(dir, e.name);
|
|
731
|
+
if (e.isDirectory()) {
|
|
732
|
+
stack.push(p);
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
if (!e.isFile() || e.isSymbolicLink())
|
|
736
|
+
continue;
|
|
737
|
+
let st;
|
|
738
|
+
try {
|
|
739
|
+
st = await lstat3(p);
|
|
740
|
+
} catch {
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
if (st.size < minFileBytes)
|
|
744
|
+
continue;
|
|
745
|
+
if (st.nlink > 1)
|
|
746
|
+
continue;
|
|
747
|
+
const key = `${st.dev}:${st.ino}`;
|
|
748
|
+
if (seenInodes.has(key))
|
|
749
|
+
continue;
|
|
750
|
+
seenInodes.add(key);
|
|
751
|
+
let hash;
|
|
752
|
+
try {
|
|
753
|
+
hash = await hashFile(p);
|
|
754
|
+
} catch {
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
hashed++;
|
|
758
|
+
bytes += st.blocks * 512;
|
|
759
|
+
opts.onProgress?.(hashed, bytes);
|
|
760
|
+
const entry = {
|
|
761
|
+
path: p,
|
|
762
|
+
treeRoot: root,
|
|
763
|
+
sizeBytes: st.blocks * 512,
|
|
764
|
+
logicalSize: st.size,
|
|
765
|
+
hash,
|
|
766
|
+
dev: st.dev,
|
|
767
|
+
ino: st.ino,
|
|
768
|
+
mode: st.mode & 4095,
|
|
769
|
+
mtimeMs: st.mtimeMs,
|
|
770
|
+
cloneRefs: 0
|
|
771
|
+
};
|
|
772
|
+
const list = byHash.get(hash) ?? [];
|
|
773
|
+
list.push(entry);
|
|
774
|
+
byHash.set(hash, list);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
const groups = [];
|
|
779
|
+
for (const [hash, all] of byHash) {
|
|
780
|
+
const byMode = new Map;
|
|
781
|
+
for (const f of all) {
|
|
782
|
+
const l = byMode.get(f.mode) ?? [];
|
|
783
|
+
l.push(f);
|
|
784
|
+
byMode.set(f.mode, l);
|
|
785
|
+
}
|
|
786
|
+
for (const files of byMode.values()) {
|
|
787
|
+
if (files.length < 2)
|
|
788
|
+
continue;
|
|
789
|
+
const byDev = new Map;
|
|
790
|
+
for (const f of files) {
|
|
791
|
+
const l = byDev.get(f.dev) ?? [];
|
|
792
|
+
l.push(f);
|
|
793
|
+
byDev.set(f.dev, l);
|
|
794
|
+
}
|
|
795
|
+
for (const same of byDev.values()) {
|
|
796
|
+
if (same.length < 2)
|
|
797
|
+
continue;
|
|
798
|
+
const canonical = pickCanonical(same);
|
|
799
|
+
const reclaimableBytes = same.filter((f) => f !== canonical).reduce((s, f) => s + f.sizeBytes, 0);
|
|
800
|
+
groups.push({ hash, files: same, canonical, reclaimableBytes });
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
groups.sort((a, b) => b.reclaimableBytes - a.reclaimableBytes);
|
|
805
|
+
return groups;
|
|
806
|
+
}
|
|
807
|
+
function pickCanonical(files) {
|
|
808
|
+
return files.reduce((best, f) => f.cloneRefs > best.cloneRefs ? f : best, files[0]);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// ../core/src/dedupe/replace.ts
|
|
812
|
+
import { lstat as lstat4, open as open2, rm as rm2, rename, chmod, utimes } from "node:fs/promises";
|
|
813
|
+
import { dirname as dirname3, join as join5 } from "node:path";
|
|
814
|
+
import { randomBytes } from "node:crypto";
|
|
815
|
+
|
|
816
|
+
// ../core/src/clone/clonefile.ts
|
|
817
|
+
var cachedFn;
|
|
818
|
+
function loadClonefile() {
|
|
819
|
+
if (cachedFn !== undefined)
|
|
820
|
+
return cachedFn;
|
|
821
|
+
if (process.platform !== "darwin") {
|
|
822
|
+
cachedFn = null;
|
|
823
|
+
return null;
|
|
824
|
+
}
|
|
825
|
+
try {
|
|
826
|
+
const koffi = __require("koffi");
|
|
827
|
+
const lib = koffi.load("libSystem.B.dylib");
|
|
828
|
+
cachedFn = lib.func("int clonefile(const char *src, const char *dst, uint32_t flags)");
|
|
829
|
+
} catch {
|
|
830
|
+
cachedFn = null;
|
|
831
|
+
}
|
|
832
|
+
return cachedFn;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
class CloneUnsupportedError extends Error {
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
class CloneFailedError extends Error {
|
|
839
|
+
}
|
|
840
|
+
async function cloneFile(src, dst) {
|
|
841
|
+
const fn = loadClonefile();
|
|
842
|
+
if (fn === null) {
|
|
843
|
+
throw new CloneUnsupportedError("clonefile is not available on this platform");
|
|
844
|
+
}
|
|
845
|
+
const rc = fn(src, dst, 0);
|
|
846
|
+
if (rc !== 0) {
|
|
847
|
+
throw new CloneFailedError(`clonefile failed: ${src} -> ${dst}`);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// ../core/src/dedupe/replace.ts
|
|
852
|
+
async function sameContent(a, b) {
|
|
853
|
+
const [fa, fb] = await Promise.all([open2(a, "r"), open2(b, "r")]);
|
|
854
|
+
try {
|
|
855
|
+
const [sa, sb] = await Promise.all([fa.stat(), fb.stat()]);
|
|
856
|
+
if (sa.size !== sb.size)
|
|
857
|
+
return false;
|
|
858
|
+
const ba = Buffer.allocUnsafe(1 << 20);
|
|
859
|
+
const bb = Buffer.allocUnsafe(1 << 20);
|
|
860
|
+
for (;; ) {
|
|
861
|
+
const [ra, rb] = await Promise.all([
|
|
862
|
+
fa.read(ba, 0, ba.length, null),
|
|
863
|
+
fb.read(bb, 0, bb.length, null)
|
|
864
|
+
]);
|
|
865
|
+
if (ra.bytesRead !== rb.bytesRead)
|
|
866
|
+
return false;
|
|
867
|
+
if (ra.bytesRead === 0)
|
|
868
|
+
return true;
|
|
869
|
+
if (!ba.subarray(0, ra.bytesRead).equals(bb.subarray(0, rb.bytesRead)))
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
} finally {
|
|
873
|
+
await Promise.all([fa.close(), fb.close()]);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
async function replaceWithClone(canonical, target) {
|
|
877
|
+
let cs, ts;
|
|
878
|
+
try {
|
|
879
|
+
cs = await lstat4(canonical.path);
|
|
880
|
+
} catch {
|
|
881
|
+
return { ok: false, reason: "source-vanished" };
|
|
882
|
+
}
|
|
883
|
+
try {
|
|
884
|
+
ts = await lstat4(target.path);
|
|
885
|
+
} catch {
|
|
886
|
+
return { ok: false, reason: "target-vanished" };
|
|
887
|
+
}
|
|
888
|
+
if (ts.mtimeMs !== target.mtimeMs || ts.ino !== target.ino || ts.size !== target.logicalSize) {
|
|
889
|
+
return { ok: false, reason: "changed-since-index" };
|
|
890
|
+
}
|
|
891
|
+
if (cs.mtimeMs !== canonical.mtimeMs || cs.ino !== canonical.ino || cs.size !== canonical.logicalSize) {
|
|
892
|
+
return { ok: false, reason: "changed-since-index" };
|
|
893
|
+
}
|
|
894
|
+
if ((cs.mode & 4095) !== (ts.mode & 4095)) {
|
|
895
|
+
return { ok: false, reason: "changed-since-index" };
|
|
896
|
+
}
|
|
897
|
+
if (!await sameContent(canonical.path, target.path)) {
|
|
898
|
+
return { ok: false, reason: "content-changed" };
|
|
899
|
+
}
|
|
900
|
+
const tmp = join5(dirname3(target.path), `.d4c-tmp-${randomBytes(6).toString("hex")}`);
|
|
901
|
+
try {
|
|
902
|
+
await cloneFile(canonical.path, tmp);
|
|
903
|
+
} catch (e) {
|
|
904
|
+
await rm2(tmp, { force: true });
|
|
905
|
+
return { ok: false, reason: "clone-failed", detail: String(e) };
|
|
906
|
+
}
|
|
907
|
+
try {
|
|
908
|
+
await chmod(tmp, ts.mode & 4095);
|
|
909
|
+
await utimes(tmp, ts.atime, ts.mtime);
|
|
910
|
+
await rename(tmp, target.path);
|
|
911
|
+
} catch (e) {
|
|
912
|
+
await rm2(tmp, { force: true });
|
|
913
|
+
return { ok: false, reason: "clone-failed", detail: String(e) };
|
|
914
|
+
}
|
|
915
|
+
try {
|
|
916
|
+
if (!await sameContent(canonical.path, target.path)) {
|
|
917
|
+
return { ok: false, reason: "verify-failed" };
|
|
918
|
+
}
|
|
919
|
+
} catch {
|
|
920
|
+
return { ok: false, reason: "verify-failed" };
|
|
921
|
+
}
|
|
922
|
+
return { ok: true, freedBytes: target.sizeBytes };
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// ../core/src/dedupe/open-files.ts
|
|
926
|
+
import { execFile } from "node:child_process";
|
|
927
|
+
import { realpath } from "node:fs/promises";
|
|
928
|
+
async function openFilesUnder(roots, opts = {}) {
|
|
929
|
+
if (roots.length === 0)
|
|
930
|
+
return new Set;
|
|
931
|
+
const bin = opts.lsofPath ?? "lsof";
|
|
932
|
+
const realRoots = await Promise.all(roots.map((r) => realpath(r).catch(() => r)));
|
|
933
|
+
return new Promise((resolve) => {
|
|
934
|
+
execFile(bin, ["-Fn", "-w"], { maxBuffer: 64 * 1024 * 1024, timeout: opts.timeoutMs ?? 60000 }, (err, stdout) => {
|
|
935
|
+
if (stdout === undefined || stdout === "") {
|
|
936
|
+
resolve(err ? null : new Set);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
const held = new Set;
|
|
940
|
+
for (const line of stdout.split(`
|
|
941
|
+
`)) {
|
|
942
|
+
if (line.charCodeAt(0) !== 110)
|
|
943
|
+
continue;
|
|
944
|
+
const p = line.slice(1);
|
|
945
|
+
for (const root of realRoots) {
|
|
946
|
+
if (p.startsWith(root)) {
|
|
947
|
+
held.add(p);
|
|
948
|
+
break;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
resolve(held);
|
|
953
|
+
});
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// ../core/src/dedupe/index.ts
|
|
958
|
+
var DEFAULT_IDLE_THRESHOLD_DAYS2 = 30;
|
|
959
|
+
async function planDedupe(inputRoot, opts = {}) {
|
|
960
|
+
const idleThresholdDays = opts.idleThresholdDays ?? DEFAULT_IDLE_THRESHOLD_DAYS2;
|
|
961
|
+
if (idleThresholdDays < 1)
|
|
962
|
+
throw new RangeError("idleThresholdDays must be at least 1");
|
|
963
|
+
const root = await realpath2(inputRoot).catch(() => inputRoot);
|
|
964
|
+
const all = await findDependencyTrees(root, opts);
|
|
965
|
+
const exclude = opts.exclude ?? [];
|
|
966
|
+
const trees = all.filter((t) => t.idleDays >= idleThresholdDays && !matchesAny(relativeTo2(t.projectRoot, root), exclude));
|
|
967
|
+
const treeMtimes = {};
|
|
968
|
+
for (const t of trees) {
|
|
969
|
+
try {
|
|
970
|
+
treeMtimes[t.path] = (await lstat5(t.path)).mtimeMs;
|
|
971
|
+
} catch {}
|
|
972
|
+
}
|
|
973
|
+
const empty = (check2) => ({
|
|
974
|
+
root,
|
|
975
|
+
idleThresholdDays,
|
|
976
|
+
trees,
|
|
977
|
+
groups: [],
|
|
978
|
+
reclaimableBytes: 0,
|
|
979
|
+
openFileCheck: check2,
|
|
980
|
+
excludedOpenFiles: 0,
|
|
981
|
+
treeMtimes,
|
|
982
|
+
interrupted: false
|
|
983
|
+
});
|
|
984
|
+
if (trees.length === 0)
|
|
985
|
+
return empty(opts.skipOpenFileCheck === true ? "skipped" : "clean");
|
|
986
|
+
let held = new Set;
|
|
987
|
+
let check = "skipped";
|
|
988
|
+
if (opts.skipOpenFileCheck !== true) {
|
|
989
|
+
held = await openFilesUnder(trees.map((t) => t.path), { lsofPath: opts.lsofPath });
|
|
990
|
+
if (held === null)
|
|
991
|
+
return empty("unavailable");
|
|
992
|
+
check = "clean";
|
|
993
|
+
}
|
|
994
|
+
const groups = await buildDuplicateIndex(trees.map((t) => t.path), {
|
|
995
|
+
minFileBytes: opts.minFileBytes,
|
|
996
|
+
onProgress: opts.onProgress,
|
|
997
|
+
signal: opts.signal
|
|
998
|
+
});
|
|
999
|
+
let excludedOpenFiles = 0;
|
|
1000
|
+
const filtered = [];
|
|
1001
|
+
for (const g of groups) {
|
|
1002
|
+
const files = g.files.filter((f) => {
|
|
1003
|
+
if (held !== null && held.has(f.path)) {
|
|
1004
|
+
excludedOpenFiles++;
|
|
1005
|
+
return false;
|
|
1006
|
+
}
|
|
1007
|
+
return true;
|
|
1008
|
+
});
|
|
1009
|
+
if (files.length < 2)
|
|
1010
|
+
continue;
|
|
1011
|
+
const canonical = files.includes(g.canonical) ? g.canonical : files[0];
|
|
1012
|
+
filtered.push({
|
|
1013
|
+
hash: g.hash,
|
|
1014
|
+
files,
|
|
1015
|
+
canonical,
|
|
1016
|
+
reclaimableBytes: files.filter((f) => f !== canonical).reduce((s, f) => s + f.sizeBytes, 0)
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
return {
|
|
1020
|
+
root,
|
|
1021
|
+
idleThresholdDays,
|
|
1022
|
+
trees,
|
|
1023
|
+
groups: filtered,
|
|
1024
|
+
reclaimableBytes: filtered.reduce((s, g) => s + g.reclaimableBytes, 0),
|
|
1025
|
+
openFileCheck: check,
|
|
1026
|
+
excludedOpenFiles,
|
|
1027
|
+
treeMtimes,
|
|
1028
|
+
interrupted: opts.signal?.aborted === true
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
function relativeTo2(path, root) {
|
|
1032
|
+
if (path === root)
|
|
1033
|
+
return ".";
|
|
1034
|
+
const prefix = root.endsWith("/") ? root : root + "/";
|
|
1035
|
+
return path.startsWith(prefix) ? path.slice(prefix.length) : path;
|
|
1036
|
+
}
|
|
1037
|
+
async function executeDedupe(plan, opts = {}) {
|
|
1038
|
+
const apply = opts.apply === true;
|
|
1039
|
+
const runId = opts.runId ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1040
|
+
const logPath = !apply ? null : opts.logPath === null ? null : opts.logPath ?? defaultLogPath();
|
|
1041
|
+
const failures = [];
|
|
1042
|
+
const abortedTrees = [];
|
|
1043
|
+
const abandoned = new Set;
|
|
1044
|
+
let replaced = 0;
|
|
1045
|
+
let freedBytes = 0;
|
|
1046
|
+
let interrupted = false;
|
|
1047
|
+
const treeMtime = new Map(Object.entries(plan.treeMtimes));
|
|
1048
|
+
outer:
|
|
1049
|
+
for (const g of plan.groups) {
|
|
1050
|
+
for (const target of g.files) {
|
|
1051
|
+
if (target === g.canonical)
|
|
1052
|
+
continue;
|
|
1053
|
+
if (opts.signal?.aborted === true) {
|
|
1054
|
+
interrupted = true;
|
|
1055
|
+
break outer;
|
|
1056
|
+
}
|
|
1057
|
+
if (abandoned.has(target.treeRoot))
|
|
1058
|
+
continue;
|
|
1059
|
+
const planned = treeMtime.get(target.treeRoot);
|
|
1060
|
+
if (planned !== undefined) {
|
|
1061
|
+
let now;
|
|
1062
|
+
try {
|
|
1063
|
+
now = (await lstat5(target.treeRoot)).mtimeMs;
|
|
1064
|
+
} catch {
|
|
1065
|
+
now = -1;
|
|
1066
|
+
}
|
|
1067
|
+
if (now !== planned) {
|
|
1068
|
+
abandoned.add(target.treeRoot);
|
|
1069
|
+
abortedTrees.push(target.treeRoot);
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
if (!apply)
|
|
1074
|
+
continue;
|
|
1075
|
+
const r = await replaceWithClone(g.canonical, target);
|
|
1076
|
+
if (!r.ok) {
|
|
1077
|
+
failures.push({ path: target.path, reason: r.reason });
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
replaced++;
|
|
1081
|
+
freedBytes += r.freedBytes;
|
|
1082
|
+
if (logPath !== null) {
|
|
1083
|
+
try {
|
|
1084
|
+
await mkdir2(dirname4(logPath), { recursive: true });
|
|
1085
|
+
await appendFile2(logPath, JSON.stringify({
|
|
1086
|
+
event: "dedupe",
|
|
1087
|
+
at: new Date().toISOString(),
|
|
1088
|
+
runId,
|
|
1089
|
+
root: plan.root,
|
|
1090
|
+
path: target.path,
|
|
1091
|
+
canonical: g.canonical.path,
|
|
1092
|
+
freedBytes: r.freedBytes,
|
|
1093
|
+
hash: g.hash
|
|
1094
|
+
}) + `
|
|
1095
|
+
`, "utf8");
|
|
1096
|
+
} catch {}
|
|
1097
|
+
}
|
|
1098
|
+
opts.onReplaced?.(target, r.freedBytes);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
return { dryRun: !apply, replaced, freedBytes, failures, abortedTrees, interrupted, runId, logPath };
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// src/commands/dedupe.ts
|
|
1105
|
+
async function dedupe(args) {
|
|
1106
|
+
const showProgress = process.stderr.isTTY === true;
|
|
1107
|
+
let lastDraw = 0;
|
|
1108
|
+
const ac = new AbortController;
|
|
1109
|
+
let interrupts = 0;
|
|
1110
|
+
const onSigint = () => {
|
|
1111
|
+
if (++interrupts === 1) {
|
|
1112
|
+
ac.abort();
|
|
1113
|
+
process.stderr.write(`
|
|
1114
|
+
stopping after the current file…
|
|
1115
|
+
`);
|
|
1116
|
+
} else
|
|
1117
|
+
process.exit(130);
|
|
1118
|
+
};
|
|
1119
|
+
process.on("SIGINT", onSigint);
|
|
1120
|
+
const plan = await planDedupe(args.cwd, {
|
|
1121
|
+
idleThresholdDays: args.days,
|
|
1122
|
+
exclude: args.exclude,
|
|
1123
|
+
minFileBytes: args.minFileBytes,
|
|
1124
|
+
skipOpenFileCheck: args.skipOpenCheck,
|
|
1125
|
+
lsofPath: args.lsofPath,
|
|
1126
|
+
maxDepth: args.depth,
|
|
1127
|
+
signal: ac.signal,
|
|
1128
|
+
onProgress: showProgress ? (n, b) => {
|
|
1129
|
+
const now = Date.now();
|
|
1130
|
+
if (now - lastDraw < 120)
|
|
1131
|
+
return;
|
|
1132
|
+
lastDraw = now;
|
|
1133
|
+
process.stderr.write(`\r hashing… ${n} files, ${humanBytes(b)}`.padEnd(60));
|
|
1134
|
+
} : undefined
|
|
1135
|
+
});
|
|
1136
|
+
if (showProgress)
|
|
1137
|
+
process.stderr.write("\r" + " ".repeat(62) + "\r");
|
|
1138
|
+
const result = await executeDedupe(plan, {
|
|
1139
|
+
apply: args.yes,
|
|
1140
|
+
logPath: args.logPath,
|
|
1141
|
+
signal: ac.signal
|
|
1142
|
+
});
|
|
1143
|
+
process.off("SIGINT", onSigint);
|
|
1144
|
+
if (args.json) {
|
|
1145
|
+
process.stdout.write(JSON.stringify({
|
|
1146
|
+
schemaVersion: 1,
|
|
1147
|
+
root: plan.root,
|
|
1148
|
+
idleThresholdDays: plan.idleThresholdDays,
|
|
1149
|
+
dryRun: result.dryRun,
|
|
1150
|
+
interrupted: plan.interrupted || result.interrupted,
|
|
1151
|
+
openFileCheck: plan.openFileCheck,
|
|
1152
|
+
excludedOpenFiles: plan.excludedOpenFiles,
|
|
1153
|
+
treeCount: plan.trees.length,
|
|
1154
|
+
groupCount: plan.groups.length,
|
|
1155
|
+
reclaimableBytes: plan.reclaimableBytes,
|
|
1156
|
+
freedBytes: result.freedBytes,
|
|
1157
|
+
replaced: result.replaced,
|
|
1158
|
+
runId: result.runId,
|
|
1159
|
+
logPath: result.logPath,
|
|
1160
|
+
failures: result.failures,
|
|
1161
|
+
abortedTrees: result.abortedTrees,
|
|
1162
|
+
groups: plan.groups.slice(0, 200).map((g) => ({
|
|
1163
|
+
copies: g.files.length,
|
|
1164
|
+
reclaimableBytes: g.reclaimableBytes,
|
|
1165
|
+
canonical: g.canonical.path
|
|
1166
|
+
}))
|
|
1167
|
+
}) + `
|
|
1168
|
+
`);
|
|
1169
|
+
return 0;
|
|
1170
|
+
}
|
|
1171
|
+
const out = ["D4C — deduplicate identical dependency files", ""];
|
|
1172
|
+
if (plan.openFileCheck === "unavailable") {
|
|
1173
|
+
out.push("Could not run lsof, so it is unknown whether anything has these files open.");
|
|
1174
|
+
out.push("Replacing a file a process is writing to would lose that write silently,");
|
|
1175
|
+
out.push("so nothing was examined. Install lsof, or pass --skip-open-check to");
|
|
1176
|
+
out.push("proceed without it.");
|
|
1177
|
+
process.stdout.write(out.join(`
|
|
1178
|
+
`) + `
|
|
1179
|
+
`);
|
|
1180
|
+
return 0;
|
|
1181
|
+
}
|
|
1182
|
+
if (plan.groups.length === 0) {
|
|
1183
|
+
out.push(`Nothing to deduplicate across ${plan.trees.length} tree(s)`);
|
|
1184
|
+
out.push(`untouched for ${plan.idleThresholdDays}+ days.`);
|
|
1185
|
+
if (plan.excludedOpenFiles > 0) {
|
|
1186
|
+
out.push("", `${plan.excludedOpenFiles} file(s) were skipped because a process has them open.`);
|
|
1187
|
+
}
|
|
1188
|
+
process.stdout.write(out.join(`
|
|
1189
|
+
`) + `
|
|
1190
|
+
`);
|
|
1191
|
+
return 0;
|
|
1192
|
+
}
|
|
1193
|
+
const verb = result.dryRun ? "Would reclaim" : "Reclaimed";
|
|
1194
|
+
const bytes = result.dryRun ? plan.reclaimableBytes : result.freedBytes;
|
|
1195
|
+
out.push(`${verb} ${humanBytes(bytes)} from ${plan.groups.length} duplicated file(s)`);
|
|
1196
|
+
out.push(`across ${plan.trees.length} tree(s) untouched for ${plan.idleThresholdDays}+ days.`, "");
|
|
1197
|
+
for (const g of plan.groups.slice(0, 15)) {
|
|
1198
|
+
const name = g.canonical.path.split("/node_modules/")[1] ?? relativize(g.canonical.path, plan.root);
|
|
1199
|
+
out.push(` ${humanBytes(g.reclaimableBytes).padStart(9)} ${String(g.files.length).padStart(3)} copies ${name.slice(0, 52)}`);
|
|
1200
|
+
}
|
|
1201
|
+
if (plan.groups.length > 15)
|
|
1202
|
+
out.push(` ... and ${plan.groups.length - 15} more`);
|
|
1203
|
+
if (plan.excludedOpenFiles > 0) {
|
|
1204
|
+
out.push("", `${plan.excludedOpenFiles} file(s) skipped — a process has them open.`);
|
|
1205
|
+
}
|
|
1206
|
+
if (result.abortedTrees.length > 0) {
|
|
1207
|
+
out.push("", `${result.abortedTrees.length} tree(s) changed while running and were left alone.`);
|
|
1208
|
+
}
|
|
1209
|
+
if (result.failures.length > 0) {
|
|
1210
|
+
out.push("", `${result.failures.length} file(s) failed a check and were not touched.`);
|
|
1211
|
+
}
|
|
1212
|
+
out.push("");
|
|
1213
|
+
out.push("Nothing is deleted. Each copy stays where it is and keeps working —");
|
|
1214
|
+
out.push("they just share storage until one of them is written to.");
|
|
1215
|
+
if (result.logPath !== null && result.replaced > 0) {
|
|
1216
|
+
out.push(`Recorded in ${result.logPath}.`);
|
|
1217
|
+
}
|
|
1218
|
+
if (result.dryRun)
|
|
1219
|
+
out.push("", "Nothing was changed. Re-run with --yes to apply.");
|
|
1220
|
+
process.stdout.write(out.join(`
|
|
1221
|
+
`) + `
|
|
1222
|
+
`);
|
|
1223
|
+
return plan.interrupted || result.interrupted ? 130 : 0;
|
|
1224
|
+
}
|
|
1225
|
+
var DEDUPE_DEFAULT_DAYS = DEFAULT_IDLE_THRESHOLD_DAYS2;
|
|
1226
|
+
|
|
1227
|
+
// src/commands/history.ts
|
|
1228
|
+
function groupByRun(records) {
|
|
1229
|
+
const runs = new Map;
|
|
1230
|
+
for (const r of records) {
|
|
1231
|
+
const list = runs.get(r.runId) ?? [];
|
|
1232
|
+
list.push(r);
|
|
1233
|
+
runs.set(r.runId, list);
|
|
1234
|
+
}
|
|
1235
|
+
return runs;
|
|
1236
|
+
}
|
|
1237
|
+
async function history(args) {
|
|
1238
|
+
const path = args.logPath ?? defaultLogPath();
|
|
1239
|
+
const records = await readHistory(path);
|
|
1240
|
+
if (args.json) {
|
|
1241
|
+
process.stdout.write(JSON.stringify({ schemaVersion: 1, logPath: path, records }) + `
|
|
1242
|
+
`);
|
|
1243
|
+
return 0;
|
|
1244
|
+
}
|
|
1245
|
+
if (records.length === 0) {
|
|
1246
|
+
process.stdout.write(`No deletions recorded.
|
|
1247
|
+
Log would be at ${path}
|
|
1248
|
+
`);
|
|
1249
|
+
return 0;
|
|
1250
|
+
}
|
|
1251
|
+
const runs = [...groupByRun(records).entries()].reverse().slice(0, args.limit);
|
|
1252
|
+
const out = [`D4C — deletion history (${path})`, ""];
|
|
1253
|
+
for (const [runId, recs] of runs) {
|
|
1254
|
+
const freed = recs.reduce((s, r) => s + r.freedBytes, 0);
|
|
1255
|
+
out.push(`${recs[0].at} run ${runId}`);
|
|
1256
|
+
out.push(` ${recs.length} tree(s), ${humanBytes(freed)} freed, scanned from ${recs[0].root}`);
|
|
1257
|
+
for (const r of recs.slice(0, 10)) {
|
|
1258
|
+
out.push(` ${humanBytes(r.freedBytes).padStart(9)} ${r.projectRoot}`);
|
|
1259
|
+
}
|
|
1260
|
+
if (recs.length > 10)
|
|
1261
|
+
out.push(` ... and ${recs.length - 10} more`);
|
|
1262
|
+
out.push("");
|
|
1263
|
+
}
|
|
1264
|
+
const cmds = new Set(records.map((r) => restoreCommand(r.lockfile)));
|
|
1265
|
+
out.push("Restore any project by running its install command in that directory:");
|
|
1266
|
+
for (const c of cmds)
|
|
1267
|
+
out.push(` ${c}`);
|
|
1268
|
+
out.push("", "Use --json for the full list.");
|
|
1269
|
+
process.stdout.write(out.join(`
|
|
1270
|
+
`) + `
|
|
1271
|
+
`);
|
|
1272
|
+
return 0;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// src/args.ts
|
|
1276
|
+
var VALUE_FLAGS = new Set(["--days", "--exclude", "--min-size", "--limit", "--log", "--depth", "--min-file", "--lsof"]);
|
|
1277
|
+
function parseArgs(argv) {
|
|
1278
|
+
const flags = new Set;
|
|
1279
|
+
const values = new Map;
|
|
1280
|
+
const errors = [];
|
|
1281
|
+
let command;
|
|
1282
|
+
for (let i = 0;i < argv.length; i++) {
|
|
1283
|
+
const a = argv[i];
|
|
1284
|
+
if (!a.startsWith("--")) {
|
|
1285
|
+
if (command === undefined)
|
|
1286
|
+
command = a;
|
|
1287
|
+
else
|
|
1288
|
+
errors.push(`unexpected argument: ${a}`);
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
const eq = a.indexOf("=");
|
|
1292
|
+
const key = eq >= 0 ? a.slice(0, eq) : a;
|
|
1293
|
+
if (VALUE_FLAGS.has(key)) {
|
|
1294
|
+
const v = eq >= 0 ? a.slice(eq + 1) : argv[++i];
|
|
1295
|
+
if (v === undefined) {
|
|
1296
|
+
errors.push(`${key} requires a value`);
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
const list = values.get(key) ?? [];
|
|
1300
|
+
list.push(v);
|
|
1301
|
+
values.set(key, list);
|
|
1302
|
+
} else {
|
|
1303
|
+
flags.add(key);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
return { command, flags, values, errors };
|
|
1307
|
+
}
|
|
1308
|
+
function firstValue(p, key) {
|
|
1309
|
+
return p.values.get(key)?.[0];
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// src/index.ts
|
|
1313
|
+
var USAGE = `d4c — dependency storage tooling for Git worktrees
|
|
1314
|
+
|
|
1315
|
+
Usage:
|
|
1316
|
+
d4c gc [options]
|
|
1317
|
+
d4c dedupe [options]
|
|
1318
|
+
d4c history [--limit N] [--json]
|
|
1319
|
+
|
|
1320
|
+
gc Report node_modules trees idle long enough to delete safely,
|
|
1321
|
+
and delete them with --yes. Dry-run by default.
|
|
1322
|
+
dedupe Make identical files across trees share storage. Deletes
|
|
1323
|
+
nothing. Dry-run by default.
|
|
1324
|
+
history Show what previous runs deleted, and how to restore it.
|
|
1325
|
+
|
|
1326
|
+
Options:
|
|
1327
|
+
--days N Idle threshold in days (default ${DEFAULT_DAYS})
|
|
1328
|
+
--exclude PATTERN Protect matching paths. Repeatable. Supports * and **.
|
|
1329
|
+
--min-size SIZE Ignore trees smaller than this (e.g. 10M, 1G)
|
|
1330
|
+
--depth N How deep to search below the current directory (default 8)
|
|
1331
|
+
--yes Actually delete. Without it nothing is removed.
|
|
1332
|
+
--json Machine-readable output on stdout only.
|
|
1333
|
+
--limit N history: how many runs to show (default 5)
|
|
1334
|
+
--min-file SIZE dedupe: ignore files smaller than this (default 64K)
|
|
1335
|
+
--skip-open-check dedupe: proceed without checking for open files
|
|
1336
|
+
--lsof PATH dedupe: path to lsof
|
|
1337
|
+
`;
|
|
1338
|
+
async function main(argv) {
|
|
1339
|
+
const p = parseArgs(argv);
|
|
1340
|
+
if (p.command === undefined || p.flags.has("--help") || p.command === "help") {
|
|
1341
|
+
process.stdout.write(USAGE);
|
|
1342
|
+
return 0;
|
|
1343
|
+
}
|
|
1344
|
+
if (p.errors.length > 0) {
|
|
1345
|
+
for (const e of p.errors)
|
|
1346
|
+
process.stderr.write(`${e}
|
|
1347
|
+
`);
|
|
1348
|
+
return 70;
|
|
1349
|
+
}
|
|
1350
|
+
if (p.command === "gc") {
|
|
1351
|
+
const daysRaw = firstValue(p, "--days");
|
|
1352
|
+
const days = daysRaw === undefined ? DEFAULT_DAYS : Number(daysRaw);
|
|
1353
|
+
if (!Number.isFinite(days) || days < 1) {
|
|
1354
|
+
process.stderr.write(`--days must be at least 1
|
|
1355
|
+
`);
|
|
1356
|
+
return 70;
|
|
1357
|
+
}
|
|
1358
|
+
const depthRaw = firstValue(p, "--depth");
|
|
1359
|
+
if (depthRaw !== undefined && (!Number.isFinite(Number(depthRaw)) || Number(depthRaw) < 1)) {
|
|
1360
|
+
process.stderr.write(`--depth must be a positive number
|
|
1361
|
+
`);
|
|
1362
|
+
return 70;
|
|
1363
|
+
}
|
|
1364
|
+
const sizeRaw = firstValue(p, "--min-size");
|
|
1365
|
+
const minSizeBytes = sizeRaw === undefined ? 0 : parseSize(sizeRaw) ?? -1;
|
|
1366
|
+
if (minSizeBytes < 0) {
|
|
1367
|
+
process.stderr.write(`--min-size: cannot parse ${JSON.stringify(sizeRaw)}
|
|
1368
|
+
`);
|
|
1369
|
+
return 70;
|
|
1370
|
+
}
|
|
1371
|
+
return gc({
|
|
1372
|
+
json: p.flags.has("--json"),
|
|
1373
|
+
yes: p.flags.has("--yes"),
|
|
1374
|
+
days,
|
|
1375
|
+
cwd: process.cwd(),
|
|
1376
|
+
exclude: p.values.get("--exclude") ?? [],
|
|
1377
|
+
minSizeBytes,
|
|
1378
|
+
logPath: firstValue(p, "--log"),
|
|
1379
|
+
depth: depthRaw === undefined ? undefined : Number(depthRaw)
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
if (p.command === "dedupe") {
|
|
1383
|
+
const daysRaw = firstValue(p, "--days");
|
|
1384
|
+
const days = daysRaw === undefined ? DEDUPE_DEFAULT_DAYS : Number(daysRaw);
|
|
1385
|
+
if (!Number.isFinite(days) || days < 1) {
|
|
1386
|
+
process.stderr.write(`--days must be at least 1
|
|
1387
|
+
`);
|
|
1388
|
+
return 70;
|
|
1389
|
+
}
|
|
1390
|
+
const mfRaw = firstValue(p, "--min-file");
|
|
1391
|
+
const minFileBytes = mfRaw === undefined ? undefined : parseSize(mfRaw) ?? -1;
|
|
1392
|
+
if (minFileBytes !== undefined && minFileBytes < 0) {
|
|
1393
|
+
process.stderr.write(`--min-file: cannot parse ${JSON.stringify(mfRaw)}
|
|
1394
|
+
`);
|
|
1395
|
+
return 70;
|
|
1396
|
+
}
|
|
1397
|
+
const depthRaw2 = firstValue(p, "--depth");
|
|
1398
|
+
return dedupe({
|
|
1399
|
+
json: p.flags.has("--json"),
|
|
1400
|
+
yes: p.flags.has("--yes"),
|
|
1401
|
+
days,
|
|
1402
|
+
cwd: process.cwd(),
|
|
1403
|
+
exclude: p.values.get("--exclude") ?? [],
|
|
1404
|
+
minFileBytes,
|
|
1405
|
+
skipOpenCheck: p.flags.has("--skip-open-check"),
|
|
1406
|
+
lsofPath: firstValue(p, "--lsof"),
|
|
1407
|
+
logPath: firstValue(p, "--log"),
|
|
1408
|
+
depth: depthRaw2 === undefined ? undefined : Number(depthRaw2)
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
if (p.command === "history") {
|
|
1412
|
+
const limitRaw = firstValue(p, "--limit");
|
|
1413
|
+
const limit = limitRaw === undefined ? 5 : Number(limitRaw);
|
|
1414
|
+
if (!Number.isFinite(limit) || limit < 1) {
|
|
1415
|
+
process.stderr.write(`--limit must be a positive number
|
|
1416
|
+
`);
|
|
1417
|
+
return 70;
|
|
1418
|
+
}
|
|
1419
|
+
return history({ json: p.flags.has("--json"), limit, logPath: firstValue(p, "--log") });
|
|
1420
|
+
}
|
|
1421
|
+
process.stderr.write(`unknown command: ${p.command}
|
|
1422
|
+
|
|
1423
|
+
${USAGE}`);
|
|
1424
|
+
return 70;
|
|
1425
|
+
}
|
|
1426
|
+
main(process.argv.slice(2)).then((code) => {
|
|
1427
|
+
process.exitCode = code;
|
|
1428
|
+
}).catch((err) => {
|
|
1429
|
+
process.stderr.write(`internal error: ${err?.stack ?? err}
|
|
1430
|
+
`);
|
|
1431
|
+
process.exitCode = 70;
|
|
1432
|
+
});
|