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/usage.js
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where disk space goes on every path of a volume, beyond the locations
|
|
3
|
+
* rules know.
|
|
4
|
+
*
|
|
5
|
+
* One read-only `du -axk` pass per root streams every file and directory
|
|
6
|
+
* with its allocated size (hard links once, other mounts skipped). Only
|
|
7
|
+
* directories and files above a threshold stay in memory, which is enough
|
|
8
|
+
* for a tree of the largest directories: every ancestor of a large
|
|
9
|
+
* directory is at least as large. Each directory of the tree names what
|
|
10
|
+
* the latest dss scan found inside it, freeable or blocked, so a report
|
|
11
|
+
* separates space dss can reclaim from data it does not consider
|
|
12
|
+
* regenerable. On macOS the APFS volumes (system, VM swap, Preboot,
|
|
13
|
+
* Recovery) and local snapshots complete the picture, since a walk of the
|
|
14
|
+
* Data volume cannot see them.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { promises as fsp } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { createInterface } from 'node:readline';
|
|
20
|
+
import { clearInterval, setInterval } from 'node:timers';
|
|
21
|
+
|
|
22
|
+
import { auditDirectory } from './audit.js';
|
|
23
|
+
import { hostExecutor } from './exec.js';
|
|
24
|
+
import { isWithin } from './paths.js';
|
|
25
|
+
import { formatBytes } from './units.js';
|
|
26
|
+
|
|
27
|
+
const MiB = 1024 * 1024;
|
|
28
|
+
const GiB = 1024 * MiB;
|
|
29
|
+
const DATA_VOLUME = '/System/Volumes/Data';
|
|
30
|
+
const DENIED = /^du: (.+): (Permission denied|Operation not permitted)$/;
|
|
31
|
+
|
|
32
|
+
/** `{bytes, path}` of one `du -k` line, or null. */
|
|
33
|
+
export function parseDuLine(line) {
|
|
34
|
+
const match = /^(\d+)\t(.+)$/.exec(line);
|
|
35
|
+
return match ? { bytes: Number(match[1]) * 1024, path: match[2] } : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Streaming accumulator for `du -a` output. `du` lists a directory after
|
|
40
|
+
* its contents, so an entry is a directory exactly when the line before it
|
|
41
|
+
* lies inside it.
|
|
42
|
+
*/
|
|
43
|
+
export class UsageCollector {
|
|
44
|
+
/**
|
|
45
|
+
* @param {{minBytes?: number, topFiles?: number}} [options]
|
|
46
|
+
*/
|
|
47
|
+
constructor(options = {}) {
|
|
48
|
+
this.minBytes = options.minBytes ?? 64 * MiB;
|
|
49
|
+
this.topFiles = options.topFiles ?? 25;
|
|
50
|
+
this.dirs = new Map();
|
|
51
|
+
this.files = [];
|
|
52
|
+
this.entries = 0;
|
|
53
|
+
this.previous = null;
|
|
54
|
+
this.last = null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
add(line) {
|
|
58
|
+
const entry = parseDuLine(line);
|
|
59
|
+
if (!entry) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
this.entries += 1;
|
|
63
|
+
const prefix = entry.path.endsWith('/') ? entry.path : `${entry.path}/`;
|
|
64
|
+
const isDir = this.previous?.startsWith(prefix) ?? false;
|
|
65
|
+
this.previous = entry.path;
|
|
66
|
+
this.last = entry;
|
|
67
|
+
if (entry.bytes < this.minBytes) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (isDir) {
|
|
71
|
+
this.dirs.set(entry.path, entry.bytes);
|
|
72
|
+
} else {
|
|
73
|
+
this.files.push(entry);
|
|
74
|
+
if (this.files.length > this.topFiles * 4) {
|
|
75
|
+
this.#trimFiles();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#trimFiles() {
|
|
81
|
+
this.files.sort((a, b) => b.bytes - a.bytes);
|
|
82
|
+
this.files.length = Math.min(this.files.length, this.topFiles);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The root `du` was given: its line comes last. */
|
|
86
|
+
result(root) {
|
|
87
|
+
this.#trimFiles();
|
|
88
|
+
const total = this.dirs.get(root) ?? this.last?.bytes ?? 0;
|
|
89
|
+
this.dirs.set(root, total);
|
|
90
|
+
return {
|
|
91
|
+
root,
|
|
92
|
+
bytes: total,
|
|
93
|
+
entries: this.entries,
|
|
94
|
+
dirs: this.dirs,
|
|
95
|
+
files: this.files,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The display form of a walked path: the Data volume is shown through the
|
|
102
|
+
* firmlinked paths users know (`/Users`, `/private/tmp`).
|
|
103
|
+
*/
|
|
104
|
+
export function displayPath(target, root) {
|
|
105
|
+
if (root === DATA_VOLUME && target.startsWith(DATA_VOLUME)) {
|
|
106
|
+
return target.slice(DATA_VOLUME.length) || '/';
|
|
107
|
+
}
|
|
108
|
+
return target;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Item paths as macOS resolves them (`/tmp` is `/private/tmp`). */
|
|
112
|
+
export function canonicalItemPath(target, platform = process.platform) {
|
|
113
|
+
if (platform !== 'darwin') {
|
|
114
|
+
return target;
|
|
115
|
+
}
|
|
116
|
+
return target.replace(/^\/(tmp|var|etc)(?=\/|$)/, '/private/$1');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Tree of the largest directories under `root`.
|
|
121
|
+
* @param {Map<string, number>} dirs directory → bytes, closed under parents
|
|
122
|
+
* @param {string} root
|
|
123
|
+
* @param {{minBytes: number, perDir?: number}} options
|
|
124
|
+
*/
|
|
125
|
+
export function buildTree(dirs, root, options) {
|
|
126
|
+
const children = new Map();
|
|
127
|
+
for (const [dir, bytes] of dirs) {
|
|
128
|
+
if (dir === root || bytes < options.minBytes) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const parent = path.posix.dirname(dir);
|
|
132
|
+
if (!children.has(parent)) {
|
|
133
|
+
children.set(parent, []);
|
|
134
|
+
}
|
|
135
|
+
children.get(parent).push(dir);
|
|
136
|
+
}
|
|
137
|
+
const perDir = options.perDir ?? 10;
|
|
138
|
+
const node = (dir) => ({
|
|
139
|
+
path: dir,
|
|
140
|
+
bytes: dirs.get(dir),
|
|
141
|
+
children: (children.get(dir) ?? [])
|
|
142
|
+
.sort((a, b) => dirs.get(b) - dirs.get(a))
|
|
143
|
+
.slice(0, perDir)
|
|
144
|
+
.map(node),
|
|
145
|
+
});
|
|
146
|
+
return node(root);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* What the latest scan found inside `target`: bytes dss may free there,
|
|
151
|
+
* bytes blocked, and the rule when `target` lies inside one item.
|
|
152
|
+
*/
|
|
153
|
+
export function scanCoverage(target, items) {
|
|
154
|
+
const coverage = { freeable: 0, blocked: 0, item: null, shared: null };
|
|
155
|
+
for (const item of items) {
|
|
156
|
+
if (isWithin(target, item.path, path.posix)) {
|
|
157
|
+
coverage.item = item;
|
|
158
|
+
}
|
|
159
|
+
if (isWithin(item.path, target, path.posix)) {
|
|
160
|
+
if (item.sharedWith) {
|
|
161
|
+
coverage.shared ??= { bytes: 0, with: item.sharedWith };
|
|
162
|
+
coverage.shared.bytes += item.apparentBytes;
|
|
163
|
+
} else if (item.blockers.length > 0) {
|
|
164
|
+
coverage.blocked += item.bytes;
|
|
165
|
+
} else {
|
|
166
|
+
coverage.freeable += item.bytes;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return coverage;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Host items of a saved scan, outermost only, with canonical paths. */
|
|
174
|
+
export function scanItems(
|
|
175
|
+
report,
|
|
176
|
+
platform = process.platform,
|
|
177
|
+
removed = new Set()
|
|
178
|
+
) {
|
|
179
|
+
const items = (report?.items ?? [])
|
|
180
|
+
.filter(
|
|
181
|
+
(item) =>
|
|
182
|
+
item.env === 'host' &&
|
|
183
|
+
item.path &&
|
|
184
|
+
!item.parentId &&
|
|
185
|
+
!removed.has(item.path)
|
|
186
|
+
)
|
|
187
|
+
.map((item) => ({
|
|
188
|
+
path: canonicalItemPath(item.path, platform),
|
|
189
|
+
bytes: item.bytes ?? 0,
|
|
190
|
+
rule: item.rule,
|
|
191
|
+
tier: item.tier,
|
|
192
|
+
blockers: item.blockers ?? [],
|
|
193
|
+
}))
|
|
194
|
+
.concat(
|
|
195
|
+
(report?.shared ?? []).map((item) => ({
|
|
196
|
+
path: canonicalItemPath(item.path, platform),
|
|
197
|
+
bytes: 0,
|
|
198
|
+
rule: item.rule,
|
|
199
|
+
blockers: [],
|
|
200
|
+
apparentBytes: item.apparentBytes ?? 0,
|
|
201
|
+
sharedWith: item.sharedWith,
|
|
202
|
+
}))
|
|
203
|
+
);
|
|
204
|
+
return items.filter(
|
|
205
|
+
(item) =>
|
|
206
|
+
!items.some(
|
|
207
|
+
(other) =>
|
|
208
|
+
other !== item &&
|
|
209
|
+
other.path !== item.path &&
|
|
210
|
+
isWithin(item.path, other.path, path.posix)
|
|
211
|
+
)
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* APFS volumes from `diskutil apfs list`.
|
|
217
|
+
* @returns {{name: string, mount: string|null, bytes: number}[]}
|
|
218
|
+
*/
|
|
219
|
+
export function parseApfsVolumes(text) {
|
|
220
|
+
const volumes = [];
|
|
221
|
+
let current = null;
|
|
222
|
+
for (const line of text.split('\n')) {
|
|
223
|
+
const name = /\bName:\s+(.+?)(?: \(Case-(?:in)?sensitive\))?\s*$/.exec(
|
|
224
|
+
line
|
|
225
|
+
);
|
|
226
|
+
if (name && !/Snapshot Name/.test(line)) {
|
|
227
|
+
current = { name: name[1], mount: null, bytes: 0 };
|
|
228
|
+
volumes.push(current);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (!current) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const mount = /^\s*\|?\s*(Snapshot )?Mount Point:\s+(\/.*)$/.exec(line);
|
|
235
|
+
if (mount && !current.mount) {
|
|
236
|
+
current.mount = mount[2].trim();
|
|
237
|
+
}
|
|
238
|
+
const consumed = /Capacity Consumed:\s+(\d+) B/.exec(line);
|
|
239
|
+
if (consumed) {
|
|
240
|
+
current.bytes = Number(consumed[1]);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return volumes;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** What an APFS volume holds, by its usual role. */
|
|
247
|
+
export function volumeRole(volume) {
|
|
248
|
+
const name = volume.name.toLowerCase();
|
|
249
|
+
if (name === 'vm' || volume.mount === '/System/Volumes/VM') {
|
|
250
|
+
return 'swap files and the sleep image, managed by macOS';
|
|
251
|
+
}
|
|
252
|
+
if (name === 'preboot') {
|
|
253
|
+
return 'boot files and the cryptex of the running macOS';
|
|
254
|
+
}
|
|
255
|
+
if (name === 'recovery') {
|
|
256
|
+
return 'macOS Recovery';
|
|
257
|
+
}
|
|
258
|
+
if (name === 'update') {
|
|
259
|
+
return 'staged macOS updates';
|
|
260
|
+
}
|
|
261
|
+
if (volume.mount === '/') {
|
|
262
|
+
return 'macOS system (sealed, read-only)';
|
|
263
|
+
}
|
|
264
|
+
if (volume.mount === DATA_VOLUME) {
|
|
265
|
+
return 'apps and user data (walked below)';
|
|
266
|
+
}
|
|
267
|
+
return volume.mount ? `mounted at ${volume.mount}` : 'not mounted';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function newestSavedReport(options) {
|
|
271
|
+
const dir = auditDirectory(options);
|
|
272
|
+
const names = await fsp.readdir(dir).catch(() => []);
|
|
273
|
+
// Stamps are ISO times with `:` and `.` replaced, so they sort as text.
|
|
274
|
+
const stamp = (name) => /-(\d{4}-\d\d-\d\dT[\d-]+Z)-/.exec(name)?.[1] ?? '';
|
|
275
|
+
const candidates = names
|
|
276
|
+
.filter((name) => /^dss-(scan|clean)-.+\.json$/.test(name))
|
|
277
|
+
.sort((a, b) => stamp(b).localeCompare(stamp(a)));
|
|
278
|
+
for (const name of candidates) {
|
|
279
|
+
const saved = await fsp
|
|
280
|
+
.readFile(path.join(dir, name), 'utf8')
|
|
281
|
+
.then(JSON.parse)
|
|
282
|
+
.catch(() => null);
|
|
283
|
+
const report = saved?.report ?? saved;
|
|
284
|
+
if (Array.isArray(report?.items)) {
|
|
285
|
+
return {
|
|
286
|
+
file: path.join(dir, name),
|
|
287
|
+
report,
|
|
288
|
+
removed: removedPaths(saved),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Paths a clean already deleted: its report still lists them. */
|
|
296
|
+
function removedPaths(audit) {
|
|
297
|
+
return new Set(
|
|
298
|
+
(audit.entries ?? [])
|
|
299
|
+
.filter((entry) => entry.status === 'removed')
|
|
300
|
+
.flatMap((entry) => entry.paths ?? [entry.path])
|
|
301
|
+
.filter(Boolean)
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function defaultRoots(platform) {
|
|
306
|
+
return platform === 'darwin' ? [DATA_VOLUME] : ['/'];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Walks `root` with `du` and streams its output into a collector.
|
|
311
|
+
* @param {object} executor with `spawn(argv, options)`
|
|
312
|
+
*/
|
|
313
|
+
export function walk(executor, root, options = {}) {
|
|
314
|
+
const collector = new UsageCollector(options);
|
|
315
|
+
const denied = { count: 0, samples: [] };
|
|
316
|
+
const started = Date.now();
|
|
317
|
+
return new Promise((resolve, reject) => {
|
|
318
|
+
const child = executor.spawn(['du', '-a', '-x', '-k', root], {
|
|
319
|
+
signal: options.signal,
|
|
320
|
+
timeoutMs: options.timeoutMs ?? 6 * 3600e3,
|
|
321
|
+
});
|
|
322
|
+
const timer = setInterval(
|
|
323
|
+
() =>
|
|
324
|
+
options.onProgress?.({
|
|
325
|
+
root,
|
|
326
|
+
elapsedMs: Date.now() - started,
|
|
327
|
+
entries: collector.entries,
|
|
328
|
+
at: collector.previous,
|
|
329
|
+
}),
|
|
330
|
+
5000
|
|
331
|
+
);
|
|
332
|
+
timer.unref?.();
|
|
333
|
+
createInterface({ input: child.stdout }).on('line', (line) =>
|
|
334
|
+
collector.add(line)
|
|
335
|
+
);
|
|
336
|
+
createInterface({ input: child.stderr }).on('line', (line) => {
|
|
337
|
+
const match = DENIED.exec(line);
|
|
338
|
+
if (match) {
|
|
339
|
+
denied.count += 1;
|
|
340
|
+
if (denied.samples.length < 5) {
|
|
341
|
+
denied.samples.push(match[1]);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
child.once('error', (error) => {
|
|
346
|
+
clearInterval(timer);
|
|
347
|
+
reject(error);
|
|
348
|
+
});
|
|
349
|
+
child.once('close', (code, signal) => {
|
|
350
|
+
clearInterval(timer);
|
|
351
|
+
if (options.signal?.aborted) {
|
|
352
|
+
reject(new Error('usage walk aborted'));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (signal || collector.entries === 0) {
|
|
356
|
+
reject(new Error(`du ${root} stopped (${signal ?? `exit ${code}`})`));
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
resolve({
|
|
360
|
+
...collector.result(root),
|
|
361
|
+
denied,
|
|
362
|
+
durationMs: Date.now() - started,
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Reports where the space of whole volumes goes. Never deletes anything.
|
|
370
|
+
* @param {{roots?: string[]|null, minSize?: number, executor?: object,
|
|
371
|
+
* platform?: string, signal?: AbortSignal, onUsageProgress?: Function,
|
|
372
|
+
* auditDir?: string}} [options]
|
|
373
|
+
*/
|
|
374
|
+
export async function usage(options = {}) {
|
|
375
|
+
const platform = options.platform ?? process.platform;
|
|
376
|
+
const executor = options.executor ?? hostExecutor();
|
|
377
|
+
const started = Date.now();
|
|
378
|
+
const roots = (
|
|
379
|
+
options.roots?.length ? options.roots : defaultRoots(platform)
|
|
380
|
+
).map((root) => path.resolve(root));
|
|
381
|
+
const saved = await newestSavedReport(options);
|
|
382
|
+
const items = scanItems(saved?.report, platform, saved?.removed);
|
|
383
|
+
// One walk at a time: parallel walks of one disk only compete for it.
|
|
384
|
+
const walks = await roots.reduce(
|
|
385
|
+
async (done, root) => [
|
|
386
|
+
...(await done),
|
|
387
|
+
await walk(executor, root, {
|
|
388
|
+
minBytes: Math.min(options.minSize ?? 64 * MiB, 64 * MiB),
|
|
389
|
+
signal: options.signal,
|
|
390
|
+
onProgress: options.onUsageProgress,
|
|
391
|
+
}),
|
|
392
|
+
],
|
|
393
|
+
Promise.resolve([])
|
|
394
|
+
);
|
|
395
|
+
const volumes =
|
|
396
|
+
platform === 'darwin'
|
|
397
|
+
? await darwinVolumes(executor, options.signal)
|
|
398
|
+
: { volumes: [], snapshots: null };
|
|
399
|
+
return {
|
|
400
|
+
schema: 1,
|
|
401
|
+
createdAt: new Date(started).toISOString(),
|
|
402
|
+
durationMs: Date.now() - started,
|
|
403
|
+
platform,
|
|
404
|
+
minSize: options.minSize ?? null,
|
|
405
|
+
scan: saved
|
|
406
|
+
? { file: saved.file, createdAt: saved.report.createdAt ?? null }
|
|
407
|
+
: null,
|
|
408
|
+
roots: walks.map((result) => rootReport(result, items, options)),
|
|
409
|
+
...volumes,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function rootReport(result, items, options) {
|
|
414
|
+
const minBytes = options.minSize ?? Math.max(result.bytes * 0.01, 100 * MiB);
|
|
415
|
+
const show = (target) => displayPath(target, result.root);
|
|
416
|
+
const dirs = new Map(
|
|
417
|
+
[...result.dirs].map(([dir, bytes]) => [show(dir), bytes])
|
|
418
|
+
);
|
|
419
|
+
const rootPath = show(result.root);
|
|
420
|
+
const annotate = (node) => ({
|
|
421
|
+
...node,
|
|
422
|
+
dss: summary(scanCoverage(node.path, items)),
|
|
423
|
+
children: node.children.map(annotate),
|
|
424
|
+
});
|
|
425
|
+
const inside = items.filter((item) =>
|
|
426
|
+
isWithin(item.path, rootPath, path.posix)
|
|
427
|
+
);
|
|
428
|
+
return {
|
|
429
|
+
root: result.root,
|
|
430
|
+
path: rootPath,
|
|
431
|
+
bytes: result.bytes,
|
|
432
|
+
entries: result.entries,
|
|
433
|
+
durationMs: result.durationMs,
|
|
434
|
+
denied: {
|
|
435
|
+
count: result.denied.count,
|
|
436
|
+
samples: result.denied.samples.map(show),
|
|
437
|
+
},
|
|
438
|
+
known: {
|
|
439
|
+
freeable: inside
|
|
440
|
+
.filter((item) => item.blockers.length === 0)
|
|
441
|
+
.reduce((sum, item) => sum + item.bytes, 0),
|
|
442
|
+
blocked: inside
|
|
443
|
+
.filter((item) => item.blockers.length > 0)
|
|
444
|
+
.reduce((sum, item) => sum + item.bytes, 0),
|
|
445
|
+
shared: inside.reduce((sum, item) => sum + (item.apparentBytes ?? 0), 0),
|
|
446
|
+
},
|
|
447
|
+
tree: annotate(
|
|
448
|
+
buildTree(dirs, rootPath, {
|
|
449
|
+
minBytes,
|
|
450
|
+
perDir: options.verbose ? 30 : 10,
|
|
451
|
+
})
|
|
452
|
+
),
|
|
453
|
+
files: result.files.map((file) => ({
|
|
454
|
+
path: show(file.path),
|
|
455
|
+
bytes: file.bytes,
|
|
456
|
+
dss: summary(scanCoverage(show(file.path), items)),
|
|
457
|
+
})),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function summary(coverage) {
|
|
462
|
+
return {
|
|
463
|
+
rule: coverage.item?.rule ?? null,
|
|
464
|
+
tier: coverage.item?.tier ?? null,
|
|
465
|
+
blocked: coverage.item ? coverage.item.blockers.length > 0 : null,
|
|
466
|
+
freeable: coverage.freeable,
|
|
467
|
+
blockedBytes: coverage.blocked,
|
|
468
|
+
shared: coverage.shared,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function darwinVolumes(executor, signal) {
|
|
473
|
+
const list = await executor
|
|
474
|
+
.run(['diskutil', 'apfs', 'list'], { timeoutMs: 30000, signal })
|
|
475
|
+
.catch(() => null);
|
|
476
|
+
const snapshots = await executor
|
|
477
|
+
.run(['tmutil', 'listlocalsnapshots', '/'], { timeoutMs: 30000, signal })
|
|
478
|
+
.catch(() => null);
|
|
479
|
+
return {
|
|
480
|
+
volumes: parseApfsVolumes(list?.stdout ?? '').map((volume) => ({
|
|
481
|
+
...volume,
|
|
482
|
+
role: volumeRole(volume),
|
|
483
|
+
})),
|
|
484
|
+
snapshots: snapshots
|
|
485
|
+
? snapshots.stdout
|
|
486
|
+
.split('\n')
|
|
487
|
+
.filter((line) => /^com\.apple\./.test(line.trim())).length
|
|
488
|
+
: null,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function dssNote(dss) {
|
|
493
|
+
if (dss.shared && !dss.freeable && !dss.blockedBytes) {
|
|
494
|
+
return ` [dss: ${formatBytes(dss.shared.bytes)} are APFS clones sharing their blocks with ${dss.shared.with}; deleting them frees almost nothing]`;
|
|
495
|
+
}
|
|
496
|
+
if (dss.rule) {
|
|
497
|
+
return ` [dss: ${dss.rule}, ${dss.blocked ? 'blocked' : dss.tier}]`;
|
|
498
|
+
}
|
|
499
|
+
const parts = [];
|
|
500
|
+
if (dss.freeable > 0) {
|
|
501
|
+
parts.push(`${formatBytes(dss.freeable)} freeable`);
|
|
502
|
+
}
|
|
503
|
+
if (dss.blockedBytes > 0) {
|
|
504
|
+
parts.push(`${formatBytes(dss.blockedBytes)} blocked`);
|
|
505
|
+
}
|
|
506
|
+
return parts.length > 0 ? ` [dss: ${parts.join(', ')}]` : '';
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function treeLines(node, depth = 0) {
|
|
510
|
+
// A directory that is almost all one subdirectory says nothing more
|
|
511
|
+
// than that subdirectory: show the deeper path in its place.
|
|
512
|
+
const [only] = node.children;
|
|
513
|
+
if (
|
|
514
|
+
depth > 0 &&
|
|
515
|
+
node.children.length === 1 &&
|
|
516
|
+
only.bytes >= node.bytes * 0.95
|
|
517
|
+
) {
|
|
518
|
+
return treeLines(only, depth);
|
|
519
|
+
}
|
|
520
|
+
return [
|
|
521
|
+
`${formatBytes(node.bytes).padStart(10)} ${' '.repeat(depth)}${node.path}${dssNote(node.dss)}`,
|
|
522
|
+
...node.children.flatMap((child) => treeLines(child, depth + 1)),
|
|
523
|
+
];
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function volumeLines(report, data) {
|
|
527
|
+
if (report.volumes.length === 0) {
|
|
528
|
+
return [];
|
|
529
|
+
}
|
|
530
|
+
const lines = ['', 'VOLUMES (APFS space consumed)'];
|
|
531
|
+
for (const volume of report.volumes) {
|
|
532
|
+
lines.push(
|
|
533
|
+
`${formatBytes(volume.bytes).padStart(10)} ${volume.name}: ${volume.role}`
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
if (report.snapshots !== null) {
|
|
537
|
+
lines.push(
|
|
538
|
+
` local Time Machine snapshots: ${report.snapshots}${report.snapshots > 0 ? ' (macOS frees them under pressure; `tmutil deletelocalsnapshots /` removes them)' : ''}`
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
const volume = report.volumes.find((entry) => entry.mount === DATA_VOLUME);
|
|
542
|
+
if (volume && data) {
|
|
543
|
+
const gap = volume.bytes - data.bytes;
|
|
544
|
+
lines.push(
|
|
545
|
+
gap > GiB
|
|
546
|
+
? ` ${formatBytes(gap)} of the Data volume is outside the walk: directories this user cannot read, snapshots or files deleted while still open.`
|
|
547
|
+
: gap < -GiB
|
|
548
|
+
? ` The walk counts APFS clones at full size, so it exceeds the Data volume by ${formatBytes(-gap)}.`
|
|
549
|
+
: ' The walk accounts for the whole Data volume.'
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
return lines;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Renders a usage report.
|
|
557
|
+
* @param {object} report
|
|
558
|
+
* @returns {string}
|
|
559
|
+
*/
|
|
560
|
+
export function formatUsage(report) {
|
|
561
|
+
const lines = [
|
|
562
|
+
`disk-space-saviour usage in ${(report.durationMs / 1000).toFixed(1)}s (report only, nothing was deleted)`,
|
|
563
|
+
];
|
|
564
|
+
for (const root of report.roots) {
|
|
565
|
+
lines.push(
|
|
566
|
+
'',
|
|
567
|
+
`WHERE SPACE GOES under ${root.path} (${formatBytes(root.bytes)} in ${root.entries.toLocaleString('en-US')} entries, ${(root.durationMs / 1000).toFixed(0)}s)`,
|
|
568
|
+
...treeLines(root.tree),
|
|
569
|
+
'',
|
|
570
|
+
'LARGEST FILES',
|
|
571
|
+
...root.files.map(
|
|
572
|
+
(file) =>
|
|
573
|
+
`${formatBytes(file.bytes).padStart(10)} ${file.path}${dssNote(file.dss)}`
|
|
574
|
+
)
|
|
575
|
+
);
|
|
576
|
+
if (report.scan) {
|
|
577
|
+
lines.push(
|
|
578
|
+
'',
|
|
579
|
+
`dss rules cover ${formatBytes(root.known.freeable + root.known.blocked)} here: ${formatBytes(root.known.freeable)} freeable now, ${formatBytes(root.known.blocked)} blocked (scan of ${report.scan.createdAt}). The rest is data dss does not treat as regenerable.`
|
|
580
|
+
);
|
|
581
|
+
if (root.known.shared > 0) {
|
|
582
|
+
lines.push(
|
|
583
|
+
`${formatBytes(root.known.shared)} of the walk are APFS clones of installed apps: du counts them in full, but deleting them frees almost nothing.`
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
} else {
|
|
587
|
+
lines.push(
|
|
588
|
+
'',
|
|
589
|
+
'Run `dss scan` to mark what dss can free inside these directories.'
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
if (root.denied.count > 0) {
|
|
593
|
+
lines.push(
|
|
594
|
+
`Unreadable: ${root.denied.count} directories were skipped, such as ${root.denied.samples.join(', ')}; grant Full Disk Access or run with sudo to include them.`
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const data = report.roots.find((root) => root.root === DATA_VOLUME);
|
|
599
|
+
lines.push(...volumeLines(report, data));
|
|
600
|
+
return lines.join('\n');
|
|
601
|
+
}
|