disk-space-saviour 0.21.0 → 0.22.1

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/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}`;
@@ -372,7 +438,39 @@ function holderLine(holder) {
372
438
  `${change > 0 ? '+' : '-'}${formatBytes(Math.abs(change))} since ${new Date(holder.growth.since).toISOString()}`
373
439
  );
374
440
  }
375
- return `${holder.pid} ${holder.command}: ${details.join(', ')}`;
441
+ const self = holder.self ? ' (this dss run)' : '';
442
+ return `${holder.pid} ${holder.command}${self}: ${details.join(', ')}`;
443
+ }
444
+
445
+ function swapLines(swap) {
446
+ if (!swap) {
447
+ return [];
448
+ }
449
+ return [
450
+ `Swap: ${formatBytes(swap.used)} used of ${formatBytes(swap.total)} (managed by macOS)`,
451
+ ...(swap.warnings ?? []).map((warning) => `WARNING: ${warning}`),
452
+ swap.attribution,
453
+ ...swap.holders.map((holder) => ` ${holderLine(holder)}`),
454
+ swap.advice,
455
+ ];
456
+ }
457
+
458
+ function environmentStatusLines(env) {
459
+ const lines = [];
460
+ if (env.layerSource) {
461
+ lines.push(` ${env.label}: layer source ${env.layerSource}`);
462
+ }
463
+ if (env.scanStatus === 'goal-reached') {
464
+ lines.push(
465
+ ` ${env.label}: scan stopped after the free-space goal was met`
466
+ );
467
+ } else if (env.scanStatus && env.scanStatus !== 'complete') {
468
+ lines.push(` ${env.label}: scan incomplete (${env.scanStatus})`);
469
+ }
470
+ if (env.hint) {
471
+ lines.push(` ${env.label}: ${env.hint}`);
472
+ }
473
+ return lines;
376
474
  }
377
475
 
378
476
  /**
@@ -388,38 +486,15 @@ export function formatReport(report, options = {}) {
388
486
  `disk-space-saviour scan of ${host.label} in ${(report.durationMs / 1000).toFixed(1)}s (report only, nothing was deleted)`,
389
487
  formatDisk(host.disk),
390
488
  ];
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
- }
489
+ lines.push(...swapLines(host.swap));
490
+ lines.push(...conditionsWarningLines(report.totals?.blocked));
404
491
  for (const tier of TIERS) {
405
492
  lines.push(...tierSection(report, tier, { limit }));
406
493
  }
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
- }
494
+ lines.push(...report.environments.flatMap(environmentStatusLines));
422
495
  lines.push(...blockedSection(report, { limit }));
496
+ lines.push(...sharedSection(report.shared));
497
+ lines.push(...conditionsSection(report.totals?.blocked, { limit }));
423
498
  lines.push(...dockerSection(report));
424
499
  lines.push(...errorsSection(report.errors));
425
500
  lines.push(...totalsSection(report));
@@ -499,6 +574,14 @@ function emergencyLines(audit) {
499
574
  * @param {object} audit
500
575
  * @param {{verbose?: boolean}} [options]
501
576
  */
577
+ /** Warns that a plan built on an incomplete scan misses what it never reached. */
578
+ export function incompleteScanLines(incomplete) {
579
+ return (incomplete ?? []).map(
580
+ (env) =>
581
+ `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).`
582
+ );
583
+ }
584
+
502
585
  export function formatAudit(audit, options = {}) {
503
586
  const limit = options.verbose ? Infinity : DEFAULT_LIMIT * 2;
504
587
  const mode = audit.dryRun ? 'dry run, nothing was deleted' : 'deleting';
@@ -527,7 +610,17 @@ export function formatAudit(audit, options = {}) {
527
610
  ? `Would free ${formatBytes(audit.plannedBytes)} (run with --yes to delete)`
528
611
  : `Freed ${formatBytes(audit.freedBytes)}`
529
612
  );
530
- if (audit.file) {
613
+ lines.push(...incompleteScanLines(audit.incomplete));
614
+ const conditions = conditionsWarning(audit.blocked);
615
+ if (conditions) {
616
+ lines.push(
617
+ conditions,
618
+ ...conditionsSection(audit.blocked, options).slice(2)
619
+ );
620
+ }
621
+ if (audit.auditError) {
622
+ lines.push(`Audit log: not written (${audit.auditError.message})`);
623
+ } else if (audit.file) {
531
624
  lines.push(`Audit log: ${audit.file}`);
532
625
  }
533
626
  return lines.join('\n');
@@ -184,4 +184,31 @@ export const DOWNLOAD_RULES = [
184
184
  ],
185
185
  minAge: 'stale',
186
186
  },
187
+ {
188
+ id: 'crashpad-pending-reports',
189
+ ecosystem: 'system',
190
+ description:
191
+ 'Chromium and Electron app crash reports never sent: after a month no app uploads them',
192
+ tier: 'moderate',
193
+ aggregate: true,
194
+ paths: [
195
+ '~/Library/Application Support/*/Crashpad/pending/*',
196
+ ...['Google', 'Yandex', 'BraveSoftware'].map(
197
+ (vendor) =>
198
+ `~/Library/Application Support/${vendor}/*/Crashpad/pending/*`
199
+ ),
200
+ '~/.config/*/Crashpad/pending/*',
201
+ ],
202
+ minAge: 'inactive',
203
+ },
204
+ {
205
+ id: 'gh-run-log-cache',
206
+ ecosystem: 'system',
207
+ description:
208
+ 'GitHub CLI cached workflow run logs: gh downloads them again while GitHub keeps them',
209
+ tier: 'moderate',
210
+ aggregate: true,
211
+ paths: ['~/.cache/gh/run-log-*.zip'],
212
+ minAge: 'stale',
213
+ },
187
214
  ];
@@ -414,6 +414,15 @@ export const OTHER_RULES = [
414
414
  aggregate: true,
415
415
  paths: ['~/Library/Logs/DiagnosticReports/*'],
416
416
  },
417
+ {
418
+ id: 'rotated-app-logs',
419
+ ecosystem: 'system',
420
+ description:
421
+ 'rotated app logs (warp.log.old.2, app.log.1): apps write only the current log',
422
+ tier: 'moderate',
423
+ paths: ['~/Library/Logs/*.log.*', '~/Library/Logs/*/*.log.*'],
424
+ minAge: 'stale',
425
+ },
417
426
  {
418
427
  id: 'apt-archives',
419
428
  ecosystem: 'system',
package/src/scan.js CHANGED
@@ -173,6 +173,7 @@ export async function environmentContext(env, options, overrides = {}) {
173
173
  });
174
174
  const liveness = new LivenessProbe(env, {
175
175
  staleAgeMs: options.staleAgeMs,
176
+ inactiveMs: options.inactiveMs,
176
177
  now: options.now,
177
178
  });
178
179
  await liveness.refresh(true);
@@ -377,13 +378,20 @@ async function scanEnvironmentScoped(
377
378
  block(item, env.scanPolicy.removalReason(target));
378
379
  }
379
380
  }
380
- for (const message of env.accessErrors?.values() ?? []) {
381
+ for (const message of await accessErrorMessages(env)) {
381
382
  errors.push({ env: env.id, scanner: 'filesystem', message });
382
383
  }
383
384
  env.scanStatus = incomplete ? 'error' : 'complete';
384
385
  return items;
385
386
  }
386
387
 
388
+ /** Report messages for paths the scan could not inspect. */
389
+ function accessErrorMessages(env) {
390
+ return env.accessErrorMessages
391
+ ? env.accessErrorMessages()
392
+ : [...(env.accessErrors?.values() ?? [])];
393
+ }
394
+
387
395
  async function attributeStorage(env, items) {
388
396
  if (env.writableLayer) {
389
397
  const usages = await env.usageMany([
@@ -516,6 +524,24 @@ export function filterItems(items, options, daemons = [], pathApi) {
516
524
  );
517
525
  }
518
526
 
527
+ /**
528
+ * Items that share every block with an installed app (APFS clones): they
529
+ * free nothing, so they are not offered, but `du` counts them at full size.
530
+ */
531
+ function sharedItems(items, options, pathApi) {
532
+ return filterItems(
533
+ items.filter((item) => item.sharedWith),
534
+ { ...options, minSizeBytes: 0 },
535
+ [],
536
+ pathApi
537
+ ).map((item) => ({
538
+ rule: item.rule,
539
+ path: item.path,
540
+ apparentBytes: item.apparentBytes,
541
+ sharedWith: item.sharedWith,
542
+ }));
543
+ }
544
+
519
545
  function envDescriptor(env, depth, chain) {
520
546
  return {
521
547
  id: env.id,
@@ -665,6 +691,7 @@ async function scanScoped(input) {
665
691
  docker?.daemons ?? [],
666
692
  env.path
667
693
  );
694
+ const shared = sharedItems(hostItems, options, env.path);
668
695
  const environments = [envDescriptor(env, 0, [])];
669
696
  for (const inner of docker?.environments ?? []) {
670
697
  environments.push(inner);
@@ -677,6 +704,7 @@ async function scanScoped(input) {
677
704
  options,
678
705
  env,
679
706
  items,
707
+ shared,
680
708
  environments,
681
709
  docker,
682
710
  errors,
@@ -707,6 +735,7 @@ export function buildReport({
707
735
  options,
708
736
  env,
709
737
  items,
738
+ shared = [],
710
739
  environments,
711
740
  docker,
712
741
  errors,
@@ -722,6 +751,7 @@ export function buildReport({
722
751
  options: reportOptions(options),
723
752
  environments,
724
753
  items,
754
+ shared,
725
755
  totals: tierTotals(items),
726
756
  docker: docker
727
757
  ? {
@@ -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
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * A node_modules directory is regenerable only as far as package managers
3
+ * fill it. Other data kept there (verification runs, evidence, hand-made
4
+ * fixtures) survives no reinstall, so it keeps the whole directory.
5
+ */
6
+
7
+ import { formatBytes } from '../units.js';
8
+
9
+ /**
10
+ * Top-level entries package managers and the tools they run create:
11
+ * links, lockfile copies, store and workspace state, and caches that
12
+ * re-create themselves.
13
+ */
14
+ const MANAGED_ENTRIES = new Set([
15
+ '.bin',
16
+ '.package-lock.json',
17
+ '.modules.yaml',
18
+ '.pnpm',
19
+ '.pnpm-workspace-state.json',
20
+ '.pnpm-workspace-state-v1.json',
21
+ '.yarn-state.yml',
22
+ '.yarn-integrity',
23
+ '.yarn-metadata.json',
24
+ '.store',
25
+ '.staging',
26
+ '.ignored',
27
+ '.cache',
28
+ '.vite',
29
+ '.vite-temp',
30
+ '.vitest',
31
+ '.vitest-cache',
32
+ '.deno',
33
+ '.prisma',
34
+ '.astro',
35
+ '.mf',
36
+ '.tmp',
37
+ '.DS_Store',
38
+ ]);
39
+
40
+ /**
41
+ * Entries of `dir` no package manager installs, with their size; empty
42
+ * leftovers are left out.
43
+ * @returns {Promise<Array<{name: string, bytes: number}>>}
44
+ */
45
+ export async function foreignEntries(env, dir) {
46
+ const candidates = [];
47
+ for (const entry of await env.list(dir)) {
48
+ if (
49
+ MANAGED_ENTRIES.has(entry.name) ||
50
+ entry.name.startsWith('@') ||
51
+ entry.type === 'symlink'
52
+ ) {
53
+ continue;
54
+ }
55
+ if (
56
+ entry.type === 'dir' &&
57
+ (await env.stat(env.path.join(entry.path, 'package.json')))
58
+ ) {
59
+ continue;
60
+ }
61
+ candidates.push(entry);
62
+ }
63
+ if (candidates.length === 0) {
64
+ return [];
65
+ }
66
+ const usages = await env.usageMany(
67
+ candidates.map((entry) => entry.path),
68
+ { cache: true }
69
+ );
70
+ return candidates
71
+ .map((entry) => ({ name: entry.name, usage: usages.get(entry.path) }))
72
+ .filter(({ usage }) => usage && (usage.files ?? 1) > 0)
73
+ .map(({ name, usage }) => ({ name, bytes: usage.bytes }))
74
+ .sort((a, b) => b.bytes - a.bytes);
75
+ }
76
+
77
+ /** Blocker naming the foreign data inside `dir`, or null. */
78
+ export async function foreignDataBlocker(env, dir) {
79
+ const foreign = await foreignEntries(env, dir);
80
+ if (foreign.length === 0) {
81
+ return null;
82
+ }
83
+ const names = foreign
84
+ .slice(0, 3)
85
+ .map((entry) => `${entry.name} (${formatBytes(entry.bytes)})`)
86
+ .join(', ');
87
+ const more = foreign.length > 3 ? ` and ${foreign.length - 3} more` : '';
88
+ return `holds data no package manager installs: ${names}${more}; a reinstall does not re-create it, so move it out of node_modules first`;
89
+ }
@@ -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
@@ -20,9 +23,11 @@ import { block, makeItem } from '../items.js';
20
23
  import { isWithin, matchesGlob } from '../paths.js';
21
24
  import { listMany, scanTimeBusy } from './common.js';
22
25
  import { analyzeRustProfile, findRustProfiles } from './rust.js';
26
+ import { foreignDataBlocker } from './node-modules.js';
23
27
  import { timed } from '../timings.js';
24
28
 
25
29
  const ECOSYSTEM_NAMES = new Map(ECOSYSTEMS.map((e) => [e.id, e.name]));
30
+ const WORKTREE_DIRS = ['.worktrees', 'worktrees'];
26
31
 
27
32
  function anyMatch(entries, globs) {
28
33
  return (entries ?? []).some((entry) =>
@@ -37,6 +42,13 @@ function depthBelow(env, roots, target) {
37
42
  return env.path.relative(root, target).split(/[\\/]/).filter(Boolean).length;
38
43
  }
39
44
 
45
+ /** Depth left below a directory no rule claims; worktrees start afresh. */
46
+ function remainingDepth(env, dirs, candidate, { depth, maxDepth }) {
47
+ return WORKTREE_DIRS.includes(candidate.name)
48
+ ? maxDepth
49
+ : depth - depthBelow(env, dirs, candidate.path);
50
+ }
51
+
40
52
  function ruleAccepts(env, rule, candidate, selfListing, grandListing) {
41
53
  if (rule.markers && !anyMatch(candidate.siblings, rule.markers)) {
42
54
  return false;
@@ -139,7 +151,9 @@ async function indexedMatches(env, roots, globs, maxDepth, rules) {
139
151
  export async function findProjectDirs(env, roots, options = {}) {
140
152
  const rules = options.rules ?? PROJECT_RULES;
141
153
  const maxDepth = options.maxDepth ?? 6;
142
- const globs = [...new Set(rules.flatMap((rule) => rule.names))];
154
+ const globs = [
155
+ ...new Set([...rules.flatMap((rule) => rule.names), ...WORKTREE_DIRS]),
156
+ ];
143
157
  const matches = [];
144
158
  const seen = new Set();
145
159
  const accept = (match) => {
@@ -171,7 +185,10 @@ export async function findProjectDirs(env, roots, options = {}) {
171
185
  }
172
186
  continue;
173
187
  }
174
- const remaining = depth - depthBelow(env, dirs, match.candidate.path);
188
+ const remaining = remainingDepth(env, dirs, match.candidate, {
189
+ depth,
190
+ maxDepth,
191
+ });
175
192
  if (remaining > 0) {
176
193
  next.set(remaining, [
177
194
  ...(next.get(remaining) ?? []),
@@ -232,6 +249,14 @@ async function projectItem(context, { candidate, rule }, usage) {
232
249
  ...projectTier(rule),
233
250
  checks: { busy: rule.busy ?? [], cwd: project, mtime: true },
234
251
  });
252
+ if (rule.id === 'node-modules') {
253
+ block(
254
+ item,
255
+ await timed(context.timings, 'projects:contents', () =>
256
+ foreignDataBlocker(env, candidate.path)
257
+ )
258
+ );
259
+ }
235
260
  await addBlockers(context, item, false);
236
261
  return item;
237
262
  }
package/src/swap.js CHANGED
@@ -260,8 +260,11 @@ export function swapWarnings(swap, disk) {
260
260
  if (reasons.length === 0) {
261
261
  return [];
262
262
  }
263
- // The kernel (PID 0) cannot be quit, so naming it helps nobody.
264
- const candidates = swap.holders.filter((holder) => holder.pid > 0);
263
+ // The kernel (PID 0) cannot be quit and this dss run ends by itself, so
264
+ // naming either helps nobody.
265
+ const candidates = swap.holders.filter(
266
+ (holder) => holder.pid > 0 && !holder.self
267
+ );
265
268
  const growing = candidates.filter(
266
269
  (holder) => holder.growth?.memoryBytes >= GROWTH_WARNING_BYTES
267
270
  );
@@ -308,6 +311,10 @@ export async function readSwap(env, options = {}) {
308
311
  if (!swap) {
309
312
  return null;
310
313
  }
314
+ // This dss run shows up among the holders while it scans; it ends on its own.
315
+ swap.holders = swap.holders.map((holder) =>
316
+ holder.pid === process.pid ? { ...holder, self: true } : holder
317
+ );
311
318
  const history = await loadSwapHistory(stateDir, now);
312
319
  const grown = withGrowth(swap, history);
313
320
  await saveSwapHistory(stateDir, [