disk-space-saviour 0.20.0 → 0.21.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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Keep a Chromium code-sign clone while any process runs its hard-linked main executable, under the installed path or the clone's, at scan time and right before deletion; a clone that still shares its blocks with the installed app reports no reclaimable space.
8
+
9
+ Warn when swap grows, nearly fills, or outgrows the free disk space, naming the processes holding the memory and how much each grew since earlier scans (with Linux `VmSwap` support), and offer Git clones in temporary directories as safe once every worktree is clean and `git ls-remote` shows the remote still holds every local commit.
10
+
11
+ Keep a Playwright browser revision while an installed Playwright package recorded in the registry's `.links` still launches it (rechecked right before deletion), offer Playwright installs and downloads left in temporary directories, offer unused nvm Node versions while naming the global packages they take with them, and limit Telegram downloaded media to the cloud files Telegram downloads again, never its media database or files the user sent.
12
+
3
13
  ## 0.20.0
4
14
 
5
15
  ### Minor Changes
package/README.md CHANGED
@@ -97,11 +97,11 @@ ranked by how recently it was used: a cache, build output or dependency
97
97
  directory is `safe` and is held back only while something uses it (see
98
98
  [Safety](#safety)).
99
99
 
100
- | Tier | What it removes |
101
- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
102
- | `safe` | Everything regenerable: download, runtime and application caches, idle package-runner entries, old browser revisions, `node_modules`, `vendor`, `target/`, `.venv`, `build/` and similar folders of any project, dangling Docker images and build cache. |
103
- | `moderate` | Data that is disposable but not regenerated by a tool: old agent scratch and Telegram downloaded media (secret-chat media may be irreplaceable), unused toolchain versions (never one holding globally installed packages or virtual environments), tagged images and stopped containers (both require explicit consent). |
104
- | `aggressive` | Accepted by `dss emergency` for compatibility; currently the same as `moderate`. |
100
+ | Tier | What it removes |
101
+ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
102
+ | `safe` | Everything regenerable: download, runtime and application caches, idle package-runner entries, old browser revisions, `node_modules`, `vendor`, `target/`, `.venv`, `build/` and similar folders of any project, dangling Docker images and build cache. |
103
+ | `moderate` | Data that is disposable but not regenerated by a tool: old agent scratch and Telegram downloaded media (secret-chat media may be irreplaceable), unused toolchain versions no process runs from (the item names the global packages it removes; never one holding virtual environments), tagged images and stopped containers (both require explicit consent). |
104
+ | `aggressive` | Accepted by `dss emergency` for compatibility; currently the same as `moderate`. |
105
105
 
106
106
  Covered ecosystems: JavaScript/TypeScript (npm, yarn, pnpm, bun, deno,
107
107
  framework build caches), Python, Rust, JVM (Gradle, Maven, Kotlin, Android),
@@ -115,15 +115,21 @@ LibreWolf, Waterfox, Zen and Floorp, IDE caches (JetBrains, VS Code, Cursor,
115
115
  Windsurf, Qoder), AI agent caches and logs, the Discord cache, macOS app
116
116
  updates staged by Sparkle and Squirrel, package manager archives, trash, crash
117
117
  reports, core dumps and journald logs. Application downloads are covered too:
118
- Chrome bundle clones left by exited browsers, Chromium on-device AI models
118
+ Chrome bundle clones left by exited browsers (kept while any browser runs the
119
+ clone's hard-linked executable, and counted as free when they still share
120
+ their blocks with the installed app), Chromium on-device AI models
119
121
  (downloaded again when a page uses built-in AI), Google Updater installers, ChatGPT Atlas and Codex app caches,
120
122
  Electron and electron-builder archives, the Bun transpiler cache, Docker
121
123
  Desktop and Warp staged updates (kept while the app runs), Zed language
122
124
  servers, tree-sitter toolchains, Codex plugin staging, the Clang module cache
123
125
  and processed Crashpad reports. Telegram pure caches, Safari/WebKit,
124
126
  Chromium code/GPU/Dawn/service-worker caches and marker-validated Electron
125
- caches are included. Telegram databases, browser profiles and Playwright MCP
126
- profiles are preserved. macOS access failures explain when Full Disk Access
127
+ caches are included. Old Playwright browser revisions are kept while a
128
+ Playwright package recorded in the registry's `.links` still launches them or
129
+ a browser runs from them; Playwright installs and downloads left in temporary
130
+ directories are removed once untouched. Telegram caches are removed only
131
+ while Telegram is not running. Telegram databases, browser profiles and
132
+ Playwright MCP profiles are preserved. macOS access failures explain when Full Disk Access
127
133
  is needed. Agent scratch cleanup requires inactivity and Git safety checks.
128
134
 
129
135
  ## Safety
@@ -241,10 +247,27 @@ and sizes. Safe cleanup also rechecks source activity. Explicit moderate cleanup
241
247
  preserves the ability to remove ignored dependencies when only source changed.
242
248
  Library callers opt into state with `cacheDir`.
243
249
 
244
- On macOS, reports include `sysctl vm.swapusage` and the largest resident/compressed
245
- memory consumers from `top`. These are memory diagnostics, not verified per-process
246
- swap ownership. When Docker Desktop consumes memory, saving work and restarting it
247
- or lowering its VM memory limit can help. dss never deletes swap files.
250
+ On macOS, reports include `sysctl vm.swapusage` and the largest memory consumers
251
+ from `top` (memory includes compressed and swapped pages). These are memory
252
+ diagnostics, not verified per-process swap ownership. On Linux, reports include
253
+ `/proc/meminfo` swap and the processes with the most swapped memory (`VmSwap`).
254
+ Each scan records swap usage and its largest holders in the state directory, so
255
+ the next scan shows how much both grew during the last week. A `WARNING:` line
256
+ appears when swap grew by 1 GiB or more, is at least 75% full, or exceeds the free
257
+ disk space it can grow into, naming the processes holding the memory and their
258
+ growth. Long-running AI agent sessions keep their history in memory: quitting and
259
+ resuming them (`codex resume <session-id>`, `claude --resume`) releases it. When
260
+ Docker Desktop consumes memory, saving work and restarting it or lowering its VM
261
+ memory limit can help. dss never deletes swap files.
262
+
263
+ Git clones in temporary directories (`{TMP}/*` and `{TMP}/*/*`) are safe to remove
264
+ once untouched for the activity window when every worktree sharing the repository
265
+ is clean, none lives outside the clone, nothing is stashed, and every commit
266
+ reachable from `HEAD`, a branch or a worktree is advertised by an https or ssh
267
+ remote. That last check runs `git ls-remote` against the remote rather than
268
+ trusting remote-tracking refs, outside any repository, with only the https and
269
+ ssh transports and no prompts. Clones of local paths and clones whose `.git` is a
270
+ symlink are left alone, and cleanup repeats every check right before deleting.
248
271
 
249
272
  Cache discovery honors tool configuration before default paths, including
250
273
  `GOMODCACHE`, `GOPATH`, `GOCACHE`, `CARGO_HOME`, `RUSTUP_HOME`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "disk-space-saviour",
3
- "version": "0.20.0",
3
+ "version": "0.21.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
@@ -39,6 +39,7 @@ import { GitInspector } from './git.js';
39
39
  import { HealthWatch } from './health.js';
40
40
  import { consentFlag, dropNested, selectByTier, tierRank } from './items.js';
41
41
  import { LivenessProbe } from './liveness.js';
42
+ import { playwrightBlocker } from './playwright-registry.js';
42
43
  import { resolveOptions } from './options.js';
43
44
  import { isWithin } from './paths.js';
44
45
  import { OTHER_RULES } from './rules/other.js';
@@ -132,6 +133,11 @@ async function browserRevisionBlocker(env, item) {
132
133
  if (!newer) {
133
134
  return `${target} is now the newest installed browser revision`;
134
135
  }
136
+ const needed =
137
+ rule.registry === 'playwright' && (await playwrightBlocker(env, target));
138
+ if (needed) {
139
+ return needed;
140
+ }
135
141
  }
136
142
  return null;
137
143
  }
@@ -432,6 +438,7 @@ export class Cleaner {
432
438
  async #safetyBlocker(ctx, item, type) {
433
439
  await ctx.liveness.refresh(true);
434
440
  await ctx.liveness.resolve(item.paths);
441
+ await ctx.liveness.resolveExecutables(item.checks?.executables);
435
442
  const busy = ctx.liveness.busyReason(item);
436
443
  if (busy) {
437
444
  return `busy: ${busy}`;
@@ -475,6 +482,11 @@ export class Cleaner {
475
482
  }
476
483
 
477
484
  async #gitBlocker(ctx, item) {
485
+ if (item.recheck?.type === 'clone') {
486
+ ctx.git.invalidate();
487
+ const blockers = await ctx.git.cloneBlockers(item.path);
488
+ return blockers.length > 0 ? blockers.join('; ') : null;
489
+ }
478
490
  if (
479
491
  item.project ||
480
492
  item.rule === 'gh-issue-solver-workdirs' ||
@@ -667,6 +679,7 @@ export class Cleaner {
667
679
  return `stopped before ${target}: ${stopped}`;
668
680
  }
669
681
  await ctx.liveness.resolve([target]);
682
+ await ctx.liveness.resolveExecutables(item.checks?.executables);
670
683
  const busy = ctx.liveness.busyReason({ ...item, paths: [target] });
671
684
  return busy ? `stopped before ${target}: busy: ${busy}` : null;
672
685
  }
package/src/env/local.js CHANGED
@@ -333,6 +333,24 @@ export class LocalEnv {
333
333
  return heads;
334
334
  }
335
335
 
336
+ /** Another user's entry of a sticky shared directory such as /tmp is private by design. */
337
+ async #foreignTempEntry(dir) {
338
+ const io = this.options.fileFs ?? fsp;
339
+ try {
340
+ const [own, parent] = await Promise.all([
341
+ io.lstat(dir),
342
+ io.stat(path.dirname(dir)),
343
+ ]);
344
+ return (
345
+ typeof process.getuid === 'function' &&
346
+ own.uid !== process.getuid() &&
347
+ (parent.mode & 0o1000) !== 0
348
+ );
349
+ } catch {
350
+ return false;
351
+ }
352
+ }
353
+
336
354
  async list(dir, { discovery = false } = {}) {
337
355
  let names;
338
356
  try {
@@ -340,7 +358,9 @@ export class LocalEnv {
340
358
  withFileTypes: true,
341
359
  });
342
360
  } catch (error) {
343
- this.recordAccessError(dir, error);
361
+ if (!(await this.#foreignTempEntry(dir))) {
362
+ this.recordAccessError(dir, error);
363
+ }
344
364
  return [];
345
365
  }
346
366
  const entries = await mapLimit(names, 32, async (entry) => {
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Proof that a repository's commits still exist on a network remote, and
3
+ * the linked worktrees that share its object database.
4
+ *
5
+ * Remote URLs are read from the repository config but never used through
6
+ * it: `git ls-remote` runs outside any repository, with only the https and
7
+ * ssh transports allowed and no prompts, so a planted config cannot run
8
+ * commands or ask for credentials. Only advertised branches, tags and
9
+ * review refs (`refs/pull/*`, `refs/merge-requests/*`) count, and only the
10
+ * advertised commits that exist locally, so "on the remote" never relies
11
+ * on cached remote-tracking refs.
12
+ */
13
+ import { promises as fsp } from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+
17
+ const LS_REMOTE_TIMEOUT_MS = 30 * 1000;
18
+ const ADVERTISED = [
19
+ 'refs/heads/*',
20
+ 'refs/tags/*',
21
+ 'refs/pull/*/head',
22
+ 'refs/merge-requests/*/head',
23
+ ];
24
+
25
+ /**
26
+ * A remote URL `git ls-remote` may contact: https/http or ssh, with a host
27
+ * that cannot be mistaken for an option. Local paths and `file://` remotes
28
+ * are not proof that the code exists elsewhere.
29
+ */
30
+ export function networkRemote(url) {
31
+ const value = url.trim();
32
+ if (/\s/.test(value) || value.startsWith('-')) {
33
+ return null;
34
+ }
35
+ const standard = /^(https?|ssh|git\+ssh):\/\/(?:[^@/]+@)?([^/:]+)/i.exec(
36
+ value
37
+ );
38
+ const scp = /^(?:[\w.-]+@)?([\w.-]+):(?!\/\/)[^\\]/.exec(value);
39
+ const host = standard?.[2] ?? scp?.[1];
40
+ // `C:/repo` is a Windows path, not the host `C`.
41
+ const drive = !standard && /^[a-z]$/i.test(host ?? '');
42
+ return host && !drive && /^[\w.[\]-]+$/.test(host) && !host.startsWith('-')
43
+ ? { url: value, host }
44
+ : null;
45
+ }
46
+
47
+ /** `remote.<name>.url` values from a Git config, as `{name, url}`. */
48
+ export function parseRemoteUrls(text) {
49
+ const remotes = [];
50
+ let remote = null;
51
+ for (const raw of text.split('\n')) {
52
+ const line = raw.trim();
53
+ const section = /^\[\s*remote\s+"([^"]+)"\s*\]$/i.exec(line);
54
+ if (section) {
55
+ remote = section[1];
56
+ continue;
57
+ }
58
+ if (/^\[/.test(line)) {
59
+ remote = null;
60
+ continue;
61
+ }
62
+ const url = /^url\s*=\s*(.+)$/i.exec(line);
63
+ if (remote && url) {
64
+ remotes.push({ name: remote, url: url[1].replace(/^"(.*)"$/, '$1') });
65
+ }
66
+ }
67
+ return remotes;
68
+ }
69
+
70
+ /** Commit IDs from `git ls-remote` output, peeled tags included. */
71
+ export function advertisedCommits(text) {
72
+ return [
73
+ ...new Set(
74
+ text
75
+ .split('\n')
76
+ .map((line) => /^([0-9a-f]{40,64})\t/.exec(line)?.[1])
77
+ .filter(Boolean)
78
+ ),
79
+ ];
80
+ }
81
+
82
+ /** Network remotes of the repository whose common Git directory is given. */
83
+ export async function networkRemotes(commonDir) {
84
+ const text = await fsp
85
+ .readFile(path.join(commonDir, 'config'), 'utf8')
86
+ .catch(() => '');
87
+ return parseRemoteUrls(text)
88
+ .map((remote) => ({ name: remote.name, ...networkRemote(remote.url) }))
89
+ .filter((remote) => remote.host);
90
+ }
91
+
92
+ /**
93
+ * Work trees attached to a common Git directory: the main one (unless the
94
+ * repository is bare) and every linked one that still exists, with the
95
+ * detached HEAD commits of linked worktrees.
96
+ */
97
+ export async function attachedWorktrees(commonDir) {
98
+ const trees = [];
99
+ const detached = [];
100
+ const config = await fsp
101
+ .readFile(path.join(commonDir, 'config'), 'utf8')
102
+ .catch(() => '');
103
+ if (
104
+ path.basename(commonDir) === '.git' &&
105
+ !/^\s*bare\s*=\s*true/m.test(config)
106
+ ) {
107
+ trees.push(path.dirname(commonDir));
108
+ }
109
+ const base = path.join(commonDir, 'worktrees');
110
+ for (const name of await fsp.readdir(base).catch(() => [])) {
111
+ const read = (file) =>
112
+ fsp.readFile(path.join(base, name, file), 'utf8').catch(() => '');
113
+ const gitdir = (await read('gitdir')).trim();
114
+ const head = (await read('HEAD')).trim();
115
+ if (/^[0-9a-f]{40,64}$/.test(head)) {
116
+ detached.push(head);
117
+ }
118
+ if (gitdir) {
119
+ const tree = path.dirname(path.resolve(base, name, gitdir));
120
+ if (await fsp.stat(tree).catch(() => null)) {
121
+ trees.push(tree);
122
+ }
123
+ }
124
+ }
125
+ return { trees, detached };
126
+ }
127
+
128
+ /**
129
+ * Runs `git ls-remote` for `url` outside every repository.
130
+ * @param {(argv: string[], options: object) => Promise<object>} run
131
+ */
132
+ export async function lsRemote(run, url) {
133
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dss-ls-remote-'));
134
+ try {
135
+ return await run(
136
+ [
137
+ 'git',
138
+ '-c',
139
+ 'protocol.allow=never',
140
+ '-c',
141
+ 'protocol.https.allow=always',
142
+ '-c',
143
+ 'protocol.http.allow=always',
144
+ '-c',
145
+ 'protocol.ssh.allow=always',
146
+ '-c',
147
+ 'core.hooksPath=/dev/null',
148
+ '-C',
149
+ dir,
150
+ 'ls-remote',
151
+ url,
152
+ ...ADVERTISED,
153
+ ],
154
+ {
155
+ timeoutMs: LS_REMOTE_TIMEOUT_MS,
156
+ env: {
157
+ GIT_TERMINAL_PROMPT: '0',
158
+ GIT_ASKPASS: '',
159
+ SSH_ASKPASS: '',
160
+ GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o ConnectTimeout=15',
161
+ GIT_CEILING_DIRECTORIES: path.dirname(dir),
162
+ },
163
+ }
164
+ );
165
+ } finally {
166
+ await fsp.rm(dir, { recursive: true, force: true });
167
+ }
168
+ }
package/src/git.js CHANGED
@@ -16,6 +16,12 @@ import path from 'node:path';
16
16
 
17
17
  import { trace } from './exec.js';
18
18
  import { preservedWork } from './git-preserved.js';
19
+ import {
20
+ advertisedCommits,
21
+ attachedWorktrees,
22
+ lsRemote,
23
+ networkRemotes,
24
+ } from './git-remote.js';
19
25
 
20
26
  const GIT_FLAGS = [
21
27
  '--no-optional-locks',
@@ -42,7 +48,7 @@ const SAFE_GIT_ENV = {
42
48
  * a linked worktree or a submodule has a `.git` file naming its directory,
43
49
  * and a linked worktree names the common directory in `commondir`.
44
50
  */
45
- async function gitDirectories(root) {
51
+ export async function gitDirectories(root) {
46
52
  const dotGit = path.join(root, '.git');
47
53
  const stat = await fsp.lstat(dotGit);
48
54
  let gitDir = dotGit;
@@ -176,10 +182,11 @@ export class GitInspector {
176
182
  this.options = options;
177
183
  this.states = new Map();
178
184
  this.roots = new Map();
185
+ this.remoteRefs = new Map();
179
186
  this.available = null;
180
187
  }
181
188
 
182
- async #git(root, args, safeDir = null) {
189
+ async #git(root, args, safeDir = null, input = undefined) {
183
190
  const result = await this.env.run(
184
191
  [
185
192
  'git',
@@ -189,7 +196,7 @@ export class GitInspector {
189
196
  ...(safeDir ? ['--git-dir', safeDir, '--work-tree', root] : []),
190
197
  ...args,
191
198
  ],
192
- { env: SAFE_GIT_ENV }
199
+ { env: SAFE_GIT_ENV, input }
193
200
  );
194
201
  trace('git', root, args.join(' '), '->', result.code);
195
202
  return result;
@@ -246,6 +253,7 @@ export class GitInspector {
246
253
  invalidate() {
247
254
  this.states.clear();
248
255
  this.roots.clear();
256
+ this.remoteRefs.clear();
249
257
  }
250
258
 
251
259
  async #loadState(root) {
@@ -322,8 +330,8 @@ export class GitInspector {
322
330
  return stateBlockers(root, await this.repoState(root));
323
331
  }
324
332
 
325
- /** Protect an entire temporary work directory, including nested clones. */
326
- async treeBlockers(target, { allowNoRepo = false } = {}) {
333
+ /** Repositories at or below `target`. */
334
+ async #repoRoots(target) {
327
335
  const dirs = await this.env.findDirs([target], {
328
336
  globs: ['.git'],
329
337
  maxDepth: 32,
@@ -334,13 +342,18 @@ export class GitInspector {
334
342
  maxDepth: 32,
335
343
  skipNames: [],
336
344
  });
337
- const roots = new Set([
345
+ return new Set([
338
346
  ...((await this.env.exists(this.env.path.join(target, '.git')))
339
347
  ? [target]
340
348
  : []),
341
349
  ...dirs.map((entry) => entry.parent),
342
350
  ...files.map((file) => this.env.path.dirname(file)),
343
351
  ]);
352
+ }
353
+
354
+ /** Protect an entire temporary work directory, including nested clones. */
355
+ async treeBlockers(target, { allowNoRepo = false } = {}) {
356
+ const roots = await this.#repoRoots(target);
344
357
  if (roots.size === 0) {
345
358
  if (allowNoRepo) {
346
359
  return [];
@@ -357,6 +370,134 @@ export class GitInspector {
357
370
  return blockers;
358
371
  }
359
372
 
373
+ /**
374
+ * Reasons a temporary clone must not be deleted. Every repository inside
375
+ * `target` must have no uncommitted, unpushed or stashed work in any of
376
+ * its worktrees, no linked worktree outside `target`, and every local
377
+ * commit must be advertised by one of its https or ssh remotes.
378
+ */
379
+ async cloneBlockers(target) {
380
+ if (this.env.kind !== 'host') {
381
+ return [`${target}: remote copies are only verified on the host`];
382
+ }
383
+ if (!(await this.isAvailable())) {
384
+ return [`${target}: git is not installed, cannot verify the repository`];
385
+ }
386
+ const roots = await this.#repoRoots(target);
387
+ if (roots.size === 0) {
388
+ return [`${target}: no Git repository found; cannot verify work`];
389
+ }
390
+ if (roots.size > 50) {
391
+ return [`${target}: too many repositories to verify safely`];
392
+ }
393
+ const blockers = [];
394
+ for (const root of roots) {
395
+ blockers.push(...(await this.#cloneRootBlockers(root, target)));
396
+ }
397
+ return blockers;
398
+ }
399
+
400
+ async #cloneRootBlockers(root, target) {
401
+ let commonDir;
402
+ try {
403
+ ({ commonDir } = await gitDirectories(root));
404
+ } catch (error) {
405
+ return [`${root}: ${error.message}`];
406
+ }
407
+ const inside = (dir) => {
408
+ const relative = path.relative(target, dir);
409
+ return (
410
+ relative === '' ||
411
+ (!relative.startsWith('..') && !path.isAbsolute(relative))
412
+ );
413
+ };
414
+ const { trees, detached } = await attachedWorktrees(commonDir);
415
+ // Deleting the repository strands every worktree that shares it.
416
+ const affected = inside(commonDir) ? [root, ...trees] : [root];
417
+ const blockers = [];
418
+ for (const tree of new Set(affected)) {
419
+ if (!inside(tree)) {
420
+ blockers.push(`linked worktree ${tree} uses the repository in ${root}`);
421
+ } else if (await this.env.exists(path.join(tree, '.git'))) {
422
+ blockers.push(...(await this.repoBlockers(tree)));
423
+ }
424
+ }
425
+ if (blockers.length > 0) {
426
+ return blockers;
427
+ }
428
+ return this.#remoteBlockers(root, commonDir, detached);
429
+ }
430
+
431
+ async #remoteBlockers(root, commonDir, detached) {
432
+ const remotes = await networkRemotes(commonDir);
433
+ if (remotes.length === 0) {
434
+ return [`Git repository ${root} has no https or ssh remote holding it`];
435
+ }
436
+ const advertised = [];
437
+ const unreachable = [];
438
+ for (const remote of remotes) {
439
+ if (!this.remoteRefs.has(remote.url)) {
440
+ this.remoteRefs.set(
441
+ remote.url,
442
+ lsRemote((argv, options) => this.env.run(argv, options), remote.url)
443
+ );
444
+ }
445
+ const result = await this.remoteRefs.get(remote.url);
446
+ if (result.code === 0) {
447
+ advertised.push(...advertisedCommits(result.stdout));
448
+ } else {
449
+ unreachable.push(`${remote.name} (${remote.host})`);
450
+ }
451
+ }
452
+ if (advertised.length === 0) {
453
+ return [
454
+ `cannot confirm Git repository ${root} on a remote: ${unreachable.length > 0 ? `${unreachable.join(', ')} unreachable` : 'its remotes advertise nothing'}`,
455
+ ];
456
+ }
457
+ try {
458
+ const missing = await safeGitDirectory(root, (safeDir) =>
459
+ this.#commitsMissingFrom(root, safeDir, advertised, detached)
460
+ );
461
+ if (missing === 0) {
462
+ return [];
463
+ }
464
+ const hosts = [...new Set(remotes.map((remote) => remote.host))];
465
+ return [
466
+ `Git repository ${root} has ${plural(missing, 'commit')} no branch, tag or pull request on ${hosts.join(', ')} contains`,
467
+ ];
468
+ } catch (error) {
469
+ return [`cannot compare ${root} with its remote: ${error.message}`];
470
+ }
471
+ }
472
+
473
+ /** Local commits reachable from HEAD, branches or `detached` but no advertised commit. */
474
+ async #commitsMissingFrom(root, safeDir, advertised, detached) {
475
+ const known = await this.#git(
476
+ root,
477
+ ['cat-file', '--batch-check=%(objectname) %(objecttype)'],
478
+ safeDir,
479
+ `${advertised.join('\n')}\n`
480
+ );
481
+ if (known.code !== 0) {
482
+ throw new Error(known.stderr.trim() || `exit ${known.code}`);
483
+ }
484
+ const commits = known.stdout
485
+ .split('\n')
486
+ .map((line) => /^([0-9a-f]+) commit$/.exec(line)?.[1])
487
+ .filter(Boolean);
488
+ const tips = ['HEAD', '--branches', ...detached];
489
+ const count = await this.#git(
490
+ root,
491
+ ['rev-list', '--count', '--stdin', ...tips],
492
+ safeDir,
493
+ commits.map((commit) => `^${commit}\n`).join('')
494
+ );
495
+ if (count.code !== 0) {
496
+ throw new Error(count.stderr.trim() || `exit ${count.code}`);
497
+ }
498
+ return Number(count.stdout.trim());
499
+ }
500
+
360
501
  /**
361
502
  * Number of tracked files inside `target` (a path within `root`).
362
503
  * Returns -1 when Git cannot answer.
package/src/index.d.ts CHANGED
@@ -172,9 +172,19 @@ export interface EnvironmentDescriptor {
172
172
  holders: Array<{
173
173
  pid: number;
174
174
  command: string;
175
+ /** Resident plus compressed and swapped memory. */
175
176
  memoryBytes: number;
176
- compressedBytes: number;
177
+ /** macOS only. */
178
+ compressedBytes?: number;
179
+ /** Linux only (`VmSwap`). */
180
+ swappedBytes?: number;
181
+ /** Change since the earliest scan of the last week listing this process. */
182
+ growth?: { since: number; memoryBytes: number };
177
183
  }>;
184
+ /** Change since the earliest scan of the last week. */
185
+ growth?: { since: number; usedBytes: number } | null;
186
+ /** Large, growing, or disk-threatening swap, naming the memory holders. */
187
+ warnings?: string[];
178
188
  attribution: string;
179
189
  advice: string;
180
190
  } | null;
package/src/liveness.js CHANGED
@@ -212,6 +212,7 @@ export class LivenessProbe {
212
212
  this.realPaths = new Map();
213
213
  this.pathOwners = new Map();
214
214
  this.pathStats = new Map();
215
+ this.inodes = new Map();
215
216
  }
216
217
 
217
218
  async refresh(force = false) {
@@ -230,6 +231,50 @@ export class LivenessProbe {
230
231
  this.realPaths.clear();
231
232
  this.pathStats.clear();
232
233
  this.pathOwners.clear();
234
+ this.inodes.clear();
235
+ }
236
+
237
+ /**
238
+ * Inodes of `executables` and of the open files that share their names,
239
+ * because a process running a hard link of an executable reports
240
+ * whichever path it was started from.
241
+ * @param {string[]} [executables]
242
+ */
243
+ async resolveExecutables(executables = []) {
244
+ if (executables.length === 0) {
245
+ return;
246
+ }
247
+ const names = new Set(
248
+ executables.map((target) => this.env.path.basename(target))
249
+ );
250
+ const missing = [
251
+ ...executables,
252
+ ...[...(this.openPaths ?? [])].filter((open) =>
253
+ names.has(this.env.path.basename(open))
254
+ ),
255
+ ].filter((target) => !this.inodes.has(target));
256
+ const stats = await Promise.all(
257
+ missing.map((target) => this.env.stat(target))
258
+ );
259
+ missing.forEach((target, index) =>
260
+ this.inodes.set(target, stats[index]?.inode ?? null)
261
+ );
262
+ }
263
+
264
+ /** A process running one of `executables` under any of its links. */
265
+ executableUsage(executables = []) {
266
+ for (const target of executables) {
267
+ const inode = this.inodes.get(target);
268
+ if (!inode) {
269
+ continue;
270
+ }
271
+ for (const open of this.openPaths ?? []) {
272
+ if (open !== target && this.inodes.get(open) === inode) {
273
+ return `in use: ${open} is ${target}`;
274
+ }
275
+ }
276
+ }
277
+ return null;
233
278
  }
234
279
 
235
280
  /**
@@ -511,6 +556,7 @@ export class LivenessProbe {
511
556
  (checks.mtime === false ? null : this.recentWrite(item.newestMtimeMs)) ??
512
557
  this.runningTool(checks.busy, checks.cwd, item) ??
513
558
  this.processUsage(paths) ??
559
+ this.executableUsage(checks.executables) ??
514
560
  this.uncertainUsage(item.paths ?? [])
515
561
  );
516
562
  }