disk-space-saviour 0.16.0 → 0.18.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/env/local.js CHANGED
@@ -48,6 +48,40 @@ export function parseLsofCwds(output) {
48
48
  return cwds;
49
49
  }
50
50
 
51
+ /**
52
+ * Absolute directories of a `PATH` value.
53
+ * @param {string} value
54
+ * @returns {string[]}
55
+ */
56
+ export function searchPathDirs(value) {
57
+ return (value ?? '').split(':').filter((dir) => dir.startsWith('/'));
58
+ }
59
+
60
+ /**
61
+ * `PATH` directories by pid from `ps -E -ww -axo pid=,command=` output,
62
+ * which appends the environment of the user's own processes to their
63
+ * arguments. A value ends at the first space, so a directory whose name
64
+ * contains one is not tracked.
65
+ * @param {string} output
66
+ * @returns {Map<number, string[]>}
67
+ */
68
+ export function parsePsSearchPaths(output) {
69
+ const paths = new Map();
70
+ for (const line of output.split('\n')) {
71
+ const match = /^\s*(\d+)\s/.exec(line);
72
+ if (!match) {
73
+ continue;
74
+ }
75
+ const dirs = [...line.matchAll(/(?:^|\s)PATH=(\S*)/g)].flatMap((found) =>
76
+ searchPathDirs(found[1])
77
+ );
78
+ if (dirs.length > 0) {
79
+ paths.set(Number(match[1]), [...new Set(dirs)]);
80
+ }
81
+ }
82
+ return paths;
83
+ }
84
+
51
85
  /**
52
86
  * Environment adapter for the machine running dss, backed by node:fs.
53
87
  *
@@ -64,6 +98,9 @@ import { CACHE_VARIABLES } from './cache-roots.js';
64
98
 
65
99
  import { hostExecutor } from '../exec.js';
66
100
  import { matchesGlob } from '../paths.js';
101
+ import { mapLimit } from './parallel.js';
102
+ import { directoryFingerprint, SIZE_MAX_AGE } from '../scan-state.js';
103
+ import { indexedDirectories } from './platform-index.js';
67
104
 
68
105
  // Directories read concurrently while measuring usage.
69
106
  const WALK_BATCH = 64;
@@ -252,6 +289,7 @@ export class LocalEnv {
252
289
  size: stats.size,
253
290
  bytes: diskBytes(stats),
254
291
  mtimeMs: stats.mtimeMs,
292
+ ctimeMs: stats.ctimeMs,
255
293
  inode: `${stats.dev}:${stats.ino}`,
256
294
  uid: stats.uid,
257
295
  gid: stats.gid,
@@ -295,31 +333,36 @@ export class LocalEnv {
295
333
  return heads;
296
334
  }
297
335
 
298
- async list(dir) {
336
+ async list(dir, { discovery = false } = {}) {
299
337
  let names;
300
338
  try {
301
- names = await (this.options.fileFs ?? fsp).readdir(dir);
339
+ names = await (this.options.fileFs ?? fsp).readdir(dir, {
340
+ withFileTypes: true,
341
+ });
302
342
  } catch (error) {
303
343
  this.recordAccessError(dir, error);
304
344
  return [];
305
345
  }
306
- const entries = await Promise.all(
307
- names.map(async (name) => {
308
- const full = path.join(dir, name);
309
- const stats = await this.#lstat(full);
310
- if (!stats) {
311
- return null;
312
- }
313
- return {
314
- name,
315
- path: full,
316
- type: entryType(stats),
317
- size: stats.size,
318
- bytes: diskBytes(stats),
319
- mtimeMs: stats.mtimeMs,
320
- };
321
- })
322
- );
346
+ const entries = await mapLimit(names, 32, async (entry) => {
347
+ const name = typeof entry === 'string' ? entry : entry.name;
348
+ const full = path.join(dir, name);
349
+ if (discovery && typeof entry !== 'string' && !entry.isDirectory()) {
350
+ return { name, path: full, type: entryType(entry), mtimeMs: 0 };
351
+ }
352
+ const stats = await this.#lstat(full);
353
+ if (!stats) {
354
+ return null;
355
+ }
356
+ return {
357
+ name,
358
+ path: full,
359
+ type: entryType(stats),
360
+ size: stats.size,
361
+ bytes: diskBytes(stats),
362
+ mtimeMs: stats.mtimeMs,
363
+ device: stats.dev,
364
+ };
365
+ });
323
366
  return entries.filter(Boolean);
324
367
  }
325
368
 
@@ -328,14 +371,15 @@ export class LocalEnv {
328
371
  * counted a single time, symlinks not followed. Also reports the newest
329
372
  * file mtime.
330
373
  */
331
- async usage(target) {
332
- return (await this.usageMany([target])).get(target) ?? null;
374
+ async usage(target, options) {
375
+ return (await this.usageMany([target], options)).get(target) ?? null;
333
376
  }
334
377
 
335
378
  async statMany(targets) {
336
- const entries = await Promise.all(
337
- targets.map(async (target) => [target, await this.stat(target)])
338
- );
379
+ const entries = await mapLimit(targets, 32, async (target) => [
380
+ target,
381
+ await this.stat(target),
382
+ ]);
339
383
  return new Map(entries.filter(([, stat]) => stat));
340
384
  }
341
385
 
@@ -362,6 +406,9 @@ export class LocalEnv {
362
406
  apparentBytes += stats.size;
363
407
  if (!stats.isDirectory()) {
364
408
  files++;
409
+ if (this.scanMetrics) {
410
+ this.scanMetrics.filesSized++;
411
+ }
365
412
  newestMtimeMs = Math.max(newestMtimeMs, stats.mtimeMs);
366
413
  }
367
414
  };
@@ -375,12 +422,10 @@ export class LocalEnv {
375
422
  errors++;
376
423
  return [];
377
424
  }
378
- const children = await Promise.all(
379
- names.map(async (name) => {
380
- const full = path.join(dir, name);
381
- return [full, await this.#lstat(full)];
382
- })
383
- );
425
+ const children = await mapLimit(names, 32, async (name) => {
426
+ const full = path.join(dir, name);
427
+ return [full, await this.#lstat(full)];
428
+ });
384
429
  const subdirs = [];
385
430
  for (const [full, stats] of children) {
386
431
  if (!stats) {
@@ -453,6 +498,10 @@ export class LocalEnv {
453
498
  return results;
454
499
  }
455
500
 
501
+ indexedDirs(roots, globs, maxDepth) {
502
+ return indexedDirectories(this, roots, globs, maxDepth);
503
+ }
504
+
456
505
  /**
457
506
  * Finds regular files whose basename matches one of `names`.
458
507
  * @returns {Promise<string[]>}
@@ -468,7 +517,14 @@ export class LocalEnv {
468
517
  results.push(entry.path);
469
518
  }
470
519
  }
471
- return null;
520
+ return new Set([
521
+ 'node_modules',
522
+ 'target',
523
+ 'vendor',
524
+ '.venv',
525
+ 'venv',
526
+ ...this.skipNames,
527
+ ]);
472
528
  });
473
529
  return results;
474
530
  }
@@ -480,55 +536,185 @@ export class LocalEnv {
480
536
  async #walk(roots, maxDepth, skipNames, visit) {
481
537
  const skip = skipNames ? new Set(skipNames) : this.skipNames;
482
538
  const visited = new Set();
483
- const queue = await Promise.all(
539
+ let queue = await Promise.all(
484
540
  roots.map(async (root) => [root, 0, await this.deviceId(root)])
485
541
  );
486
542
  while (queue.length > 0) {
487
543
  checkSignal(this.signal);
488
- const [dir, depth, device] = queue.shift();
489
- if (visited.has(dir)) {
490
- continue;
491
- }
492
- visited.add(dir);
493
- const entries = [];
494
- for (const entry of await this.list(dir)) {
495
- if (
496
- entry.type !== 'dir' ||
497
- (await this.deviceId(entry.path)) === device
498
- ) {
499
- entries.push(entry);
500
- }
501
- }
502
- const pruned = visit(dir, entries) ?? new Set();
503
- if (depth + 1 >= maxDepth) {
504
- continue;
505
- }
506
- for (const entry of entries) {
507
- if (
508
- entry.type === 'dir' &&
509
- !pruned.has(entry.name) &&
510
- !skip.has(entry.name) &&
511
- (await this.deviceId(entry.path)) === device
512
- ) {
513
- queue.push([entry.path, depth + 1, device]);
544
+ const current = queue;
545
+ queue = [];
546
+ const children = await mapLimit(
547
+ current,
548
+ 8,
549
+ async ([dir, depth, device]) => {
550
+ if (visited.has(dir)) {
551
+ return [];
552
+ }
553
+ visited.add(dir);
554
+ const entries = [];
555
+ for (const entry of await this.discoveryList(dir)) {
556
+ if (
557
+ entry.type !== 'dir' ||
558
+ (entry.device ?? (await this.deviceId(entry.path))) === device
559
+ ) {
560
+ entries.push(entry);
561
+ }
562
+ }
563
+ const pruned = visit(dir, entries) ?? new Set();
564
+ if (depth + 1 >= maxDepth) {
565
+ return [];
566
+ }
567
+ const next = [];
568
+ for (const entry of entries) {
569
+ if (
570
+ entry.type === 'dir' &&
571
+ !pruned.has(entry.name) &&
572
+ !this.skipDiscoveryDirectory(dir, entry.name, skip) &&
573
+ (entry.device ?? (await this.deviceId(entry.path))) === device
574
+ ) {
575
+ next.push([entry.path, depth + 1, device]);
576
+ }
577
+ }
578
+ return next;
514
579
  }
580
+ );
581
+ queue = children.flat();
582
+ }
583
+ }
584
+
585
+ skipDiscoveryDirectory(dir, name, skip = this.skipNames) {
586
+ if (skip.has(name) || /\.photoslibrary$/i.test(name)) {
587
+ return true;
588
+ }
589
+ const homes = this.options.homes ?? [this.currentHome];
590
+ return (
591
+ (['Music', 'Pictures', 'Movies'].includes(name) && homes.includes(dir)) ||
592
+ (name === 'Volumes' && dir === this.path.parse(dir).root)
593
+ );
594
+ }
595
+
596
+ async discoveryStat(dir) {
597
+ let stats = await this.stat(dir);
598
+ // Explicit roots may be aliases (for example /tmp on macOS). Child links
599
+ // are never queued by the walker, so only an explicitly supplied root follows.
600
+ if (stats?.type === 'symlink') {
601
+ const resolved = await this.realPath(dir);
602
+ stats = resolved ? await this.stat(resolved) : null;
603
+ }
604
+ return stats;
605
+ }
606
+
607
+ async discoveryList(dir) {
608
+ checkSignal(this.signal);
609
+ const stats = await this.discoveryStat(dir);
610
+ if (!stats || stats.type !== 'dir') {
611
+ return [];
612
+ }
613
+ const key = `list:${dir}`;
614
+ const fingerprint = `${directoryFingerprint(stats)}:${JSON.stringify([this.scanPolicy?.exclude, this.scanPolicy?.pruned])}`;
615
+ let entries = this.scanIndex?.get(key, fingerprint);
616
+ if (entries) {
617
+ this.scanMetrics.indexHits++;
618
+ } else {
619
+ const errors = this.accessErrors?.size ?? 0;
620
+ entries = await this.list(dir, { discovery: true });
621
+ if ((this.accessErrors?.size ?? 0) === errors) {
622
+ this.scanIndex?.set(key, fingerprint, entries);
515
623
  }
516
624
  }
625
+ // Stored metadata is a discovery hint; never accept external paths from it.
626
+ const listing = entries
627
+ .filter(
628
+ (entry) => entry.name && this.path.basename(entry.name) === entry.name
629
+ )
630
+ .map((entry) => ({ ...entry, path: this.path.join(dir, entry.name) }))
631
+ .filter(
632
+ (entry) => !this.scanPolicy || this.scanPolicy.allows(entry.path)
633
+ );
634
+ // Mounts and link replacements need not change the parent's mtime.
635
+ return (
636
+ await mapLimit(listing, 32, async (entry) => {
637
+ if (entry.type !== 'dir') {
638
+ return entry;
639
+ }
640
+ const fresh = await this.stat(entry.path);
641
+ return fresh ? { ...entry, ...fresh } : null;
642
+ })
643
+ ).filter(Boolean);
517
644
  }
518
645
 
519
- async usageMany(targets) {
520
- const usages = await this.rawUsageMany(targets);
646
+ async usageMany(targets, options) {
647
+ const usages = await this.rawUsageMany(targets, options);
521
648
  return this.writableLayer
522
649
  ? this.writableLayer.measure(this, usages)
523
650
  : usages;
524
651
  }
525
652
 
526
- async rawUsageMany(targets) {
527
- const usages = new Map();
528
- for (const target of targets) {
529
- usages.set(target, await this.rawUsage(target));
653
+ async rawUsageMany(targets, { mtime = true, cache = false } = {}) {
654
+ const entries = await mapLimit([...new Set(targets)], 4, async (target) => [
655
+ target,
656
+ !this.writableLayer && cache
657
+ ? await this.cachedUsage(target, mtime)
658
+ : !mtime && !this.writableLayer
659
+ ? await this.quickUsage(target)
660
+ : await this.rawUsage(target),
661
+ ]);
662
+ return new Map(entries);
663
+ }
664
+
665
+ async cachedUsage(target, mtime) {
666
+ const stats = await this.stat(target);
667
+ if (!stats) {
668
+ return null;
530
669
  }
531
- return usages;
670
+ const key = `usage:${mtime}:${target}`;
671
+ const fingerprint = `${directoryFingerprint(stats)}:${JSON.stringify([this.scanPolicy?.exclude, this.scanPolicy?.pruned])}`;
672
+ const cached = this.scanIndex?.get(key, fingerprint, SIZE_MAX_AGE);
673
+ if (cached) {
674
+ this.scanMetrics.sizeCacheHits++;
675
+ return { ...cached, estimated: true };
676
+ }
677
+ const usage = mtime
678
+ ? await this.rawUsage(target)
679
+ : await this.quickUsage(target);
680
+ if (usage && !usage.errors) {
681
+ this.scanIndex?.set(key, fingerprint, usage);
682
+ }
683
+ return usage;
684
+ }
685
+
686
+ async quickUsage(target) {
687
+ const stats = await this.stat(target);
688
+ if (!stats) {
689
+ return null;
690
+ }
691
+ // du cannot enforce path-glob exclusions or detect a nested mount beneath a candidate.
692
+ if (
693
+ this.platform === 'win32' ||
694
+ this.scanPolicy?.exclude.length ||
695
+ this.scanPolicy?.mounts.some(
696
+ (mount) =>
697
+ mount.path !== '/' && mount.path.startsWith(`${target}${path.sep}`)
698
+ )
699
+ ) {
700
+ return this.rawUsage(target);
701
+ }
702
+ const result = await this.run(['du', '-skx', target]);
703
+ const match = /^\s*(\d+)\s/.exec(result.stdout);
704
+ if (result.code !== 0 || !match) {
705
+ return this.rawUsage(target);
706
+ }
707
+ const usage = {
708
+ bytes: Number(match[1]) * 1024,
709
+ apparentBytes: Number(match[1]) * 1024,
710
+ newestMtimeMs: 0,
711
+ files: 0,
712
+ errors: 0,
713
+ };
714
+ if (this.scanMetrics) {
715
+ this.scanMetrics.directoriesSized++;
716
+ }
717
+ return usage;
532
718
  }
533
719
 
534
720
  async readLink(target) {
@@ -608,6 +794,15 @@ export class LocalEnv {
608
794
  ).stdout
609
795
  )
610
796
  : new Map();
797
+ // A process may start programs from its PATH at any time without holding
798
+ // them open (for example a release directory of an updated tool).
799
+ const searchPaths =
800
+ this.platform === 'darwin'
801
+ ? parsePsSearchPaths(
802
+ (await this.run(['ps', '-E', '-ww', '-axo', 'pid=,command=']))
803
+ .stdout
804
+ )
805
+ : new Map();
611
806
  return result.stdout
612
807
  .split('\n')
613
808
  .map((line) => /^\s*(\d+)\s+(.+)$/.exec(line))
@@ -617,6 +812,7 @@ export class LocalEnv {
617
812
  name: path.basename(m[2].trim()),
618
813
  cwd: cwds.get(Number(m[1])) ?? null,
619
814
  command: commands.get(Number(m[1])) ?? '',
815
+ searchPath: searchPaths.get(Number(m[1])) ?? [],
620
816
  }))
621
817
  .filter((proc) => !selfPids.has(proc.pid));
622
818
  }
@@ -650,6 +846,9 @@ export class LocalEnv {
650
846
  const cmdline = await io
651
847
  .readFile(`${base}/cmdline`, 'utf8')
652
848
  .catch(() => '');
849
+ const environ = await io
850
+ .readFile(`${base}/environ`, 'utf8')
851
+ .catch(() => '');
653
852
  processes.push({
654
853
  pid: Number(pid),
655
854
  ...(await procIdentity(base, io)),
@@ -660,6 +859,12 @@ export class LocalEnv {
660
859
  aliases: processAliases(exe, cmdline.split('\0')[0]),
661
860
  command: cmdline.replaceAll('\0', ' '),
662
861
  argv: cmdline.split('\0').filter(Boolean),
862
+ searchPath: searchPathDirs(
863
+ environ
864
+ .split('\0')
865
+ .find((entry) => entry.startsWith('PATH='))
866
+ ?.slice('PATH='.length)
867
+ ),
663
868
  });
664
869
  }
665
870
  return processes;
@@ -0,0 +1,23 @@
1
+ /** Finite worker pool; settle all in-flight work before propagating failure. */
2
+ export async function mapLimit(values, limit, operation) {
3
+ const results = new Array(values.length);
4
+ let cursor = 0;
5
+ let failure;
6
+ const worker = async () => {
7
+ while (!failure && cursor < values.length) {
8
+ const index = cursor++;
9
+ try {
10
+ results[index] = await operation(values[index], index);
11
+ } catch (error) {
12
+ failure ??= error;
13
+ }
14
+ }
15
+ };
16
+ await Promise.all(
17
+ Array.from({ length: Math.min(limit, values.length) }, worker)
18
+ );
19
+ if (failure) {
20
+ throw failure;
21
+ }
22
+ return results;
23
+ }
@@ -0,0 +1,87 @@
1
+ /** OS index hits are hints; stale/missing indexes always fall back to discovery. */
2
+ import { isWithin, matchesGlob } from '../paths.js';
3
+ import { mapLimit } from './parallel.js';
4
+
5
+ export async function indexedDirectories(env, roots, globs, maxDepth) {
6
+ if (!env.scanIndex?.directory || env.platformIndex === false) {
7
+ return [];
8
+ }
9
+ let hits = [];
10
+ try {
11
+ if (env.platform === 'darwin' && (await env.which('mdfind'))) {
12
+ const query = globs
13
+ .map((glob) => `kMDItemFSName == "${glob.replaceAll('"', '\\"')}"`)
14
+ .join(' || ');
15
+ for (const root of roots) {
16
+ const result = await env.run(['mdfind', '-0', '-onlyin', root, query]);
17
+ if (result.code === 0) {
18
+ hits.push(...result.stdout.split('\0').filter(Boolean));
19
+ }
20
+ }
21
+ } else if (env.platform === 'linux') {
22
+ const command = (await env.which('plocate'))
23
+ ? 'plocate'
24
+ : (await env.which('locate'))
25
+ ? 'locate'
26
+ : null;
27
+ if (command) {
28
+ const result = await env.run([command, '-0', '-e', '-b', ...globs]);
29
+ if (result.code === 0) {
30
+ hits = result.stdout.split('\0').filter(Boolean);
31
+ }
32
+ }
33
+ }
34
+ } catch {
35
+ // Budget/cancellation will be observed by the fallback walk too.
36
+ }
37
+ hits = [...new Set(hits)]
38
+ .filter((target) => {
39
+ const root = roots.find((candidate) =>
40
+ isWithin(target, candidate, env.path)
41
+ );
42
+ if (
43
+ !root ||
44
+ !env.path.isAbsolute(target) ||
45
+ !globs.some((glob) => matchesGlob(env.path.basename(target), glob))
46
+ ) {
47
+ return false;
48
+ }
49
+ const parts = env.path.relative(root, target).split(/[\\/]/);
50
+ return (
51
+ parts.length <= maxDepth &&
52
+ !parts
53
+ .slice(0, -1)
54
+ .some(
55
+ (name, index) =>
56
+ env.skipDiscoveryDirectory(
57
+ env.path.join(root, ...parts.slice(0, index)),
58
+ name
59
+ ) || globs.some((glob) => matchesGlob(name, glob))
60
+ )
61
+ );
62
+ })
63
+ .slice(0, 20000);
64
+ const candidates = await mapLimit(hits, 8, async (target) => {
65
+ const stat = await env.stat(target);
66
+ const root = roots.find((candidate) =>
67
+ isWithin(target, candidate, env.path)
68
+ );
69
+ if (stat?.type !== 'dir' || stat.device !== (await env.deviceId(root))) {
70
+ return null;
71
+ }
72
+ let parent = env.path.dirname(target);
73
+ while (parent !== root && isWithin(parent, root, env.path)) {
74
+ const ancestor = await env.stat(parent);
75
+ if (ancestor?.type !== 'dir' || ancestor.device !== stat.device) {
76
+ return null;
77
+ }
78
+ parent = env.path.dirname(parent);
79
+ }
80
+ return {
81
+ path: target,
82
+ name: env.path.basename(target),
83
+ parent: env.path.dirname(target),
84
+ };
85
+ });
86
+ return candidates.filter(Boolean);
87
+ }
package/src/env/shell.js CHANGED
@@ -405,7 +405,16 @@ export class ShellEnv {
405
405
  }
406
406
 
407
407
  findFiles(roots, { names, maxDepth = 6, skipNames } = {}) {
408
- return this.#find(roots, 'f', names, maxDepth, skipNames);
408
+ return this.#find(roots, 'f', names, maxDepth, [
409
+ ...new Set([
410
+ ...(skipNames ?? this.skipNames),
411
+ 'node_modules',
412
+ 'target',
413
+ 'vendor',
414
+ '.venv',
415
+ 'venv',
416
+ ]),
417
+ ]);
409
418
  }
410
419
 
411
420
  /**
@@ -0,0 +1,39 @@
1
+ /** Clean completed safe host findings while discovery continues. */
2
+ import { clean } from './clean.js';
3
+ import { scan } from './scan.js';
4
+
5
+ export async function cleanDuringScan(options) {
6
+ const entries = [];
7
+ const health = [];
8
+ let failure;
9
+ const report = await scan({
10
+ ...options,
11
+ onScanPhase: async (phase) => {
12
+ try {
13
+ const audit = await clean(phase, {
14
+ ...options,
15
+ tier: 'safe',
16
+ priorEntries: entries,
17
+ priorHealth: health,
18
+ });
19
+ entries.splice(0, entries.length, ...audit.entries);
20
+ health.splice(0, health.length, ...audit.health);
21
+ } catch (error) {
22
+ failure = error;
23
+ return false;
24
+ }
25
+ },
26
+ });
27
+ if (failure) {
28
+ throw failure;
29
+ }
30
+ if (report.aborted) {
31
+ return { report, audit: null };
32
+ }
33
+ const audit = await clean(report, {
34
+ ...options,
35
+ priorEntries: entries,
36
+ priorHealth: health,
37
+ });
38
+ return { report, audit };
39
+ }