disk-space-saviour 0.16.0 → 0.17.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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a518d7b: Reuse recent authenticated scans, stream safe global-cache cleanup before project discovery, and stop emergency scans as soon as the free-space goal is met. Add bounded discovery, persistent directory and size indexes, separate candidate/sizing progress counters, and macOS swap diagnostics while retaining fresh deletion checks.
8
+
3
9
  ## 0.16.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -76,7 +76,14 @@ Run `dss --help` for every option. The most useful ones:
76
76
  - `--scanner NAME` restricts scanning to `projects`, `global`, `versions`,
77
77
  `agents` or `system`.
78
78
  - `--report FILE` limits cleaning to items in a saved `dss scan --json`
79
- report. The current rules rescan those items before deletion.
79
+ report. `--from FILE` also accepts an audit JSON containing the full report.
80
+ Recent reports authenticated by this installation are reused; older, unsigned
81
+ or edited reports require discovery again. Changing discovery or action options
82
+ also rebuilds the plan. Every deletion still gets fresh checks.
83
+ - `clean` automatically reuses a matching scan from the last 30 minutes.
84
+ `--fresh` bypasses this plan and the discovery/size index; `--cache-dir DIR`
85
+ chooses the state location (default `$XDG_STATE_HOME/disk-space-saviour`, or
86
+ `~/.local/state/disk-space-saviour`).
80
87
 
81
88
  ## Tiers
82
89
 
@@ -179,6 +186,38 @@ print completed findings marked `aborted`; incomplete findings remain blocked.
179
186
  The CLI exits 130 or 143 respectively. A second signal exits immediately.
180
187
  Library callers can pass an `AbortSignal` and an `onScanProgress` callback.
181
188
 
189
+ Global caches run first, prioritizing rules that can use native sizing without
190
+ recursive activity timestamps, and emit findings as each rule finishes. Non-interactive
191
+ `clean` plans or deletes completed safe host caches while the remaining discovery
192
+ continues. `emergency` does the same, checks actual free space after each deletion,
193
+ and skips the remaining scan once the goal is met. Interactive runs show a plan
194
+ before asking for deletion consent. A later phase hitting its budget blocks its
195
+ partial findings while completed independent findings stay usable.
196
+
197
+ Project discovery uses eight directory workers, prunes matched generated trees,
198
+ Git objects, home media/Photos libraries, macOS Library and mounted volumes.
199
+ Source activity is checked separately without sizing entire project trees again.
200
+ Spotlight (`mdfind`) or `plocate`/`locate` supplies verified early hints when
201
+ available; the walk also runs because an index can miss files. Version-pin discovery
202
+ also prunes dependency/build trees.
203
+
204
+ The persistent `index/` stores directory listings and candidate measurements.
205
+ Each descendant directory's own mtime/ctime and identity is checked before reusing
206
+ its listing, so a nested addition is visible even when an ancestor did not change.
207
+ Unchanged directories avoid another listing; discovery still checks their metadata.
208
+ Sizing uses bounded workers and `du -skx` when no recursive activity timestamp is
209
+ needed. Sizes cached for up to an hour are labeled **estimated**, because changing
210
+ an existing descendant need not update the candidate's directory mtime. Cleanup
211
+ always bypasses that cache and rechecks processes, open files, Git, path boundaries
212
+ and sizes. Safe cleanup also rechecks source activity. Explicit moderate cleanup
213
+ preserves the ability to remove ignored dependencies when only source changed.
214
+ Library callers opt into state with `cacheDir`.
215
+
216
+ On macOS, reports include `sysctl vm.swapusage` and the largest resident/compressed
217
+ memory consumers from `top`. These are memory diagnostics, not verified per-process
218
+ swap ownership. When Docker Desktop consumes memory, saving work and restarting it
219
+ or lowering its VM memory limit can help. dss never deletes swap files.
220
+
182
221
  Cache discovery honors tool configuration before default paths, including
183
222
  `GOMODCACHE`, `GOPATH`, `GOCACHE`, `CARGO_HOME`, `RUSTUP_HOME`,
184
223
  `GRADLE_USER_HOME`, `DENO_DIR`, `BUN_INSTALL_CACHE_DIR` and `XDG_CACHE_HOME`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "disk-space-saviour",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Safely reclaims disk space: caches and build outputs of 20+ ecosystems, forgotten projects, and Docker (running, stopped and nested containers), with an emergency mode",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/clean.js CHANGED
@@ -44,6 +44,7 @@ import { OTHER_RULES } from './rules/other.js';
44
44
  import { compareVersions, VERSION_RULES } from './rules/versions.js';
45
45
  import { scanVersions } from './scanners/versions.js';
46
46
  import { analyzeRustProfile } from './scanners/rust.js';
47
+ import { currentProjectTier } from './scanners/projects.js';
47
48
  import {
48
49
  filterItems,
49
50
  revalidateReport,
@@ -161,7 +162,7 @@ export class Cleaner {
161
162
  this.report = report;
162
163
  this.options = options;
163
164
  this.contexts = new Map();
164
- this.health = new HealthWatch();
165
+ this.health = new HealthWatch(options.priorHealth);
165
166
  this.descriptors = new Map(
166
167
  (report.environments ?? []).map((env) => [env.id, env])
167
168
  );
@@ -371,6 +372,15 @@ export class Cleaner {
371
372
  }
372
373
 
373
374
  async #staticBlocker(ctx, item) {
375
+ if (item.project && this.options.tier === 'safe') {
376
+ const tier = await currentProjectTier(
377
+ { ...ctx, options: this.options, now: this.options.now() },
378
+ item
379
+ );
380
+ if (tierRank(tier) > tierRank(this.options.tier)) {
381
+ return `project recently active; requires the ${tier} tier`;
382
+ }
383
+ }
374
384
  if (item.recheck?.type === 'version') {
375
385
  if (!ctx.cacheRootsConfigured) {
376
386
  await configureCacheRoots(ctx.env, await ctx.env.homeDirs());
@@ -385,6 +395,7 @@ export class Cleaner {
385
395
  options: {
386
396
  ...this.options,
387
397
  homes: [item.recheck.home],
398
+ freshSizes: true,
388
399
  roots: this.report.options.roots ?? [],
389
400
  },
390
401
  now: this.options.now(),
@@ -429,9 +440,18 @@ export class Cleaner {
429
440
  if (
430
441
  ctx.env.kind === 'host' &&
431
442
  type === 'remove' &&
432
- this.report.docker?.daemons?.some((daemon) => daemon.env === ctx.env.id)
443
+ (this.report.docker?.daemons?.some(
444
+ (daemon) => daemon.env === ctx.env.id
445
+ ) ||
446
+ this.options.streaming)
433
447
  ) {
434
- const mounts = await runningBindMounts(new DockerCli(ctx.executor));
448
+ const docker = new DockerCli(ctx.executor);
449
+ if (this.options.streaming && !(await docker.info())) {
450
+ return this.options.docker === true
451
+ ? 'cannot verify running Docker bind mounts'
452
+ : null;
453
+ }
454
+ const mounts = await runningBindMounts(docker);
435
455
  for (const mount of mounts) {
436
456
  if (
437
457
  item.paths.some(
@@ -907,7 +927,8 @@ export async function clean(report, input = {}) {
907
927
  const audit = startAudit(input.command ?? 'clean', {
908
928
  dryRun: Boolean(options.dryRun),
909
929
  tier: options.tier,
910
- report: { createdAt: report.createdAt, totals: report.totals },
930
+ report,
931
+ entries: [...(input.priorEntries ?? [])],
911
932
  });
912
933
  const persistProgress = async (entry) => {
913
934
  if (!audit.entries.includes(entry)) {
@@ -927,7 +948,10 @@ export async function clean(report, input = {}) {
927
948
  await writeAudit(audit, { ...options, inProgress: true });
928
949
  }
929
950
  try {
930
- for (const item of planItems(report, options)) {
951
+ const done = new Set(audit.entries.map((entry) => entry.id));
952
+ for (const item of planItems(report, options).filter(
953
+ (item) => !done.has(item.id)
954
+ )) {
931
955
  if (options.signal?.aborted) {
932
956
  throw new Error('cleanup aborted');
933
957
  }
package/src/cli.js CHANGED
@@ -15,9 +15,11 @@ import { startAudit, writeAudit } from './audit.js';
15
15
  import { clean } from './clean.js';
16
16
  import { emergency } from './emergency.js';
17
17
  import { setTrace } from './exec.js';
18
- import { resolveGoal } from './options.js';
18
+ import { resolveGoal, resolveOptions } from './options.js';
19
19
  import { formatAudit, formatReport } from './report.js';
20
20
  import { scan } from './scan.js';
21
+ import { cleanDuringScan } from './fast-clean.js';
22
+ import { defaultStateDirectory, loadScan } from './scan-state.js';
21
23
  import { formatBytes } from './units.js';
22
24
 
23
25
  export const USAGE = `Usage: dss <command> [paths...] [options]
@@ -67,6 +69,9 @@ Consent:
67
69
  Output:
68
70
  --json print the report or audit log as JSON
69
71
  --report FILE clean: use a report saved by scan --json
72
+ --from FILE clean/emergency: reuse a scan or audit JSON
73
+ --fresh bypass the recent scan and persistent index
74
+ --cache-dir DIR scan state (default: XDG state directory)
70
75
  --audit-dir DIR audit logs ($DSS_AUDIT_DIR)
71
76
  --backup-dir DIR container log backups ($DSS_BACKUP_DIR)
72
77
  --verbose list everything and trace commands (DSS_DEBUG=1)
@@ -106,6 +111,9 @@ const OPTIONS = {
106
111
  'allow-dirty-container': { type: 'string', multiple: true },
107
112
  'no-native': { type: 'boolean' },
108
113
  report: { type: 'string' },
114
+ from: { type: 'string' },
115
+ fresh: { type: 'boolean' },
116
+ 'cache-dir': { type: 'string' },
109
117
  'audit-dir': { type: 'string' },
110
118
  'backup-dir': { type: 'string' },
111
119
  help: { type: 'boolean', short: 'h' },
@@ -182,6 +190,9 @@ export function toOptions(values, paths = []) {
182
190
  noNative: values['no-native'],
183
191
  auditDir: values['audit-dir'],
184
192
  backupDir: values['backup-dir'],
193
+ cacheDir: values.fresh
194
+ ? false
195
+ : (values['cache-dir'] ?? defaultStateDirectory()),
185
196
  };
186
197
  }
187
198
 
@@ -240,14 +251,7 @@ function dockerOnly(options, values) {
240
251
  export function scanAudit(report) {
241
252
  return startAudit('scan', {
242
253
  dryRun: true,
243
- report: {
244
- createdAt: report.createdAt,
245
- durationMs: report.durationMs,
246
- totals: report.totals,
247
- environments: report.environments.map((env) => env.id),
248
- errors: report.errors,
249
- items: report.items,
250
- },
254
+ report,
251
255
  entries: report.items.map((item) => ({
252
256
  id: item.id,
253
257
  env: item.env,
@@ -283,7 +287,7 @@ export function processIo() {
283
287
  };
284
288
  }
285
289
 
286
- const API = { scan, clean, emergency };
290
+ const API = { scan, clean, emergency, cleanDuringScan };
287
291
 
288
292
  class Cli {
289
293
  constructor(io, api) {
@@ -321,10 +325,19 @@ class Cli {
321
325
  }
322
326
 
323
327
  async loadReport(options, values) {
324
- if (!values.report) {
328
+ const file = values.from ?? values.report;
329
+ if (!file) {
330
+ const recent = await loadScan(resolveOptions(options));
331
+ if (recent) {
332
+ this.io.stderr(
333
+ `Reusing scan from ${recent.createdAt}; planned items will be checked before deletion.`
334
+ );
335
+ return recent;
336
+ }
325
337
  return this.api.scan(options);
326
338
  }
327
- return JSON.parse(await fsp.readFile(values.report, 'utf8'));
339
+ const saved = JSON.parse(await fsp.readFile(file, 'utf8'));
340
+ return saved.report?.schema ? saved.report : saved;
328
341
  }
329
342
 
330
343
  confirmItem() {
@@ -371,6 +384,33 @@ class Cli {
371
384
  }
372
385
 
373
386
  async clean(options, values) {
387
+ if (
388
+ this.api.cleanDuringScan &&
389
+ !values.report &&
390
+ !values.from &&
391
+ !this.io.interactive
392
+ ) {
393
+ const recent = await loadScan(resolveOptions(options));
394
+ if (!recent) {
395
+ const result = await this.consent(values, (extra) =>
396
+ this.api.cleanDuringScan({
397
+ ...options,
398
+ ...extra,
399
+ streaming: options.docker !== false,
400
+ })
401
+ );
402
+ if (result.report.aborted) {
403
+ return this.printScan(result.report, options, values);
404
+ }
405
+ this.print(values, result.audit, () =>
406
+ formatAudit(result.audit, { verbose: values.verbose })
407
+ );
408
+ return result.audit.aborted ||
409
+ result.audit.entries.some((entry) => entry.status === 'failed')
410
+ ? 1
411
+ : 0;
412
+ }
413
+ }
374
414
  const report = await this.loadReport(options, values);
375
415
  if (report.aborted) {
376
416
  return this.printScan(report, options, values);
@@ -393,6 +433,32 @@ class Cli {
393
433
  } catch (error) {
394
434
  throw new UsageError(error.message);
395
435
  }
436
+ if (
437
+ this.api === API &&
438
+ !values.report &&
439
+ !values.from &&
440
+ !this.io.interactive
441
+ ) {
442
+ const recent = await loadScan(resolveOptions(options));
443
+ const audit = await this.consent(values, (extra) =>
444
+ this.api.emergency({
445
+ ...options,
446
+ ...(recent ? { report: recent } : {}),
447
+ ...extra,
448
+ })
449
+ );
450
+ if (audit.scanReport?.aborted) {
451
+ return this.printScan(audit.scanReport, options, values);
452
+ }
453
+ this.print(values, audit, () =>
454
+ formatAudit(audit, { verbose: values.verbose })
455
+ );
456
+ return audit.entries.some((entry) => entry.status === 'failed')
457
+ ? 1
458
+ : audit.goalMet
459
+ ? 0
460
+ : 3;
461
+ }
396
462
  const report = await this.loadReport(options, values);
397
463
  if (report.aborted) {
398
464
  return this.printScan(report, options, values);
@@ -461,7 +527,7 @@ export async function runCli(argv, deps = {}) {
461
527
  }
462
528
  options.onScanProgress = (progress) =>
463
529
  io.stderr(
464
- `[scan] ${progress.env}: ${progress.scanner}, ${Math.round(progress.elapsedMs / 1000)}s, ${progress.items} items (${progress.status})`
530
+ `[scan] ${progress.env}: ${progress.scanner}, ${Math.round(progress.elapsedMs / 1000)}s, ${progress.candidates ?? progress.items} candidates, ${progress.filesSized ?? 0} files sized, ${progress.directoriesSized ?? 0} directories sized (${progress.status})`
465
531
  );
466
532
  return await dispatch(
467
533
  new Cli(io, deps.api ?? API),
package/src/emergency.js CHANGED
@@ -89,12 +89,40 @@ export async function emergency(input = {}) {
89
89
  });
90
90
  const report = input.report
91
91
  ? await revalidateReport(input.report, { ...input, env })
92
- : await scan({ ...input, env });
93
- audit.report = { createdAt: report.createdAt, totals: report.totals };
92
+ : goalMet(goal, diskBefore)
93
+ ? {
94
+ createdAt: new Date(options.now()).toISOString(),
95
+ items: [],
96
+ environments: [{ id: env.id, label: env.label }],
97
+ totals: {},
98
+ }
99
+ : await scan({
100
+ ...input,
101
+ env,
102
+ onScanPhase: async (phase) => {
103
+ audit.diskAfter = await escalate({
104
+ report: phase,
105
+ options: {
106
+ ...options,
107
+ tier: 'safe',
108
+ streaming: options.docker !== false,
109
+ },
110
+ env,
111
+ target,
112
+ goal,
113
+ audit,
114
+ });
115
+ return !goalMet(goal, audit.diskAfter);
116
+ },
117
+ });
118
+ audit.report = report;
94
119
  if (options.audit !== false) {
95
120
  await writeAudit(audit, { ...options, inProgress: true });
96
121
  }
97
- let disk = diskBefore;
122
+ let disk = audit.diskAfter ?? diskBefore;
123
+ if (report.aborted) {
124
+ audit.scanReport = report;
125
+ }
98
126
  try {
99
127
  if (!goalMet(goal, disk)) {
100
128
  disk = await escalate({ report, options, env, target, goal, audit });
@@ -121,9 +149,10 @@ async function escalate({ report, options, env, target, goal, audit }) {
121
149
  ...options,
122
150
  env,
123
151
  onProgress: persistProgress,
152
+ priorHealth: audit.health,
124
153
  });
125
154
  audit.health = cleaner.health.records;
126
- const done = new Set();
155
+ const done = new Set(audit.entries.map((entry) => entry.id));
127
156
  const targetDevice = await env.deviceId?.(target);
128
157
  const scoped = await Promise.all(
129
158
  wantedItems(report, options).map(async (item) => {
@@ -141,8 +170,10 @@ async function escalate({ report, options, env, target, goal, audit }) {
141
170
  })
142
171
  );
143
172
  const wanted = scoped.filter(Boolean);
144
- let disk = audit.diskBefore;
145
- let freed = 0;
173
+ let disk = audit.diskAfter ?? audit.diskBefore;
174
+ let freed = audit.entries
175
+ .filter((entry) => entry.status === 'planned')
176
+ .reduce((sum, entry) => sum + entry.plannedBytes, 0);
146
177
  for (const tier of TIERS.slice(0, tierRank(options.tier) + 1)) {
147
178
  audit.reachedTier = tier;
148
179
  const items = selectByTier(wanted, tier)