disk-space-saviour 0.22.0 → 0.23.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,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.23.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Recursive container scans honor requested roots: `--container-root PATH` (`containerRoots`) sets project roots inside every scanned container at any Docker depth, and host roots that exist inside a container join its defaults. The same roots reach the traversal policy and project discovery. Every environment now reports its effective `scope` (roots with source and status, max depth, exclusions, pruned mounts) and the text report names requested roots that were absent, inaccessible, pruned, excluded or not searched.
8
+
9
+ Hard-link aware sizes: items report physically reclaimable bytes, with `allocatedBytes` and `sharedBytes` when links outside the item keep blocks allocated. Each inode is counted once across a selected deletion set, and the audit records `sharedBytes` and marks link-aware estimated `freedBytes`.
10
+
11
+ Add explicit macOS app bundle removal through native Trash with process checks,
12
+ dry runs and audits. Report possible app leftovers separately from user data,
13
+ without automatically deleting either category.
14
+
15
+ ## 0.22.1
16
+
17
+ ### Patch Changes
18
+
19
+ - 9479acd: Keep a `node_modules` folder that holds data no package manager installs, naming each such entry and its size, at scan time and right before deletion.
20
+
21
+ Let `clean` and `emergency` delete on a full disk: an audit log that does not fit is reported as not written and written as soon as deletions free space.
22
+
23
+ Keep everything inside a live Claude Code session scratchpad until the session and its transcript stay idle for the inactive window, at scan time and right before deletion.
24
+
25
+ Show the dss run among swap holders as `(this dss run)` and never name it as a process to quit.
26
+
27
+ Name what fills a directory of many small entries in `dss usage` by name pattern, with their count and total size.
28
+
29
+ Offer rotated app logs in `~/Library/Logs`, Crashpad reports left unsent for 30 days and the GitHub CLI run-log cache in the moderate tier.
30
+
3
31
  ## 0.22.0
4
32
 
5
33
  ### Minor Changes
package/README.md CHANGED
@@ -94,12 +94,19 @@ Run `dss --help` for every option. The most useful ones:
94
94
  files, and for each directory what the latest dss scan or clean found
95
95
  inside it (`[dss: 1.2 GiB freeable, 3.4 GiB blocked]`). On macOS it adds
96
96
  the APFS volumes a walk cannot see (system, VM swap, Preboot, Recovery),
97
- local snapshots and the part of the Data volume left unread. It needs
98
- `du`, so it runs on macOS and Linux; a full walk takes minutes.
97
+ local snapshots and the part of the Data volume left unread. A directory
98
+ filled by many small entries names what fills it by pattern
99
+ (`T/ms-* (68523 entries, each under 64 MiB)`). It needs `du`, so it runs
100
+ on macOS and Linux; a full walk takes minutes.
99
101
  - APFS clones that share every block with an installed app, such as Chrome's
100
102
  code-sign clones, free nothing when deleted, so they are never offered. The
101
103
  scan lists them under `SHARED WITH INSTALLED APPS` and `dss usage` marks
102
104
  them, because `du` and Finder count them at full size.
105
+ - `node_modules` counts as regenerable only while it holds what package
106
+ managers install: packages, `@scope` folders, `.bin`, lockfile copies and
107
+ tool caches such as `.cache` or `.vite`. Any other data kept there
108
+ (verification runs, captured evidence) blocks the whole directory, and the
109
+ check runs again right before deleting.
103
110
  - Git worktree folders (`.worktrees`, `worktrees`) restart the project search
104
111
  depth, so dependencies of nested worktrees are found below `--max-depth`.
105
112
  - `clean` automatically reuses a matching scan from the last 30 minutes.
@@ -139,7 +146,9 @@ their blocks with the installed app), Chromium on-device AI models
139
146
  Electron and electron-builder archives, the Bun transpiler cache, Docker
140
147
  Desktop and Warp staged updates (kept while the app runs), Zed language
141
148
  servers, tree-sitter toolchains, Codex plugin staging, the Clang module cache
142
- and processed Crashpad reports. Telegram pure caches, Safari/WebKit,
149
+ Crashpad reports (processed ones, and unsent ones after 30 days), rotated app
150
+ logs in `~/Library/Logs` (`warp.log.old.2`) and the GitHub CLI run-log cache.
151
+ Telegram pure caches, Safari/WebKit,
143
152
  Chromium code/GPU/Dawn/service-worker caches and marker-validated Electron
144
153
  caches are included. Old Playwright browser revisions are kept while a
145
154
  Playwright package recorded in the registry's `.links` still launches them or
@@ -148,6 +157,10 @@ directories are removed once untouched. Telegram caches are removed only
148
157
  while Telegram is not running. Telegram databases, browser profiles and
149
158
  Playwright MCP profiles are preserved. macOS access failures explain when Full Disk Access
150
159
  is needed. Agent scratch cleanup requires inactivity and Git safety checks.
160
+ Anything inside a Claude Code session scratchpad
161
+ (`{TMP}/claude-<uid>/<project>/<session>`) is kept until the session and its
162
+ transcript in `~/.claude/projects` stay untouched for the inactive window
163
+ (30 days), checked again right before deletion.
151
164
 
152
165
  ## Safety
153
166
 
@@ -233,7 +246,10 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
233
246
  include every candidate and its blockers; blocked candidates do not
234
247
  contribute to reclaimable tier totals.
235
248
  If a read-only scan cannot save its audit, it warns and still prints the
236
- report. Root scans with another user's `HOME` use a private temporary audit
249
+ report. When the disk is full, `clean` and `emergency` delete anyway and
250
+ write their audit as soon as deletions free room; if it never fits, the
251
+ summary says `Audit log: not written (ENOSPC ...)`. Other audit failures,
252
+ such as an unusable `--audit-dir`, still stop them before deleting. Root scans with another user's `HOME` use a private temporary audit
237
253
  directory. Set `--audit-dir` to choose a persistent writable location.
238
254
 
239
255
  Long-lived `npm exec`/`npx` servers allow idle npm download and node-gyp
@@ -290,7 +306,8 @@ Each scan records swap usage and its largest holders in the state directory, so
290
306
  the next scan shows how much both grew during the last week. A `WARNING:` line
291
307
  appears when swap grew by 1 GiB or more, is at least 75% full, or exceeds the free
292
308
  disk space it can grow into, naming the processes holding the memory and their
293
- growth. Long-running AI agent sessions keep their history in memory: quitting and
309
+ growth. The dss run itself is listed as `(this dss run)` and never named as a
310
+ process to quit. Long-running AI agent sessions keep their history in memory: quitting and
294
311
  resuming them (`codex resume <session-id>`, `claude --resume`) releases it. When
295
312
  Docker Desktop consumes memory, saving work and restarting it or lowering its VM
296
313
  memory limit can help. dss never deletes swap files.
@@ -394,6 +411,41 @@ is scanned as a new environment, down to `--depth` levels (default 3). The
394
411
  CI job `Docker-in-Docker Integration` builds a host → l1 → l2 chain and runs
395
412
  `tests/integration/dind.mjs` against it (`npm run test:dind`).
396
413
 
414
+ ### Scan roots
415
+
416
+ Project discovery walks a list of roots per environment; the traversal
417
+ policy (which mount points are crossed) uses the same requested roots, so
418
+ both always agree:
419
+
420
+ - host: the positional `paths` (`roots` in the API), otherwise the homes and
421
+ temporary directories;
422
+ - every scanned container, at every Docker depth: `--container-root PATH`
423
+ (`containerRoots`, repeatable) replaces the defaults; without it, the
424
+ container's homes, temporary directories, existing common workspace paths
425
+ (`/workspace`, `/workspaces`, `/app`, `/src`, `/code`, `/project`,
426
+ `/usr/src/app`) and every host root that also exists inside the container.
427
+ Host and container paths are different namespaces, so no path is remapped.
428
+
429
+ Each JSON environment carries `scope`: its roots with `source` (`roots`,
430
+ `container-roots`, `home`, `tmp`, `container-default`) and `status`
431
+ (`searched`, `partial`, `absent`, `inaccessible`, `pruned`, `excluded`,
432
+ `not-searched`), plus `maxDepth`, `exclude` and `prunedMounts`. The text
433
+ report names requested roots that were not searched. `scanStatus: complete`
434
+ means complete within that scope; exclusions, mount boundaries and liveness
435
+ checks still apply.
436
+
437
+ ### Hard links
438
+
439
+ Sizes are physical reclaimable bytes. A file's blocks count toward an item
440
+ only when every hard link to it lies inside the deletion set: a package
441
+ cache or `node_modules` hard-linked to a shared store (pnpm, Nix, uv) reports
442
+ those blocks as `sharedBytes`, next to `allocatedBytes` (what `du` prints).
443
+ Each `dev:inode` is counted once across the whole selection, so files linked
444
+ between two selected paths are reclaimable once. In the audit log,
445
+ `sharedBytes` lists blocks that stayed allocated; `freedBytes` is a measured
446
+ before/after value for Docker writable layers and otherwise the link-aware
447
+ estimate, marked with `freedEstimated` and `freedAccounting`.
448
+
397
449
  ## Emergency mode
398
450
 
399
451
  `dss emergency --free 20G` (at least 20 GiB available) or
@@ -759,3 +811,33 @@ Quick steps:
759
811
  ## License
760
812
 
761
813
  [Unlicense](LICENSE) - Public Domain
814
+
815
+ ### Explicit macOS app removal and leftovers
816
+
817
+ `dss apps remove "/Users/me/Applications/Example.app"` previews removal;
818
+ `--yes` moves only that bundle to the macOS Trash. It never empties Trash or
819
+ removes support data. App identity and process activity are checked before
820
+ removal; symlinked paths, nested app components and App Store receipts are
821
+ refused. Use a vendor uninstaller first when one exists.
822
+
823
+ Chrome web apps should first be uninstalled using `chrome://apps`.
824
+ `--launcher-only` explicitly allows moving their launcher bundles to Trash;
825
+ it preserves Chrome's app registration, cookies, profiles and website data,
826
+ so Chrome may recreate the launcher. The command is separate from automatic
827
+ cache cleanup. Removed URL handlers may be recreated by their owning tool.
828
+
829
+ `dss apps leftovers` reports possible leftover Library files, including logs.
830
+ An absent bundle in standard application folders is only a clue: apps can live
831
+ elsewhere, and helpers or other apps can share data. Caches and logs are marked
832
+ `disposable-after-activity-check`; settings, application support, containers,
833
+ and saved state require a user decision. Every candidate remains read-only,
834
+ including disposable candidates. No leftover is safe to delete unconditionally;
835
+ logs may be needed for investigation. Inventory errors appear in the report.
836
+
837
+ ### Platform-specific competitor comparison
838
+
839
+ See the [feature comparison tables](docs/research/platform-feature-comparison.md)
840
+ for dss and 30 competitors across Linux, macOS, FreeBSD, OpenBSD, NetBSD,
841
+ illumos and AIX. Capability, interface and safety-backend tables distinguish
842
+ implemented features, vendor documentation, partial coverage and unknowns.
843
+ The comparison is a sourced inventory, not a performance or superiority claim.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "disk-space-saviour",
3
- "version": "0.22.0",
3
+ "version": "0.23.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/audit.js CHANGED
@@ -101,7 +101,38 @@ export async function writeAudit(audit, options = {}) {
101
101
  dir,
102
102
  `dss-${audit.command}-${stamp}-${audit.pid}.json`
103
103
  );
104
- await fsp.writeFile(file, `${JSON.stringify(audit, null, 2)}\n`);
104
+ // A write that fails half way (a full disk) leaves the previous record.
105
+ const partial = `${file}.${process.pid}.tmp`;
106
+ try {
107
+ await fsp.writeFile(partial, `${JSON.stringify(audit, null, 2)}\n`);
108
+ await fsp.rename(partial, file);
109
+ } catch (error) {
110
+ await fsp.rm(partial, { force: true }).catch(() => {});
111
+ throw error;
112
+ }
105
113
  audit.file = file;
106
114
  return file;
107
115
  }
116
+
117
+ /** Errors meaning the disk is full, which is what cleaning fixes. */
118
+ const NO_SPACE = new Set(['ENOSPC', 'EDQUOT']);
119
+
120
+ /**
121
+ * Writes the record of a run that deletes. A full disk does not stop the
122
+ * run: the failure is kept in `audit.auditError`, and the next write, after
123
+ * deletions freed space, tries again.
124
+ * @returns {Promise<string|null>} the file, or null when the disk is full
125
+ */
126
+ export async function recordAudit(audit, options = {}) {
127
+ try {
128
+ const file = await writeAudit(audit, options);
129
+ delete audit.auditError;
130
+ return file;
131
+ } catch (error) {
132
+ if (!NO_SPACE.has(error.code)) {
133
+ throw error;
134
+ }
135
+ audit.auditError = { message: error.message, code: error.code };
136
+ return null;
137
+ }
138
+ }
package/src/clean.js CHANGED
@@ -12,12 +12,13 @@
12
12
  * log.
13
13
  */
14
14
 
15
+ import { selectionBytes } from './env/hardlinks.js';
15
16
  import { once } from 'node:events';
16
17
  import { createWriteStream, promises as fsp } from 'node:fs';
17
18
  import path from 'node:path';
18
19
  import { finished } from 'node:stream/promises';
19
20
 
20
- import { backupDirectory, startAudit, writeAudit } from './audit.js';
21
+ import { backupDirectory, recordAudit, startAudit } from './audit.js';
21
22
  import { PhaseTimer } from './timings.js';
22
23
  import { configureContainerHost } from './docker/writable.js';
23
24
  import { DockerCli, assertAllowed, parseDockerSize } from './docker/cli.js';
@@ -45,6 +46,7 @@ import { isWithin } from './paths.js';
45
46
  import { OTHER_RULES } from './rules/other.js';
46
47
  import { compareVersions, VERSION_RULES } from './rules/versions.js';
47
48
  import { scanVersions } from './scanners/versions.js';
49
+ import { foreignDataBlocker } from './scanners/node-modules.js';
48
50
  import { analyzeRustProfile } from './scanners/rust.js';
49
51
  import {
50
52
  filterItems,
@@ -78,6 +80,7 @@ export function cleanOrder(a, b) {
78
80
  );
79
81
  }
80
82
 
83
+ /** Plain per-path sum: used for before/after measurements of one set. */
81
84
  function sumBytes(usages, paths = [...usages.keys()]) {
82
85
  let total = 0;
83
86
  for (const target of paths) {
@@ -192,6 +195,7 @@ export class Cleaner {
192
195
  git: new GitInspector(env),
193
196
  liveness: new LivenessProbe(env, {
194
197
  staleAgeMs: this.options.staleAgeMs,
198
+ inactiveMs: this.options.inactiveMs,
195
199
  now: this.options.now,
196
200
  }),
197
201
  });
@@ -332,7 +336,7 @@ export class Cleaner {
332
336
  const fresh = {
333
337
  ...item,
334
338
  paths,
335
- bytes: sumBytes(usages, paths),
339
+ bytes: selectionBytes(usages, paths).bytes,
336
340
  newestMtimeMs: newest || item.newestMtimeMs,
337
341
  action: type === 'remove' ? { ...item.action, paths } : item.action,
338
342
  };
@@ -447,6 +451,12 @@ export class Cleaner {
447
451
  if (mount) {
448
452
  return mount;
449
453
  }
454
+ if (item.rule === 'node-modules' && type === 'remove') {
455
+ const foreign = await foreignDataBlocker(ctx.env, item.path);
456
+ if (foreign) {
457
+ return foreign;
458
+ }
459
+ }
450
460
  return this.#gitBlocker(ctx, item);
451
461
  }
452
462
 
@@ -551,7 +561,12 @@ export class Cleaner {
551
561
  entry.reason = boundary;
552
562
  return;
553
563
  }
554
- await this.#deletePaths(ctx, entry, [target], usage?.bytes ?? 0);
564
+ await this.#deletePaths(
565
+ ctx,
566
+ entry,
567
+ [target],
568
+ selectionBytes(new Map([[target, usage]]), [target])
569
+ );
555
570
  }
556
571
  if (entry.deletedPaths.length === 0 && entry.skippedPaths.length > 0) {
557
572
  entry.reason = 'every path was in use or recently written';
@@ -591,7 +606,12 @@ export class Cleaner {
591
606
  if (!selected.length) {
592
607
  return true;
593
608
  }
594
- await this.#deletePaths(ctx, entry, selected, sumBytes(usages, selected));
609
+ await this.#deletePaths(
610
+ ctx,
611
+ entry,
612
+ selected,
613
+ selectionBytes(usages, selected)
614
+ );
595
615
  const lost = await this.health.check(ctx.env, item);
596
616
  if (lost) {
597
617
  entry.reason = lost;
@@ -630,7 +650,12 @@ export class Cleaner {
630
650
  return usage.bytes === 0 ? 'no writable bytes remain' : null;
631
651
  }
632
652
 
633
- async #deletePaths(ctx, entry, selected, estimate) {
653
+ /** `accounted`: hard-link aware {bytes, sharedBytes} of `selected`. */
654
+ async #deletePaths(ctx, entry, selected, accounted) {
655
+ const estimate = accounted.bytes;
656
+ if (accounted.sharedBytes > 0) {
657
+ entry.sharedBytes = (entry.sharedBytes ?? 0) + accounted.sharedBytes;
658
+ }
634
659
  const before = await ctx.env.writableLayer?.allocatedBytes?.(
635
660
  selected,
636
661
  ctx.env.path
@@ -653,9 +678,10 @@ export class Cleaner {
653
678
  if (typeof before === 'number' && typeof after === 'number') {
654
679
  return Math.max(0, before - after);
655
680
  }
656
- if (ctx.env.writableLayer) {
657
- entry.freedEstimated = true;
658
- }
681
+ // Without a block measurement the hard-link aware selection estimate
682
+ // is reported, and marked as an estimate.
683
+ entry.freedEstimated = true;
684
+ entry.freedAccounting = 'hardlink-aware-estimate';
659
685
  return fallback;
660
686
  }
661
687
 
@@ -959,7 +985,7 @@ export async function clean(report, input = {}) {
959
985
  }
960
986
  if (options.audit !== false) {
961
987
  await timings.measure('audit-write', () =>
962
- writeAudit(audit, { ...options, inProgress: true })
988
+ recordAudit(audit, { ...options, inProgress: true })
963
989
  );
964
990
  }
965
991
  };
@@ -971,7 +997,7 @@ export async function clean(report, input = {}) {
971
997
  audit.health = cleaner.health.records;
972
998
  audit.timings = timings;
973
999
  if (options.audit !== false) {
974
- await writeAudit(audit, { ...options, inProgress: true });
1000
+ await recordAudit(audit, { ...options, inProgress: true });
975
1001
  }
976
1002
  try {
977
1003
  const done = new Set(audit.entries.map((entry) => entry.id));
@@ -988,7 +1014,7 @@ export async function clean(report, input = {}) {
988
1014
  options.onEntry?.(entry, item);
989
1015
  if (options.audit !== false) {
990
1016
  await timings.measure('audit-write', () =>
991
- writeAudit(audit, { ...options, inProgress: true })
1017
+ recordAudit(audit, { ...options, inProgress: true })
992
1018
  );
993
1019
  }
994
1020
  }
@@ -1025,7 +1051,7 @@ export async function finishAudit(audit, report, options) {
1025
1051
  )
1026
1052
  .map((env) => ({ label: env.label, status: env.scanStatus }));
1027
1053
  if (options.audit !== false) {
1028
- await writeAudit(audit, options);
1054
+ await recordAudit(audit, options);
1029
1055
  }
1030
1056
  return audit;
1031
1057
  }
package/src/cli.js CHANGED
@@ -13,6 +13,7 @@ import { parseArgs } from 'node:util';
13
13
 
14
14
  import { startAudit, writeAudit } from './audit.js';
15
15
  import { clean } from './clean.js';
16
+ import { runApps } from './mac-apps.js';
16
17
  import { exitedBlockerPids, pidAlive } from './conditions.js';
17
18
  import { emergency } from './emergency.js';
18
19
  import { setTrace } from './exec.js';
@@ -34,6 +35,8 @@ Commands:
34
35
  largest directories and files, what dss can
35
36
  free inside them, APFS volumes (never deletes)
36
37
  docker scan|clean Only Docker: daemons, containers, nested daemons
38
+ apps remove APP.app Move explicitly selected macOS apps to Trash
39
+ apps leftovers Report possible app leftovers (read-only)
37
40
  help, version
38
41
 
39
42
  Scope:
@@ -43,6 +46,9 @@ Scope:
43
46
  --depth N Docker nesting depth to recurse into (default 3)
44
47
  --recursive docker: recurse into nested daemons
45
48
  --container ID|NAME limit Docker work to these containers (repeat)
49
+ --container-root PATH project root inside every scanned container,
50
+ at any Docker depth; replaces the container
51
+ defaults (repeat)
46
52
  --max-depth N directory depth for project search (default 6)
47
53
  --scan-budget AGE time budget per filesystem environment (2m)
48
54
  --only NAME|ID|PATH only these ecosystems, rules, kinds, item ids,
@@ -91,6 +97,7 @@ const OPTIONS = {
91
97
  depth: { type: 'string' },
92
98
  recursive: { type: 'boolean' },
93
99
  container: { type: 'string', multiple: true },
100
+ 'container-root': { type: 'string', multiple: true },
94
101
  'max-depth': { type: 'string' },
95
102
  'scan-budget': { type: 'string' },
96
103
  only: { type: 'string', multiple: true },
@@ -170,6 +177,7 @@ function tierOf(values) {
170
177
  export function toOptions(values, paths = []) {
171
178
  return {
172
179
  roots: paths.length > 0 ? paths : null,
180
+ containerRoots: values['container-root'],
173
181
  docker: dockerMode(values),
174
182
  dockerDepth: integer(values.depth, '--depth'),
175
183
  containers: values.container,
@@ -539,6 +547,9 @@ function dispatch(cli, command, options, values) {
539
547
  export async function runCli(argv, deps = {}) {
540
548
  const io = deps.io ?? processIo();
541
549
  try {
550
+ if (argv[0] === 'apps') {
551
+ return await runApps(argv.slice(1), io, deps.signal);
552
+ }
542
553
  const { command, paths, values } = parseCli(argv);
543
554
  if (command === 'help') {
544
555
  io.stdout(USAGE);
package/src/conditions.js CHANGED
@@ -47,6 +47,13 @@ const CONDITIONS = [
47
47
  action: `they stay untouched for the ${m[1]} activity window`,
48
48
  }),
49
49
  ],
50
+ [
51
+ /^(?:busy: )?inside the scratchpad of Claude Code session (\S+) .*; kept until it is idle for (.+)$/,
52
+ (m) => ({
53
+ key: `session:${m[1]}`,
54
+ action: `the Claude Code session ${m[1]} stays idle for ${m[2]}`,
55
+ }),
56
+ ],
50
57
  [
51
58
  /^scan incomplete: (.+)$/,
52
59
  (m) => ({
@@ -632,6 +632,7 @@ class DockerScan {
632
632
  this.result.items.push(...(await this.scanEnvironment(env)));
633
633
  Object.assign(descriptor, {
634
634
  scanStatus: env.scanStatus,
635
+ scope: env.scanScope ?? null,
635
636
  hint: env.probeHint ?? null,
636
637
  layerSource: env.writableLayer?.source ?? null,
637
638
  timings: env.timings?.toJSON() ?? [],
@@ -213,9 +213,15 @@ export class WritableLayer {
213
213
  )
214
214
  )
215
215
  : 0;
216
+ // Upper-layer blocks are measured directly: merged-view link data
217
+ // does not describe them, so the writable bytes are final.
218
+ const plain = { ...usage };
219
+ delete plain.links;
220
+ delete plain.sharedBytes;
216
221
  result.set(target, {
217
- ...usage,
222
+ ...plain,
218
223
  bytes,
224
+ allocatedBytes: bytes,
219
225
  totalBytes: usage.bytes,
220
226
  imageBytes: known ? Math.max(0, usage.bytes - bytes) : 0,
221
227
  sizeUnknown: !known,
package/src/emergency.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * kept for investigation needs its own `removeContainers` entry.
13
13
  */
14
14
 
15
- import { startAudit, writeAudit, throwIfAborted } from './audit.js';
15
+ import { recordAudit, startAudit, throwIfAborted } from './audit.js';
16
16
  import { Cleaner, cleanOrder, finishAudit, wantedItems } from './clean.js';
17
17
  import { LocalEnv } from './env/local.js';
18
18
  import { TIERS, selectByTier, tierRank } from './items.js';
@@ -120,7 +120,7 @@ export async function emergency(input = {}) {
120
120
  });
121
121
  audit.report = report;
122
122
  if (options.audit !== false) {
123
- await writeAudit(audit, { ...options, inProgress: true });
123
+ await recordAudit(audit, { ...options, inProgress: true });
124
124
  }
125
125
  let disk = audit.diskAfter ?? diskBefore;
126
126
  if (report.aborted) {
@@ -145,7 +145,7 @@ async function escalate({ report, options, env, target, goal, audit }) {
145
145
  audit.entries.push(entry);
146
146
  }
147
147
  if (options.audit !== false) {
148
- await writeAudit(audit, { ...options, inProgress: true });
148
+ await recordAudit(audit, { ...options, inProgress: true });
149
149
  }
150
150
  };
151
151
  const cleaner = new Cleaner(report, {
@@ -195,7 +195,7 @@ async function escalate({ report, options, env, target, goal, audit }) {
195
195
  }
196
196
  options.onEntry?.(entry, item);
197
197
  if (options.audit !== false) {
198
- await writeAudit(audit, { ...options, inProgress: true });
198
+ await recordAudit(audit, { ...options, inProgress: true });
199
199
  }
200
200
  if (entry.status !== 'removed' && entry.status !== 'planned') {
201
201
  continue;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Hard-link aware accounting of reclaimable blocks.
3
+ *
4
+ * `du` (and every per-path walk) counts each inode once per measured path,
5
+ * including inodes that also have links outside that path. Deleting such a
6
+ * path removes a directory entry but releases no file blocks while another
7
+ * link remains. A file's blocks are reclaimable only when every one of its
8
+ * links is inside the selected deletion set; several links that are all
9
+ * inside the set are reclaimable once.
10
+ *
11
+ * Usage objects carry:
12
+ * - `allocatedBytes`: blocks reachable from the path (what `du` prints);
13
+ * - `sharedBytes`: blocks of multi-link files with a link outside the path;
14
+ * - `bytes`: `allocatedBytes - sharedBytes`, the physically reclaimable part;
15
+ * - `links`: the multi-link non-directory entries below the path
16
+ * (`{path, id, nlink, bytes}`), used to account a whole selection.
17
+ */
18
+
19
+ /** POSIX shell fragment listing multi-link files below "$t" (GNU/busybox or BSD stat). */
20
+ export const LINKS_SCRIPT = `if stat -c %h / >/dev/null 2>&1; then
21
+ find "$t" -xdev ! -type d -links +1 -exec stat -c '%d:%i %h %b %B %n' -- {} + 2>/dev/null
22
+ else
23
+ find "$t" -xdev ! -type d -links +1 -exec stat -f '%d:%i %l %b 512 %N' {} + 2>/dev/null
24
+ fi`;
25
+
26
+ /** Parses LINKS_SCRIPT output lines (`dev:ino nlink blocks blocksize path`). */
27
+ export function parseLinks(text) {
28
+ const links = [];
29
+ for (const line of (text ?? '').split('\n')) {
30
+ const match = /^(\d+:\d+) (\d+) (\d+) (\d+) (.+)$/.exec(line);
31
+ if (match) {
32
+ links.push({
33
+ id: match[1],
34
+ nlink: Number(match[2]),
35
+ bytes: Number(match[3]) * Number(match[4]),
36
+ path: match[5],
37
+ });
38
+ }
39
+ }
40
+ return links;
41
+ }
42
+
43
+ function groupLinks(groups) {
44
+ const inodes = new Map();
45
+ for (const [target, links] of groups) {
46
+ for (const link of links ?? []) {
47
+ if (!inodes.has(link.id)) {
48
+ inodes.set(link.id, {
49
+ nlink: link.nlink,
50
+ bytes: link.bytes,
51
+ paths: new Set(),
52
+ targets: new Set(),
53
+ });
54
+ }
55
+ const inode = inodes.get(link.id);
56
+ inode.nlink = Math.max(inode.nlink, link.nlink);
57
+ inode.paths.add(link.path);
58
+ inode.targets.add(target);
59
+ }
60
+ }
61
+ return inodes;
62
+ }
63
+
64
+ /**
65
+ * Accounts a deletion set measured per path.
66
+ * @param {Array<[string, {allocated: number, links?: object[]}]>} measured
67
+ * one entry per selected path: its per-path allocated bytes (each inode
68
+ * counted once within that path) and its multi-link entries
69
+ * @returns {{allocatedBytes: number, sharedBytes: number, bytes: number}}
70
+ * blocks reachable from the set (each inode once), blocks retained by
71
+ * links outside the set, and the physically reclaimable remainder
72
+ */
73
+ export function accountLinks(measured) {
74
+ let allocated = measured.reduce((sum, [, entry]) => sum + entry.allocated, 0);
75
+ let shared = 0;
76
+ const inodes = groupLinks(
77
+ measured.map(([target, entry]) => [target, entry.links])
78
+ );
79
+ for (const inode of inodes.values()) {
80
+ // Per-path walks counted this inode once in every path containing it.
81
+ allocated -= (inode.targets.size - 1) * inode.bytes;
82
+ if (inode.paths.size < inode.nlink) {
83
+ shared += inode.bytes;
84
+ }
85
+ }
86
+ allocated = Math.max(0, allocated);
87
+ shared = Math.min(shared, allocated);
88
+ return {
89
+ allocatedBytes: allocated,
90
+ sharedBytes: shared,
91
+ bytes: allocated - shared,
92
+ };
93
+ }
94
+
95
+ /** Adds allocated/shared/reclaimable fields to one path's usage. */
96
+ export function withLinkAccounting(usage, links) {
97
+ if (!usage) {
98
+ return usage;
99
+ }
100
+ const allocated = usage.allocatedBytes ?? usage.bytes;
101
+ const accounted = accountLinks([['', { allocated, links }]]);
102
+ return {
103
+ ...usage,
104
+ bytes: accounted.bytes,
105
+ allocatedBytes: accounted.allocatedBytes,
106
+ sharedBytes: accounted.sharedBytes,
107
+ ...(links?.length ? { links } : {}),
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Reclaimable bytes of several selected paths of one environment, from
113
+ * their usages. Links between selected paths are reclaimable; links
114
+ * outside the selection are not.
115
+ */
116
+ export function selectionBytes(usages, selected) {
117
+ const measured = selected
118
+ .map((target) => [target, usages.get(target)])
119
+ .filter(([, usage]) => usage)
120
+ .map(([target, usage]) => [
121
+ target,
122
+ {
123
+ allocated: usage.allocatedBytes ?? usage.bytes,
124
+ links: usage.links,
125
+ },
126
+ ]);
127
+ return accountLinks(measured);
128
+ }
129
+
130
+ /**
131
+ * Item size fields of the usages of one item's paths: `bytes` is the
132
+ * reclaimable total; `allocatedBytes`/`sharedBytes` are added only when
133
+ * hard links outside the item retain blocks.
134
+ */
135
+ export function itemSize(entries) {
136
+ const usages = new Map(entries.filter(([, usage]) => usage));
137
+ const accounted = selectionBytes(usages, [...usages.keys()]);
138
+ return accounted.sharedBytes > 0 ? accounted : { bytes: accounted.bytes };
139
+ }