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 +30 -0
- package/README.md +47 -16
- package/package.json +1 -1
- package/src/clean.js +21 -1
- package/src/cli.js +2 -1
- 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 +21 -9
- package/src/rules/other.js +56 -10
- package/src/rules/versions.js +4 -0
- package/src/scan.js +46 -8
- package/src/scanners/app-clones.js +53 -0
- package/src/scanners/common.js +1 -0
- package/src/scanners/global.js +115 -12
- package/src/scanners/versions.js +53 -7
- package/src/swap.js +286 -28
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
|
|
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 (
|
|
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
|
|
114
|
-
|
|
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.
|
|
121
|
-
|
|
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
|
-
|
|
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
|
|
237
|
-
|
|
238
|
-
swap ownership.
|
|
239
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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;
|