disk-space-saviour 0.20.0 → 0.22.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/CHANGELOG.md +16 -0
- package/README.md +70 -12
- package/package.json +1 -1
- package/src/app-owners.js +87 -0
- package/src/clean.js +24 -0
- package/src/cli.js +32 -3
- package/src/conditions.js +145 -0
- package/src/env/local.js +91 -8
- package/src/git-remote.js +168 -0
- package/src/git.js +147 -6
- package/src/index.d.ts +65 -1
- package/src/index.js +1 -0
- package/src/items.js +3 -0
- package/src/liveness.js +113 -4
- package/src/playwright-registry.js +77 -0
- package/src/report.js +143 -28
- package/src/rules/apps.js +3 -2
- package/src/rules/downloads.js +5 -3
- package/src/rules/other.js +56 -10
- package/src/scan.js +37 -4
- package/src/scanners/app-clones.js +53 -0
- package/src/scanners/common.js +1 -0
- package/src/scanners/global.js +96 -10
- package/src/scanners/projects.js +18 -2
- package/src/scanners/versions.js +9 -6
- package/src/swap.js +286 -28
- package/src/usage.js +601 -0
package/src/liveness.js
CHANGED
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
|
|
20
|
+
import {
|
|
21
|
+
bundleRoots,
|
|
22
|
+
bundlesOwn,
|
|
23
|
+
ownerBundleId,
|
|
24
|
+
processBundles,
|
|
25
|
+
} from './app-owners.js';
|
|
20
26
|
import { isWithin } from './paths.js';
|
|
21
27
|
import { formatDuration } from './units.js';
|
|
22
28
|
|
|
@@ -195,6 +201,12 @@ export function matchingName(proc, names) {
|
|
|
195
201
|
return null;
|
|
196
202
|
}
|
|
197
203
|
|
|
204
|
+
/** `matched`, prefixed with the app it runs from: `Warp (stable)`. */
|
|
205
|
+
function appLabel(proc, matched) {
|
|
206
|
+
const app = proc.bundles?.[0]?.name;
|
|
207
|
+
return app && app !== matched ? `${app} (${matched})` : matched;
|
|
208
|
+
}
|
|
209
|
+
|
|
198
210
|
export class LivenessProbe {
|
|
199
211
|
/**
|
|
200
212
|
* @param {object} env environment adapter
|
|
@@ -212,6 +224,9 @@ export class LivenessProbe {
|
|
|
212
224
|
this.realPaths = new Map();
|
|
213
225
|
this.pathOwners = new Map();
|
|
214
226
|
this.pathStats = new Map();
|
|
227
|
+
this.inodes = new Map();
|
|
228
|
+
// Bundle identifiers never change, so they outlive refreshes.
|
|
229
|
+
this.bundleIds = new Map();
|
|
215
230
|
}
|
|
216
231
|
|
|
217
232
|
async refresh(force = false) {
|
|
@@ -223,6 +238,7 @@ export class LivenessProbe {
|
|
|
223
238
|
this.listed = processes !== null;
|
|
224
239
|
this.processes = processes ?? [];
|
|
225
240
|
this.bunCacheUsers = await this.#bunCacheUsers();
|
|
241
|
+
await this.#resolveBundles();
|
|
226
242
|
this.probeError =
|
|
227
243
|
(processes === null || this.openPaths === null) &&
|
|
228
244
|
(this.env.platform === 'linux' || this.env.platform === 'darwin');
|
|
@@ -230,6 +246,96 @@ export class LivenessProbe {
|
|
|
230
246
|
this.realPaths.clear();
|
|
231
247
|
this.pathStats.clear();
|
|
232
248
|
this.pathOwners.clear();
|
|
249
|
+
this.inodes.clear();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Reads the identifiers of the app bundles running processes come from. */
|
|
253
|
+
async #resolveBundles() {
|
|
254
|
+
if (!this.env.bundleIdentifier) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
for (const proc of this.processes) {
|
|
258
|
+
const roots = bundleRoots(proc.executable);
|
|
259
|
+
for (const root of roots) {
|
|
260
|
+
if (!this.bundleIds.has(root)) {
|
|
261
|
+
this.bundleIds.set(
|
|
262
|
+
root,
|
|
263
|
+
await this.env.bundleIdentifier(root).catch(() => null)
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
proc.bundles = processBundles(roots, this.bundleIds);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** ` (pid N, name)` of the process holding `open`, when the probe knows it. */
|
|
272
|
+
#holder(open) {
|
|
273
|
+
const pid = this.env.openPathPids?.get(open);
|
|
274
|
+
if (!pid) {
|
|
275
|
+
return '';
|
|
276
|
+
}
|
|
277
|
+
const proc = this.processes.find((entry) => entry.pid === pid);
|
|
278
|
+
return proc
|
|
279
|
+
? ` (pid ${pid}, ${appLabel(proc, proc.name)})`
|
|
280
|
+
: ` (pid ${pid})`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** A running app that owns a per-app Library folder of `paths`. */
|
|
284
|
+
appUsage(paths) {
|
|
285
|
+
const owners = [...new Set(paths.map(ownerBundleId).filter(Boolean))];
|
|
286
|
+
if (owners.length === 0) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
for (const proc of this.processes) {
|
|
290
|
+
const owner = owners.find((id) => bundlesOwn(proc.bundles ?? [], id));
|
|
291
|
+
if (owner) {
|
|
292
|
+
return `${appLabel(proc, proc.name)} is running (pid ${proc.pid}) and owns ${owner}`;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Inodes of `executables` and of the open files that share their names,
|
|
300
|
+
* because a process running a hard link of an executable reports
|
|
301
|
+
* whichever path it was started from.
|
|
302
|
+
* @param {string[]} [executables]
|
|
303
|
+
*/
|
|
304
|
+
async resolveExecutables(executables = []) {
|
|
305
|
+
if (executables.length === 0) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const names = new Set(
|
|
309
|
+
executables.map((target) => this.env.path.basename(target))
|
|
310
|
+
);
|
|
311
|
+
const missing = [
|
|
312
|
+
...executables,
|
|
313
|
+
...[...(this.openPaths ?? [])].filter((open) =>
|
|
314
|
+
names.has(this.env.path.basename(open))
|
|
315
|
+
),
|
|
316
|
+
].filter((target) => !this.inodes.has(target));
|
|
317
|
+
const stats = await Promise.all(
|
|
318
|
+
missing.map((target) => this.env.stat(target))
|
|
319
|
+
);
|
|
320
|
+
missing.forEach((target, index) =>
|
|
321
|
+
this.inodes.set(target, stats[index]?.inode ?? null)
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** A process running one of `executables` under any of its links. */
|
|
326
|
+
executableUsage(executables = []) {
|
|
327
|
+
for (const target of executables) {
|
|
328
|
+
const inode = this.inodes.get(target);
|
|
329
|
+
if (!inode) {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
for (const open of this.openPaths ?? []) {
|
|
333
|
+
if (open !== target && this.inodes.get(open) === inode) {
|
|
334
|
+
return `in use: ${open} is ${target}`;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
233
339
|
}
|
|
234
340
|
|
|
235
341
|
/**
|
|
@@ -360,10 +466,11 @@ export class LivenessProbe {
|
|
|
360
466
|
) {
|
|
361
467
|
continue;
|
|
362
468
|
}
|
|
363
|
-
const
|
|
364
|
-
if (!
|
|
469
|
+
const matched = matchingName(proc, names);
|
|
470
|
+
if (!matched) {
|
|
365
471
|
continue;
|
|
366
472
|
}
|
|
473
|
+
const name = appLabel(proc, matched);
|
|
367
474
|
if (!cwd) {
|
|
368
475
|
return `${name} is running (pid ${proc.pid})`;
|
|
369
476
|
}
|
|
@@ -386,7 +493,7 @@ export class LivenessProbe {
|
|
|
386
493
|
for (const open of this.openPaths) {
|
|
387
494
|
for (const target of targets) {
|
|
388
495
|
if (isWithin(open, target, pathApi)) {
|
|
389
|
-
return `in use: ${open}`;
|
|
496
|
+
return `in use: ${open}${this.#holder(open)}`;
|
|
390
497
|
}
|
|
391
498
|
}
|
|
392
499
|
}
|
|
@@ -421,7 +528,7 @@ export class LivenessProbe {
|
|
|
421
528
|
candidate &&
|
|
422
529
|
paths.some((target) => isWithin(candidate, target, this.env.path))
|
|
423
530
|
) {
|
|
424
|
-
return `in use: ${candidate} (pid ${proc.pid})`;
|
|
531
|
+
return `in use: ${candidate} (pid ${proc.pid}, ${appLabel(proc, proc.name)})`;
|
|
425
532
|
}
|
|
426
533
|
}
|
|
427
534
|
}
|
|
@@ -510,7 +617,9 @@ export class LivenessProbe {
|
|
|
510
617
|
this.probeBlocker() ??
|
|
511
618
|
(checks.mtime === false ? null : this.recentWrite(item.newestMtimeMs)) ??
|
|
512
619
|
this.runningTool(checks.busy, checks.cwd, item) ??
|
|
620
|
+
this.appUsage(item.paths ?? []) ??
|
|
513
621
|
this.processUsage(paths) ??
|
|
622
|
+
this.executableUsage(checks.executables) ??
|
|
514
623
|
this.uncertainUsage(item.paths ?? [])
|
|
515
624
|
);
|
|
516
625
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser revisions that installed Playwright packages still launch.
|
|
3
|
+
*
|
|
4
|
+
* Playwright records every package it installed browsers for as a file in
|
|
5
|
+
* `<registry>/.links/` holding that package's directory, and the package's
|
|
6
|
+
* `browsers.json` names the revisions it launches. Playwright's own cleanup
|
|
7
|
+
* keeps a revision while a linked package that still exists names it; dss
|
|
8
|
+
* keeps the same revisions, so deleting one never breaks an installed
|
|
9
|
+
* project until its next `playwright install`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Directory of a registry revision: `chromium-headless-shell` → `chromium_headless_shell-1248`. */
|
|
13
|
+
export function browserDirName(name, revision) {
|
|
14
|
+
return `${name.replace(/-/g, '_')}-${revision}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Revision directories one `browsers.json` names, platform overrides included. */
|
|
18
|
+
export function requiredDirs(text) {
|
|
19
|
+
const browsers = JSON.parse(text)?.browsers;
|
|
20
|
+
if (!Array.isArray(browsers)) {
|
|
21
|
+
throw new Error('no browsers list');
|
|
22
|
+
}
|
|
23
|
+
return browsers.flatMap((browser) =>
|
|
24
|
+
[browser.revision, ...Object.values(browser.revisionOverrides ?? {})]
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.map((revision) => browserDirName(browser.name, revision))
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Maps each revision directory name to the linked packages that need it;
|
|
32
|
+
* `unreadable` lists links whose package exists but cannot be read.
|
|
33
|
+
*/
|
|
34
|
+
export async function playwrightUsers(env, registry) {
|
|
35
|
+
const users = new Map();
|
|
36
|
+
const unreadable = [];
|
|
37
|
+
for (const link of await env.list(env.path.join(registry, '.links'))) {
|
|
38
|
+
const pkg = (await env.readText(link.path))?.trim();
|
|
39
|
+
if (!pkg) {
|
|
40
|
+
unreadable.push(link.path);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const manifest = env.path.join(pkg, 'browsers.json');
|
|
44
|
+
if (!(await env.exists(manifest))) {
|
|
45
|
+
// The project or its node_modules is gone; Playwright drops the link too.
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
for (const dir of requiredDirs((await env.readText(manifest)) ?? '')) {
|
|
50
|
+
users.set(dir, [...(users.get(dir) ?? []), pkg]);
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
unreadable.push(manifest);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { users, unreadable };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Why `target`, a revision directory in a Playwright registry, must stay;
|
|
61
|
+
* null when no installed package needs it.
|
|
62
|
+
* @param {Map<string, Promise<object>>} [cache] registry → playwrightUsers
|
|
63
|
+
*/
|
|
64
|
+
export async function playwrightBlocker(env, target, cache = new Map()) {
|
|
65
|
+
const registry = env.path.dirname(target);
|
|
66
|
+
if (!cache.has(registry)) {
|
|
67
|
+
cache.set(registry, playwrightUsers(env, registry));
|
|
68
|
+
}
|
|
69
|
+
const { users, unreadable } = await cache.get(registry);
|
|
70
|
+
if (unreadable.length > 0) {
|
|
71
|
+
return `cannot read Playwright install record ${unreadable[0]}`;
|
|
72
|
+
}
|
|
73
|
+
const needed = users.get(env.path.basename(target));
|
|
74
|
+
return needed
|
|
75
|
+
? `Playwright in ${needed[0]} still uses ${env.path.basename(target)}`
|
|
76
|
+
: null;
|
|
77
|
+
}
|
package/src/report.js
CHANGED
|
@@ -82,7 +82,14 @@ function tierSection(report, tier, options) {
|
|
|
82
82
|
'',
|
|
83
83
|
`${tier.toUpperCase()} ${plural(items.length, 'item')}, ${formatBytes(bytes)}`,
|
|
84
84
|
...limited(
|
|
85
|
-
items.
|
|
85
|
+
items.flatMap((item) => [
|
|
86
|
+
itemLine(item, showEnv),
|
|
87
|
+
...(item.globalPackages?.length
|
|
88
|
+
? [
|
|
89
|
+
` also removes global packages: ${item.globalPackages.join(', ')}`,
|
|
90
|
+
]
|
|
91
|
+
: []),
|
|
92
|
+
]),
|
|
86
93
|
options.limit
|
|
87
94
|
),
|
|
88
95
|
];
|
|
@@ -107,6 +114,72 @@ function blockedSection(report, options) {
|
|
|
107
114
|
];
|
|
108
115
|
}
|
|
109
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Clones that share every block with an installed app: `du` and Finder
|
|
119
|
+
* count them at full size, deleting them frees nothing.
|
|
120
|
+
*/
|
|
121
|
+
export function sharedSection(shared) {
|
|
122
|
+
if (!shared?.length) {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
const apparent = shared.reduce((sum, item) => sum + item.apparentBytes, 0);
|
|
126
|
+
return [
|
|
127
|
+
'',
|
|
128
|
+
`SHARED WITH INSTALLED APPS ${plural(shared.length, 'item')}, ${formatBytes(apparent)} as du counts it, about 0 B to free (APFS clones; the app recreates them at launch)`,
|
|
129
|
+
...shared.map(
|
|
130
|
+
(item) =>
|
|
131
|
+
` ${pad(formatBytes(item.apparentBytes), 10)} ${item.rule} ${item.path} (shares its blocks with ${item.sharedWith})`
|
|
132
|
+
),
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function tierBreakdown(tiers) {
|
|
137
|
+
return TIERS.filter((tier) => tiers[tier])
|
|
138
|
+
.map((tier) => `${tier} ${formatBytes(tiers[tier])}`)
|
|
139
|
+
.join(', ');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** One-line warning naming the largest conditions, or null. */
|
|
143
|
+
export function conditionsWarning(blocked) {
|
|
144
|
+
const conditions = blocked?.conditions ?? [];
|
|
145
|
+
if (conditions.length === 0) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const bytes = conditions.reduce((sum, group) => sum + group.bytes, 0);
|
|
149
|
+
const top = conditions
|
|
150
|
+
.slice(0, 3)
|
|
151
|
+
.map((group) => `${group.action} (${formatBytes(group.bytes)})`);
|
|
152
|
+
const more = conditions.length > 3 ? `, ${conditions.length - 3} more` : '';
|
|
153
|
+
return `${formatBytes(bytes)} more frees only once something stops using it: ${top.join('; ')}${more}. See FREEABLE ON CONDITIONS.`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function conditionsWarningLines(blocked) {
|
|
157
|
+
const warning = conditionsWarning(blocked);
|
|
158
|
+
return warning ? [`WARNING: ${warning}`] : [];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Blocked space grouped by the condition that frees it. */
|
|
162
|
+
export function conditionsSection(blocked, options = {}) {
|
|
163
|
+
const conditions = blocked?.conditions ?? [];
|
|
164
|
+
if (conditions.length === 0) {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
const lines = conditions.map(
|
|
168
|
+
(group) =>
|
|
169
|
+
` ${pad(formatBytes(group.bytes), 9)} ${group.action} (${plural(group.items, 'item')}: ${tierBreakdown(group.tiers)})`
|
|
170
|
+
);
|
|
171
|
+
if (blocked.other?.items) {
|
|
172
|
+
lines.push(
|
|
173
|
+
` ${pad(formatBytes(blocked.other.bytes), 9)} kept for other reasons (Git state, still needed, protected or unverifiable): ${plural(blocked.other.items, 'item')}`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return [
|
|
177
|
+
'',
|
|
178
|
+
'FREEABLE ON CONDITIONS (blocked items free once these happen; rerun dss afterwards)',
|
|
179
|
+
...limited(lines, options.limit ?? DEFAULT_LIMIT),
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
|
|
110
183
|
function repoSummary(repo) {
|
|
111
184
|
if (repo.error) {
|
|
112
185
|
return `${repo.root}: ${repo.error}`;
|
|
@@ -351,6 +424,54 @@ function timingLines(report, { verbose }) {
|
|
|
351
424
|
});
|
|
352
425
|
}
|
|
353
426
|
|
|
427
|
+
function holderLine(holder) {
|
|
428
|
+
const details = [`${formatBytes(holder.memoryBytes)} memory`];
|
|
429
|
+
if (holder.compressedBytes !== undefined) {
|
|
430
|
+
details.push(`${formatBytes(holder.compressedBytes)} compressed`);
|
|
431
|
+
}
|
|
432
|
+
if (holder.swappedBytes !== undefined) {
|
|
433
|
+
details.push(`${formatBytes(holder.swappedBytes)} swapped`);
|
|
434
|
+
}
|
|
435
|
+
const change = holder.growth?.memoryBytes;
|
|
436
|
+
if (change) {
|
|
437
|
+
details.push(
|
|
438
|
+
`${change > 0 ? '+' : '-'}${formatBytes(Math.abs(change))} since ${new Date(holder.growth.since).toISOString()}`
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
return `${holder.pid} ${holder.command}: ${details.join(', ')}`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function swapLines(swap) {
|
|
445
|
+
if (!swap) {
|
|
446
|
+
return [];
|
|
447
|
+
}
|
|
448
|
+
return [
|
|
449
|
+
`Swap: ${formatBytes(swap.used)} used of ${formatBytes(swap.total)} (managed by macOS)`,
|
|
450
|
+
...(swap.warnings ?? []).map((warning) => `WARNING: ${warning}`),
|
|
451
|
+
swap.attribution,
|
|
452
|
+
...swap.holders.map((holder) => ` ${holderLine(holder)}`),
|
|
453
|
+
swap.advice,
|
|
454
|
+
];
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function environmentStatusLines(env) {
|
|
458
|
+
const lines = [];
|
|
459
|
+
if (env.layerSource) {
|
|
460
|
+
lines.push(` ${env.label}: layer source ${env.layerSource}`);
|
|
461
|
+
}
|
|
462
|
+
if (env.scanStatus === 'goal-reached') {
|
|
463
|
+
lines.push(
|
|
464
|
+
` ${env.label}: scan stopped after the free-space goal was met`
|
|
465
|
+
);
|
|
466
|
+
} else if (env.scanStatus && env.scanStatus !== 'complete') {
|
|
467
|
+
lines.push(` ${env.label}: scan incomplete (${env.scanStatus})`);
|
|
468
|
+
}
|
|
469
|
+
if (env.hint) {
|
|
470
|
+
lines.push(` ${env.label}: ${env.hint}`);
|
|
471
|
+
}
|
|
472
|
+
return lines;
|
|
473
|
+
}
|
|
474
|
+
|
|
354
475
|
/**
|
|
355
476
|
* Renders a scan report.
|
|
356
477
|
* @param {object} report
|
|
@@ -364,37 +485,15 @@ export function formatReport(report, options = {}) {
|
|
|
364
485
|
`disk-space-saviour scan of ${host.label} in ${(report.durationMs / 1000).toFixed(1)}s (report only, nothing was deleted)`,
|
|
365
486
|
formatDisk(host.disk),
|
|
366
487
|
];
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
`Swap: ${formatBytes(host.swap.used)} used of ${formatBytes(host.swap.total)} (managed by macOS)`
|
|
370
|
-
);
|
|
371
|
-
lines.push(host.swap.attribution);
|
|
372
|
-
for (const holder of host.swap.holders) {
|
|
373
|
-
lines.push(
|
|
374
|
-
` ${holder.pid} ${holder.command}: ${formatBytes(holder.memoryBytes)} memory, ${formatBytes(holder.compressedBytes)} compressed`
|
|
375
|
-
);
|
|
376
|
-
}
|
|
377
|
-
lines.push(host.swap.advice);
|
|
378
|
-
}
|
|
488
|
+
lines.push(...swapLines(host.swap));
|
|
489
|
+
lines.push(...conditionsWarningLines(report.totals?.blocked));
|
|
379
490
|
for (const tier of TIERS) {
|
|
380
491
|
lines.push(...tierSection(report, tier, { limit }));
|
|
381
492
|
}
|
|
382
|
-
|
|
383
|
-
if (env.layerSource) {
|
|
384
|
-
lines.push(` ${env.label}: layer source ${env.layerSource}`);
|
|
385
|
-
}
|
|
386
|
-
if (env.scanStatus === 'goal-reached') {
|
|
387
|
-
lines.push(
|
|
388
|
-
` ${env.label}: scan stopped after the free-space goal was met`
|
|
389
|
-
);
|
|
390
|
-
} else if (env.scanStatus && env.scanStatus !== 'complete') {
|
|
391
|
-
lines.push(` ${env.label}: scan incomplete (${env.scanStatus})`);
|
|
392
|
-
}
|
|
393
|
-
if (env.hint) {
|
|
394
|
-
lines.push(` ${env.label}: ${env.hint}`);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
493
|
+
lines.push(...report.environments.flatMap(environmentStatusLines));
|
|
397
494
|
lines.push(...blockedSection(report, { limit }));
|
|
495
|
+
lines.push(...sharedSection(report.shared));
|
|
496
|
+
lines.push(...conditionsSection(report.totals?.blocked, { limit }));
|
|
398
497
|
lines.push(...dockerSection(report));
|
|
399
498
|
lines.push(...errorsSection(report.errors));
|
|
400
499
|
lines.push(...totalsSection(report));
|
|
@@ -474,6 +573,14 @@ function emergencyLines(audit) {
|
|
|
474
573
|
* @param {object} audit
|
|
475
574
|
* @param {{verbose?: boolean}} [options]
|
|
476
575
|
*/
|
|
576
|
+
/** Warns that a plan built on an incomplete scan misses what it never reached. */
|
|
577
|
+
export function incompleteScanLines(incomplete) {
|
|
578
|
+
return (incomplete ?? []).map(
|
|
579
|
+
(env) =>
|
|
580
|
+
`WARNING: the scan of ${env.label} behind this plan stopped early (${env.status}), so items it did not reach were not considered; rerun with a larger --scan-budget (for example --scan-budget 5m).`
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
477
584
|
export function formatAudit(audit, options = {}) {
|
|
478
585
|
const limit = options.verbose ? Infinity : DEFAULT_LIMIT * 2;
|
|
479
586
|
const mode = audit.dryRun ? 'dry run, nothing was deleted' : 'deleting';
|
|
@@ -502,6 +609,14 @@ export function formatAudit(audit, options = {}) {
|
|
|
502
609
|
? `Would free ${formatBytes(audit.plannedBytes)} (run with --yes to delete)`
|
|
503
610
|
: `Freed ${formatBytes(audit.freedBytes)}`
|
|
504
611
|
);
|
|
612
|
+
lines.push(...incompleteScanLines(audit.incomplete));
|
|
613
|
+
const conditions = conditionsWarning(audit.blocked);
|
|
614
|
+
if (conditions) {
|
|
615
|
+
lines.push(
|
|
616
|
+
conditions,
|
|
617
|
+
...conditionsSection(audit.blocked, options).slice(2)
|
|
618
|
+
);
|
|
619
|
+
}
|
|
505
620
|
if (audit.file) {
|
|
506
621
|
lines.push(`Audit log: ${audit.file}`);
|
|
507
622
|
}
|
package/src/rules/apps.js
CHANGED
|
@@ -36,13 +36,14 @@ export const APP_RULES = [
|
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
id: 'telegram-downloaded-media',
|
|
39
|
-
excludeNames: ['cache', 'cache-storage', 'short-cache'],
|
|
40
39
|
ecosystem: 'apps',
|
|
41
40
|
tier: 'moderate',
|
|
42
41
|
description:
|
|
43
42
|
'Telegram downloaded media: secret-chat media may not be recoverable',
|
|
43
|
+
// Only media Telegram downloads again from its cloud; `storage` holds
|
|
44
|
+
// the media database and `local-file-*` files the user sent.
|
|
44
45
|
paths: [
|
|
45
|
-
`${telegramAccounts}
|
|
46
|
+
`${telegramAccounts}/telegram-cloud-*`,
|
|
46
47
|
...telegramDesktop.map((root) => `${root}/user_data/downloads`),
|
|
47
48
|
],
|
|
48
49
|
busy: ['Telegram', 'telegram-desktop'],
|
package/src/rules/downloads.js
CHANGED
|
@@ -20,10 +20,12 @@ export const DOWNLOAD_RULES = [
|
|
|
20
20
|
description:
|
|
21
21
|
'Chromium app bundle clones left by exited browsers (an update makes them full copies)',
|
|
22
22
|
// Chrome clones its bundle into the per-user `X` directory next to
|
|
23
|
-
// `$TMPDIR` at launch and removes the clone when it exits. A
|
|
24
|
-
//
|
|
25
|
-
//
|
|
23
|
+
// `$TMPDIR` at launch and removes the clone when it exits. A clone
|
|
24
|
+
// stays while any process runs its hard-linked main executable (see
|
|
25
|
+
// ../scanners/app-clones.js): a browser name would block orphans
|
|
26
|
+
// forever, and open files miss a browser started before the update.
|
|
26
27
|
paths: ['{TMP}/../X/*.code_sign_clone/code_sign_clone.*'],
|
|
28
|
+
appClone: { installed: ['/Applications', '~/Applications'] },
|
|
27
29
|
directoryOnly: true,
|
|
28
30
|
minAge: 'stale',
|
|
29
31
|
busy: [],
|
package/src/rules/other.js
CHANGED
|
@@ -12,7 +12,11 @@
|
|
|
12
12
|
* otherwise each match is its own item. `minAge: 'stale'` only reports
|
|
13
13
|
* matches untouched for the stale-age window, `minAge: 'inactive'` for the
|
|
14
14
|
* project inactivity window. `git: true` refuses matches that are Git work
|
|
15
|
-
* trees with uncommitted, unpushed, or stashed changes.
|
|
15
|
+
* trees with uncommitted, unpushed, or stashed changes. `git: 'clone'`
|
|
16
|
+
* matches only Git work trees with an https or ssh remote, and refuses them
|
|
17
|
+
* unless every worktree is clean and the remote still advertises every
|
|
18
|
+
* local commit. `registry: 'playwright'` keeps revisions that a Playwright
|
|
19
|
+
* package recorded in the registry's `.links` still launches.
|
|
16
20
|
*/
|
|
17
21
|
|
|
18
22
|
export const OTHER_RULES = [
|
|
@@ -29,15 +33,10 @@ export const OTHER_RULES = [
|
|
|
29
33
|
versionPattern: /^(?<family>[a-z_]+)-(?<version>\d+)$/,
|
|
30
34
|
minAge: 'inactive',
|
|
31
35
|
tier: 'safe',
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
'chromium',
|
|
37
|
-
'headless_shell',
|
|
38
|
-
'firefox',
|
|
39
|
-
'webkit',
|
|
40
|
-
],
|
|
36
|
+
registry: 'playwright',
|
|
37
|
+
// A running Playwright launches the revisions its package names, which
|
|
38
|
+
// the registry check keeps; a revision a browser runs from is open.
|
|
39
|
+
busy: [],
|
|
41
40
|
},
|
|
42
41
|
{
|
|
43
42
|
id: 'playwright-go-drivers',
|
|
@@ -519,6 +518,23 @@ export const OTHER_RULES = [
|
|
|
519
518
|
minAge: 'stale',
|
|
520
519
|
busy: ['chrome', 'chromium', 'headless_shell'],
|
|
521
520
|
},
|
|
521
|
+
{
|
|
522
|
+
id: 'tmp-playwright-browsers',
|
|
523
|
+
ecosystem: 'tmp',
|
|
524
|
+
scope: 'tmp',
|
|
525
|
+
description:
|
|
526
|
+
'Playwright browser installs and downloads in temporary directories',
|
|
527
|
+
paths: [
|
|
528
|
+
'{TMP}/playwright-download-*',
|
|
529
|
+
'{TMP}/ms-playwright',
|
|
530
|
+
'{TMP}/ms-playwright-*',
|
|
531
|
+
'{TMP}/*/ms-playwright',
|
|
532
|
+
'{TMP}/*/.cache/ms-playwright',
|
|
533
|
+
'{TMP}/*/Library/Caches/ms-playwright',
|
|
534
|
+
],
|
|
535
|
+
minAge: 'stale',
|
|
536
|
+
busy: ['playwright', 'node', 'chrome', 'chromium', 'headless_shell'],
|
|
537
|
+
},
|
|
522
538
|
{
|
|
523
539
|
id: 'node-tmp-caches',
|
|
524
540
|
ecosystem: 'tmp',
|
|
@@ -533,3 +549,33 @@ export const OTHER_RULES = [
|
|
|
533
549
|
busy: ['node'],
|
|
534
550
|
},
|
|
535
551
|
];
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Checkouts cloned into temporary directories by tools, tests and agents,
|
|
555
|
+
* checked after every named rule so a more specific rule claims its own
|
|
556
|
+
* paths first.
|
|
557
|
+
*/
|
|
558
|
+
export const TEMP_CLONE_RULES = [
|
|
559
|
+
{
|
|
560
|
+
id: 'temp-git-clones',
|
|
561
|
+
ecosystem: 'tmp',
|
|
562
|
+
scope: 'tmp',
|
|
563
|
+
description:
|
|
564
|
+
'Git clones in temporary directories whose commits all exist on a remote',
|
|
565
|
+
tier: 'safe',
|
|
566
|
+
paths: ['{TMP}/*', '{TMP}/*/*'],
|
|
567
|
+
directoryOnly: true,
|
|
568
|
+
minAge: 'stale',
|
|
569
|
+
git: 'clone',
|
|
570
|
+
busy: [
|
|
571
|
+
'git',
|
|
572
|
+
'claude',
|
|
573
|
+
'codex',
|
|
574
|
+
'opencode',
|
|
575
|
+
'agent',
|
|
576
|
+
'node',
|
|
577
|
+
'bun',
|
|
578
|
+
'cargo',
|
|
579
|
+
],
|
|
580
|
+
},
|
|
581
|
+
];
|
package/src/scan.js
CHANGED
|
@@ -377,13 +377,20 @@ async function scanEnvironmentScoped(
|
|
|
377
377
|
block(item, env.scanPolicy.removalReason(target));
|
|
378
378
|
}
|
|
379
379
|
}
|
|
380
|
-
for (const message of env
|
|
380
|
+
for (const message of await accessErrorMessages(env)) {
|
|
381
381
|
errors.push({ env: env.id, scanner: 'filesystem', message });
|
|
382
382
|
}
|
|
383
383
|
env.scanStatus = incomplete ? 'error' : 'complete';
|
|
384
384
|
return items;
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
+
/** Report messages for paths the scan could not inspect. */
|
|
388
|
+
function accessErrorMessages(env) {
|
|
389
|
+
return env.accessErrorMessages
|
|
390
|
+
? env.accessErrorMessages()
|
|
391
|
+
: [...(env.accessErrors?.values() ?? [])];
|
|
392
|
+
}
|
|
393
|
+
|
|
387
394
|
async function attributeStorage(env, items) {
|
|
388
395
|
if (env.writableLayer) {
|
|
389
396
|
const usages = await env.usageMany([
|
|
@@ -516,6 +523,24 @@ export function filterItems(items, options, daemons = [], pathApi) {
|
|
|
516
523
|
);
|
|
517
524
|
}
|
|
518
525
|
|
|
526
|
+
/**
|
|
527
|
+
* Items that share every block with an installed app (APFS clones): they
|
|
528
|
+
* free nothing, so they are not offered, but `du` counts them at full size.
|
|
529
|
+
*/
|
|
530
|
+
function sharedItems(items, options, pathApi) {
|
|
531
|
+
return filterItems(
|
|
532
|
+
items.filter((item) => item.sharedWith),
|
|
533
|
+
{ ...options, minSizeBytes: 0 },
|
|
534
|
+
[],
|
|
535
|
+
pathApi
|
|
536
|
+
).map((item) => ({
|
|
537
|
+
rule: item.rule,
|
|
538
|
+
path: item.path,
|
|
539
|
+
apparentBytes: item.apparentBytes,
|
|
540
|
+
sharedWith: item.sharedWith,
|
|
541
|
+
}));
|
|
542
|
+
}
|
|
543
|
+
|
|
519
544
|
function envDescriptor(env, depth, chain) {
|
|
520
545
|
return {
|
|
521
546
|
id: env.id,
|
|
@@ -530,13 +555,17 @@ function envDescriptor(env, depth, chain) {
|
|
|
530
555
|
};
|
|
531
556
|
}
|
|
532
557
|
|
|
533
|
-
async function withDisk(env, descriptor) {
|
|
558
|
+
async function withDisk(env, descriptor, options) {
|
|
534
559
|
const target = env.platform === 'win32' ? env.currentHome : '/';
|
|
535
560
|
const disk = await env.diskUsage(target).catch(() => null);
|
|
536
561
|
return {
|
|
537
562
|
...descriptor,
|
|
538
563
|
disk: disk ? { path: target, ...disk } : null,
|
|
539
|
-
swap: await readSwap(env
|
|
564
|
+
swap: await readSwap(env, {
|
|
565
|
+
stateDir: options.cacheDir,
|
|
566
|
+
disk,
|
|
567
|
+
now: options.now?.(),
|
|
568
|
+
}),
|
|
540
569
|
};
|
|
541
570
|
}
|
|
542
571
|
|
|
@@ -661,18 +690,20 @@ async function scanScoped(input) {
|
|
|
661
690
|
docker?.daemons ?? [],
|
|
662
691
|
env.path
|
|
663
692
|
);
|
|
693
|
+
const shared = sharedItems(hostItems, options, env.path);
|
|
664
694
|
const environments = [envDescriptor(env, 0, [])];
|
|
665
695
|
for (const inner of docker?.environments ?? []) {
|
|
666
696
|
environments.push(inner);
|
|
667
697
|
}
|
|
668
698
|
environments[0] = await timed(env.timings, 'disk', () =>
|
|
669
|
-
withDisk(env, environments[0])
|
|
699
|
+
withDisk(env, environments[0], options)
|
|
670
700
|
);
|
|
671
701
|
environments[0].timings = env.timings.toJSON();
|
|
672
702
|
const report = buildReport({
|
|
673
703
|
options,
|
|
674
704
|
env,
|
|
675
705
|
items,
|
|
706
|
+
shared,
|
|
676
707
|
environments,
|
|
677
708
|
docker,
|
|
678
709
|
errors,
|
|
@@ -703,6 +734,7 @@ export function buildReport({
|
|
|
703
734
|
options,
|
|
704
735
|
env,
|
|
705
736
|
items,
|
|
737
|
+
shared = [],
|
|
706
738
|
environments,
|
|
707
739
|
docker,
|
|
708
740
|
errors,
|
|
@@ -718,6 +750,7 @@ export function buildReport({
|
|
|
718
750
|
options: reportOptions(options),
|
|
719
751
|
environments,
|
|
720
752
|
items,
|
|
753
|
+
shared,
|
|
721
754
|
totals: tierTotals(items),
|
|
722
755
|
docker: docker
|
|
723
756
|
? {
|