disk-space-saviour 0.21.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
@@ -213,6 +225,8 @@ export class LivenessProbe {
213
225
  this.pathOwners = new Map();
214
226
  this.pathStats = new Map();
215
227
  this.inodes = new Map();
228
+ // Bundle identifiers never change, so they outlive refreshes.
229
+ this.bundleIds = new Map();
216
230
  }
217
231
 
218
232
  async refresh(force = false) {
@@ -224,6 +238,7 @@ export class LivenessProbe {
224
238
  this.listed = processes !== null;
225
239
  this.processes = processes ?? [];
226
240
  this.bunCacheUsers = await this.#bunCacheUsers();
241
+ await this.#resolveBundles();
227
242
  this.probeError =
228
243
  (processes === null || this.openPaths === null) &&
229
244
  (this.env.platform === 'linux' || this.env.platform === 'darwin');
@@ -234,6 +249,52 @@ export class LivenessProbe {
234
249
  this.inodes.clear();
235
250
  }
236
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
+
237
298
  /**
238
299
  * Inodes of `executables` and of the open files that share their names,
239
300
  * because a process running a hard link of an executable reports
@@ -405,10 +466,11 @@ export class LivenessProbe {
405
466
  ) {
406
467
  continue;
407
468
  }
408
- const name = matchingName(proc, names);
409
- if (!name) {
469
+ const matched = matchingName(proc, names);
470
+ if (!matched) {
410
471
  continue;
411
472
  }
473
+ const name = appLabel(proc, matched);
412
474
  if (!cwd) {
413
475
  return `${name} is running (pid ${proc.pid})`;
414
476
  }
@@ -431,7 +493,7 @@ export class LivenessProbe {
431
493
  for (const open of this.openPaths) {
432
494
  for (const target of targets) {
433
495
  if (isWithin(open, target, pathApi)) {
434
- return `in use: ${open}`;
496
+ return `in use: ${open}${this.#holder(open)}`;
435
497
  }
436
498
  }
437
499
  }
@@ -466,7 +528,7 @@ export class LivenessProbe {
466
528
  candidate &&
467
529
  paths.some((target) => isWithin(candidate, target, this.env.path))
468
530
  ) {
469
- return `in use: ${candidate} (pid ${proc.pid})`;
531
+ return `in use: ${candidate} (pid ${proc.pid}, ${appLabel(proc, proc.name)})`;
470
532
  }
471
533
  }
472
534
  }
@@ -555,6 +617,7 @@ export class LivenessProbe {
555
617
  this.probeBlocker() ??
556
618
  (checks.mtime === false ? null : this.recentWrite(item.newestMtimeMs)) ??
557
619
  this.runningTool(checks.busy, checks.cwd, item) ??
620
+ this.appUsage(item.paths ?? []) ??
558
621
  this.processUsage(paths) ??
559
622
  this.executableUsage(checks.executables) ??
560
623
  this.uncertainUsage(item.paths ?? [])
package/src/report.js CHANGED
@@ -114,6 +114,72 @@ function blockedSection(report, options) {
114
114
  ];
115
115
  }
116
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
+
117
183
  function repoSummary(repo) {
118
184
  if (repo.error) {
119
185
  return `${repo.root}: ${repo.error}`;
@@ -375,6 +441,37 @@ function holderLine(holder) {
375
441
  return `${holder.pid} ${holder.command}: ${details.join(', ')}`;
376
442
  }
377
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
+
378
475
  /**
379
476
  * Renders a scan report.
380
477
  * @param {object} report
@@ -388,38 +485,15 @@ export function formatReport(report, options = {}) {
388
485
  `disk-space-saviour scan of ${host.label} in ${(report.durationMs / 1000).toFixed(1)}s (report only, nothing was deleted)`,
389
486
  formatDisk(host.disk),
390
487
  ];
391
- if (host.swap) {
392
- lines.push(
393
- `Swap: ${formatBytes(host.swap.used)} used of ${formatBytes(host.swap.total)} (managed by macOS)`
394
- );
395
- for (const warning of host.swap.warnings ?? []) {
396
- lines.push(`WARNING: ${warning}`);
397
- }
398
- lines.push(host.swap.attribution);
399
- for (const holder of host.swap.holders) {
400
- lines.push(` ${holderLine(holder)}`);
401
- }
402
- lines.push(host.swap.advice);
403
- }
488
+ lines.push(...swapLines(host.swap));
489
+ lines.push(...conditionsWarningLines(report.totals?.blocked));
404
490
  for (const tier of TIERS) {
405
491
  lines.push(...tierSection(report, tier, { limit }));
406
492
  }
407
- for (const env of report.environments) {
408
- if (env.layerSource) {
409
- lines.push(` ${env.label}: layer source ${env.layerSource}`);
410
- }
411
- if (env.scanStatus === 'goal-reached') {
412
- lines.push(
413
- ` ${env.label}: scan stopped after the free-space goal was met`
414
- );
415
- } else if (env.scanStatus && env.scanStatus !== 'complete') {
416
- lines.push(` ${env.label}: scan incomplete (${env.scanStatus})`);
417
- }
418
- if (env.hint) {
419
- lines.push(` ${env.label}: ${env.hint}`);
420
- }
421
- }
493
+ lines.push(...report.environments.flatMap(environmentStatusLines));
422
494
  lines.push(...blockedSection(report, { limit }));
495
+ lines.push(...sharedSection(report.shared));
496
+ lines.push(...conditionsSection(report.totals?.blocked, { limit }));
423
497
  lines.push(...dockerSection(report));
424
498
  lines.push(...errorsSection(report.errors));
425
499
  lines.push(...totalsSection(report));
@@ -499,6 +573,14 @@ function emergencyLines(audit) {
499
573
  * @param {object} audit
500
574
  * @param {{verbose?: boolean}} [options]
501
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
+
502
584
  export function formatAudit(audit, options = {}) {
503
585
  const limit = options.verbose ? Infinity : DEFAULT_LIMIT * 2;
504
586
  const mode = audit.dryRun ? 'dry run, nothing was deleted' : 'deleting';
@@ -527,6 +609,14 @@ export function formatAudit(audit, options = {}) {
527
609
  ? `Would free ${formatBytes(audit.plannedBytes)} (run with --yes to delete)`
528
610
  : `Freed ${formatBytes(audit.freedBytes)}`
529
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
+ }
530
620
  if (audit.file) {
531
621
  lines.push(`Audit log: ${audit.file}`);
532
622
  }
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,
@@ -665,6 +690,7 @@ async function scanScoped(input) {
665
690
  docker?.daemons ?? [],
666
691
  env.path
667
692
  );
693
+ const shared = sharedItems(hostItems, options, env.path);
668
694
  const environments = [envDescriptor(env, 0, [])];
669
695
  for (const inner of docker?.environments ?? []) {
670
696
  environments.push(inner);
@@ -677,6 +703,7 @@ async function scanScoped(input) {
677
703
  options,
678
704
  env,
679
705
  items,
706
+ shared,
680
707
  environments,
681
708
  docker,
682
709
  errors,
@@ -707,6 +734,7 @@ export function buildReport({
707
734
  options,
708
735
  env,
709
736
  items,
737
+ shared = [],
710
738
  environments,
711
739
  docker,
712
740
  errors,
@@ -722,6 +750,7 @@ export function buildReport({
722
750
  options: reportOptions(options),
723
751
  environments,
724
752
  items,
753
+ shared,
725
754
  totals: tierTotals(items),
726
755
  docker: docker
727
756
  ? {
@@ -238,6 +238,10 @@ async function describeAppClone(context, rule, item, homes) {
238
238
  item.checks.executables = executables;
239
239
  if (sharedWith) {
240
240
  // Every block is shared with the installed app: nothing to reclaim.
241
+ // The report lists it under `shared` so the space `du` counts for it
242
+ // is explained.
243
+ item.sharedWith = sharedWith;
244
+ item.apparentBytes = item.bytes;
241
245
  item.bytes = 0;
242
246
  item.reason = `shares its disk blocks with ${sharedWith}`;
243
247
  }
@@ -9,6 +9,9 @@ import { scanItems } from '../env/scope.js';
9
9
  * are present. Generic names (`build`, `lib`, `target`, `bin`) that fail
10
10
  * every rule are searched again below, with the remaining depth, so a
11
11
  * non-matching `build` directory never hides real projects inside it.
12
+ * Git worktree folders (`.worktrees`, `worktrees`, `.claude/worktrees`)
13
+ * hold whole checkouts, so the search below them starts with the full
14
+ * depth again: an agent's worktree deep in a project is a project too.
12
15
  *
13
16
  * Cargo `target` directories additionally yield `cargo-superseded` items:
14
17
  * older build hashes that a newer build of the same unit replaced (see
@@ -23,6 +26,7 @@ import { analyzeRustProfile, findRustProfiles } from './rust.js';
23
26
  import { timed } from '../timings.js';
24
27
 
25
28
  const ECOSYSTEM_NAMES = new Map(ECOSYSTEMS.map((e) => [e.id, e.name]));
29
+ const WORKTREE_DIRS = ['.worktrees', 'worktrees'];
26
30
 
27
31
  function anyMatch(entries, globs) {
28
32
  return (entries ?? []).some((entry) =>
@@ -37,6 +41,13 @@ function depthBelow(env, roots, target) {
37
41
  return env.path.relative(root, target).split(/[\\/]/).filter(Boolean).length;
38
42
  }
39
43
 
44
+ /** Depth left below a directory no rule claims; worktrees start afresh. */
45
+ function remainingDepth(env, dirs, candidate, { depth, maxDepth }) {
46
+ return WORKTREE_DIRS.includes(candidate.name)
47
+ ? maxDepth
48
+ : depth - depthBelow(env, dirs, candidate.path);
49
+ }
50
+
40
51
  function ruleAccepts(env, rule, candidate, selfListing, grandListing) {
41
52
  if (rule.markers && !anyMatch(candidate.siblings, rule.markers)) {
42
53
  return false;
@@ -139,7 +150,9 @@ async function indexedMatches(env, roots, globs, maxDepth, rules) {
139
150
  export async function findProjectDirs(env, roots, options = {}) {
140
151
  const rules = options.rules ?? PROJECT_RULES;
141
152
  const maxDepth = options.maxDepth ?? 6;
142
- const globs = [...new Set(rules.flatMap((rule) => rule.names))];
153
+ const globs = [
154
+ ...new Set([...rules.flatMap((rule) => rule.names), ...WORKTREE_DIRS]),
155
+ ];
143
156
  const matches = [];
144
157
  const seen = new Set();
145
158
  const accept = (match) => {
@@ -171,7 +184,10 @@ export async function findProjectDirs(env, roots, options = {}) {
171
184
  }
172
185
  continue;
173
186
  }
174
- const remaining = depth - depthBelow(env, dirs, match.candidate.path);
187
+ const remaining = remainingDepth(env, dirs, match.candidate, {
188
+ depth,
189
+ maxDepth,
190
+ });
175
191
  if (remaining > 0) {
176
192
  next.set(remaining, [
177
193
  ...(next.get(remaining) ?? []),