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/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 name = matchingName(proc, names);
364
- if (!name) {
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.map((item) => itemLine(item, showEnv)),
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
- if (host.swap) {
368
- lines.push(
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
- for (const env of report.environments) {
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'],
@@ -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 running
24
- // browser maps the executables of its clone, so the open-file check
25
- // keeps every clone in use: a browser name would block orphans forever.
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: [],
@@ -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
- busy: [
33
- 'playwright',
34
- 'playwright-mcp',
35
- 'chrome',
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.accessErrors?.values() ?? []) {
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
  ? {