disk-space-saviour 0.19.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,35 @@
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
+
13
+ ## 0.20.0
14
+
15
+ ### Minor Changes
16
+
17
+ - Clean every installed Chromium on-device AI model, including the current
18
+ version and the classifier model, in Chrome, Chromium and Edge. The browser
19
+ downloads a model again when a page uses its built-in AI; a loaded model or a
20
+ download in progress keeps it in place.
21
+
22
+ Keep a Claude Code version while its lock file names a running session or
23
+ cannot be read, and recheck locks, globally installed packages and other
24
+ version blockers right before deleting a toolchain version.
25
+
26
+ `--only` also targets a single item by its id and the items inside a path or
27
+ matching a glob. A run limited to global cache rules or their items scans only
28
+ those rules and skips project discovery.
29
+
30
+ A run limited to toolchain version rules, such as `--only claude-code-versions`,
31
+ scans only those rules and skips project discovery.
32
+
3
33
  ## 0.19.0
4
34
 
5
35
  ### Minor Changes
package/README.md CHANGED
@@ -68,8 +68,13 @@ dss docker scan|clean [--container ID] [--recursive]
68
68
 
69
69
  Run `dss --help` for every option. The most useful ones:
70
70
 
71
- - `--only NAME` limits a run to an ecosystem, rule or kind (`--only rust`,
72
- `--only npm-cache`, `--only docker-stopped-container`).
71
+ - `--only NAME|ID|PATH` limits a run to an ecosystem, rule or kind
72
+ (`--only rust`, `--only npm-cache`, `--only docker-stopped-container`), a
73
+ single item by its JSON `id`, or the items inside a path or matching a glob
74
+ (`--only ~/Library/Caches/Codex`, `--only '**/Crashpad/*'`). A path never
75
+ widens to the item holding it. When every entry names a cache or
76
+ toolchain version rule or one of its items, only those rules are scanned,
77
+ so `dss clean --only openai-app-caches --yes` takes seconds.
73
78
  - `--exclude PATH|GLOB` prunes matching paths and descendants before reading
74
79
  them. Patterns containing separators match full paths and their ancestors;
75
80
  other patterns also match ancestor basenames.
@@ -92,11 +97,11 @@ ranked by how recently it was used: a cache, build output or dependency
92
97
  directory is `safe` and is held back only while something uses it (see
93
98
  [Safety](#safety)).
94
99
 
95
- | Tier | What it removes |
96
- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
97
- | `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. |
98
- | `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). |
99
- | `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`. |
100
105
 
101
106
  Covered ecosystems: JavaScript/TypeScript (npm, yarn, pnpm, bun, deno,
102
107
  framework build caches), Python, Rust, JVM (Gradle, Maven, Kotlin, Android),
@@ -110,15 +115,21 @@ LibreWolf, Waterfox, Zen and Floorp, IDE caches (JetBrains, VS Code, Cursor,
110
115
  Windsurf, Qoder), AI agent caches and logs, the Discord cache, macOS app
111
116
  updates staged by Sparkle and Squirrel, package manager archives, trash, crash
112
117
  reports, core dumps and journald logs. Application downloads are covered too:
113
- Chrome bundle clones left by exited browsers, superseded Chrome on-device
114
- models, Google Updater installers, ChatGPT Atlas and Codex app caches,
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
121
+ (downloaded again when a page uses built-in AI), Google Updater installers, ChatGPT Atlas and Codex app caches,
115
122
  Electron and electron-builder archives, the Bun transpiler cache, Docker
116
123
  Desktop and Warp staged updates (kept while the app runs), Zed language
117
124
  servers, tree-sitter toolchains, Codex plugin staging, the Clang module cache
118
125
  and processed Crashpad reports. Telegram pure caches, Safari/WebKit,
119
126
  Chromium code/GPU/Dawn/service-worker caches and marker-validated Electron
120
- caches are included. Telegram databases, browser profiles and Playwright MCP
121
- 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
122
133
  is needed. Agent scratch cleanup requires inactivity and Git safety checks.
123
134
 
124
135
  ## Safety
@@ -145,7 +156,10 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
145
156
  version holding globally installed npm packages or pyenv virtual
146
157
  environments is blocked; bundled `npm`/`corepack` and use-m caches
147
158
  (`<name>-v-<version>`) are reinstalled on demand and do not count.
148
- Container paths are checked individually before each bounded deletion batch.
159
+ A Claude Code version stays while a running session uses it: its
160
+ executable is open, or its `~/.local/state/claude/locks/<version>.lock`
161
+ names a live process or cannot be read. All of this is checked again right
162
+ before each version is deleted. Container paths are checked individually before each bounded deletion batch.
149
163
  - **Rust builds are pruned in two parts.** `cargo-superseded` removes
150
164
  older hashes of a unit (`.rlib`, `.rmeta`, `.so`/`.dylib`/`.a`,
151
165
  `build/*`, `.fingerprint`) that a newer build replaced, but never while
@@ -233,10 +247,27 @@ and sizes. Safe cleanup also rechecks source activity. Explicit moderate cleanup
233
247
  preserves the ability to remove ignored dependencies when only source changed.
234
248
  Library callers opt into state with `cacheDir`.
235
249
 
236
- On macOS, reports include `sysctl vm.swapusage` and the largest resident/compressed
237
- memory consumers from `top`. These are memory diagnostics, not verified per-process
238
- swap ownership. When Docker Desktop consumes memory, saving work and restarting it
239
- 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.
240
271
 
241
272
  Cache discovery honors tool configuration before default paths, including
242
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.19.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
  }
@@ -403,9 +409,16 @@ export class Cleaner {
403
409
  },
404
410
  { rules: [rule] }
405
411
  );
406
- if (!candidates.some((candidate) => candidate.path === item.path)) {
412
+ const current = candidates.find(
413
+ (candidate) => candidate.path === item.path
414
+ );
415
+ if (!current) {
407
416
  return `${item.path} is now current, pinned, or the newest installed version`;
408
417
  }
418
+ // Locks of running processes and data reinstalling would not restore.
419
+ if (current.blockers.length > 0) {
420
+ return current.blockers[0];
421
+ }
409
422
  }
410
423
  if (
411
424
  item.recheck?.type === 'pyenv-envs' &&
@@ -425,6 +438,7 @@ export class Cleaner {
425
438
  async #safetyBlocker(ctx, item, type) {
426
439
  await ctx.liveness.refresh(true);
427
440
  await ctx.liveness.resolve(item.paths);
441
+ await ctx.liveness.resolveExecutables(item.checks?.executables);
428
442
  const busy = ctx.liveness.busyReason(item);
429
443
  if (busy) {
430
444
  return `busy: ${busy}`;
@@ -468,6 +482,11 @@ export class Cleaner {
468
482
  }
469
483
 
470
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
+ }
471
490
  if (
472
491
  item.project ||
473
492
  item.rule === 'gh-issue-solver-workdirs' ||
@@ -660,6 +679,7 @@ export class Cleaner {
660
679
  return `stopped before ${target}: ${stopped}`;
661
680
  }
662
681
  await ctx.liveness.resolve([target]);
682
+ await ctx.liveness.resolveExecutables(item.checks?.executables);
663
683
  const busy = ctx.liveness.busyReason({ ...item, paths: [target] });
664
684
  return busy ? `stopped before ${target}: busy: ${busy}` : null;
665
685
  }
package/src/cli.js CHANGED
@@ -40,7 +40,8 @@ Scope:
40
40
  --container ID|NAME limit Docker work to these containers (repeat)
41
41
  --max-depth N directory depth for project search (default 6)
42
42
  --scan-budget AGE time budget per filesystem environment (2m)
43
- --only NAME only these ecosystems, rules or kinds (repeat)
43
+ --only NAME|ID|PATH only these ecosystems, rules, kinds, item ids,
44
+ or items inside a path or glob (repeat)
44
45
  --exclude PATH|GLOB never touch matching paths (repeat)
45
46
  --scanner NAME projects, global, versions, agents, system
46
47
  --min-size SIZE ignore smaller items (default 1M)
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;