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 +10 -0
- package/README.md +35 -12
- package/package.json +1 -1
- package/src/clean.js +13 -0
- package/src/env/local.js +21 -1
- package/src/git-remote.js +168 -0
- package/src/git.js +147 -6
- package/src/index.d.ts +11 -1
- package/src/liveness.js +46 -0
- package/src/playwright-registry.js +77 -0
- package/src/report.js +29 -4
- package/src/rules/apps.js +3 -2
- package/src/rules/downloads.js +5 -3
- package/src/rules/other.js +56 -10
- package/src/scan.js +7 -3
- package/src/scanners/app-clones.js +53 -0
- package/src/scanners/common.js +1 -0
- package/src/scanners/global.js +92 -10
- package/src/scanners/versions.js +9 -6
- package/src/swap.js +286 -28
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 (
|
|
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
|
|
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.
|
|
126
|
-
|
|
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
|
|
245
|
-
|
|
246
|
-
swap ownership.
|
|
247
|
-
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
326
|
-
async
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|