disk-space-saviour 0.20.0 → 0.22.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 +16 -0
- package/README.md +70 -12
- package/package.json +1 -1
- package/src/app-owners.js +87 -0
- package/src/clean.js +24 -0
- package/src/cli.js +32 -3
- package/src/conditions.js +145 -0
- package/src/env/local.js +91 -8
- package/src/git-remote.js +168 -0
- package/src/git.js +147 -6
- package/src/index.d.ts +65 -1
- package/src/index.js +1 -0
- package/src/items.js +3 -0
- package/src/liveness.js +113 -4
- package/src/playwright-registry.js +77 -0
- package/src/report.js +143 -28
- 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 +37 -4
- package/src/scanners/app-clones.js +53 -0
- package/src/scanners/common.js +1 -0
- package/src/scanners/global.js +96 -10
- package/src/scanners/projects.js +18 -2
- package/src/scanners/versions.js +9 -6
- package/src/swap.js +286 -28
- package/src/usage.js +601 -0
package/src/env/local.js
CHANGED
|
@@ -89,7 +89,7 @@ export function parsePsSearchPaths(output) {
|
|
|
89
89
|
* host and inside containers (see shell.js).
|
|
90
90
|
*/
|
|
91
91
|
|
|
92
|
-
import { promises as fsp } from 'node:fs';
|
|
92
|
+
import { constants as fsConstants, promises as fsp } from 'node:fs';
|
|
93
93
|
import os from 'node:os';
|
|
94
94
|
import path from 'node:path';
|
|
95
95
|
import { URL, fileURLToPath } from 'node:url';
|
|
@@ -97,6 +97,7 @@ import { procIdentity, procOpenPaths } from './proc.js';
|
|
|
97
97
|
import { CACHE_VARIABLES } from './cache-roots.js';
|
|
98
98
|
|
|
99
99
|
import { hostExecutor } from '../exec.js';
|
|
100
|
+
import { parseBundleIdentifier } from '../app-owners.js';
|
|
100
101
|
import { matchesGlob } from '../paths.js';
|
|
101
102
|
import { mapLimit } from './parallel.js';
|
|
102
103
|
import { directoryFingerprint, SIZE_MAX_AGE } from '../scan-state.js';
|
|
@@ -251,6 +252,36 @@ export class LocalEnv {
|
|
|
251
252
|
);
|
|
252
253
|
}
|
|
253
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Access errors as report messages. Paths inside a directory this user
|
|
257
|
+
* cannot enter collapse into one line: nothing inside such a directory
|
|
258
|
+
* can be deleted by this user, so no candidate hides there.
|
|
259
|
+
* @returns {Promise<string[]>}
|
|
260
|
+
*/
|
|
261
|
+
async accessErrorMessages() {
|
|
262
|
+
const messages = [];
|
|
263
|
+
const foreign = new Set();
|
|
264
|
+
for (const [target, message] of this.accessErrors ?? []) {
|
|
265
|
+
const parent = path.dirname(target);
|
|
266
|
+
const enterable = await fsp
|
|
267
|
+
.access(parent, fsConstants.X_OK)
|
|
268
|
+
.then(() => true)
|
|
269
|
+
.catch(() => false);
|
|
270
|
+
if (enterable) {
|
|
271
|
+
messages.push(message);
|
|
272
|
+
} else {
|
|
273
|
+
foreign.add(parent);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (foreign.size > 0) {
|
|
277
|
+
const [example] = [...foreign].sort();
|
|
278
|
+
messages.push(
|
|
279
|
+
`skipped ${foreign.size} ${foreign.size === 1 ? 'directory' : 'directories'} this user cannot enter, such as ${example}; nothing inside can be deleted without their owner's rights`
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
return messages;
|
|
283
|
+
}
|
|
284
|
+
|
|
254
285
|
async #lstat(target) {
|
|
255
286
|
try {
|
|
256
287
|
return await (this.options.fileFs ?? fsp).lstat(target);
|
|
@@ -333,6 +364,24 @@ export class LocalEnv {
|
|
|
333
364
|
return heads;
|
|
334
365
|
}
|
|
335
366
|
|
|
367
|
+
/** Another user's entry of a sticky shared directory such as /tmp is private by design. */
|
|
368
|
+
async #foreignTempEntry(dir) {
|
|
369
|
+
const io = this.options.fileFs ?? fsp;
|
|
370
|
+
try {
|
|
371
|
+
const [own, parent] = await Promise.all([
|
|
372
|
+
io.lstat(dir),
|
|
373
|
+
io.stat(path.dirname(dir)),
|
|
374
|
+
]);
|
|
375
|
+
return (
|
|
376
|
+
typeof process.getuid === 'function' &&
|
|
377
|
+
own.uid !== process.getuid() &&
|
|
378
|
+
(parent.mode & 0o1000) !== 0
|
|
379
|
+
);
|
|
380
|
+
} catch {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
336
385
|
async list(dir, { discovery = false } = {}) {
|
|
337
386
|
let names;
|
|
338
387
|
try {
|
|
@@ -340,7 +389,9 @@ export class LocalEnv {
|
|
|
340
389
|
withFileTypes: true,
|
|
341
390
|
});
|
|
342
391
|
} catch (error) {
|
|
343
|
-
this
|
|
392
|
+
if (!(await this.#foreignTempEntry(dir))) {
|
|
393
|
+
this.recordAccessError(dir, error);
|
|
394
|
+
}
|
|
344
395
|
return [];
|
|
345
396
|
}
|
|
346
397
|
const entries = await mapLimit(names, 32, async (entry) => {
|
|
@@ -810,6 +861,8 @@ export class LocalEnv {
|
|
|
810
861
|
.map((m) => ({
|
|
811
862
|
pid: Number(m[1]),
|
|
812
863
|
name: path.basename(m[2].trim()),
|
|
864
|
+
// macOS `comm` is the executable's full path.
|
|
865
|
+
executable: m[2].trim(),
|
|
813
866
|
cwd: cwds.get(Number(m[1])) ?? null,
|
|
814
867
|
command: commands.get(Number(m[1])) ?? '',
|
|
815
868
|
searchPath: searchPaths.get(Number(m[1])) ?? [],
|
|
@@ -958,12 +1011,22 @@ export class LocalEnv {
|
|
|
958
1011
|
if (result.code !== 0 && !result.stdout) {
|
|
959
1012
|
return null;
|
|
960
1013
|
}
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1014
|
+
// `-F` always prints the pid (`p`) before the files of a process.
|
|
1015
|
+
const paths = new Set();
|
|
1016
|
+
const owners = new Map();
|
|
1017
|
+
let pid = null;
|
|
1018
|
+
for (const line of result.stdout.split('\n')) {
|
|
1019
|
+
if (line.startsWith('p')) {
|
|
1020
|
+
pid = Number(line.slice(1));
|
|
1021
|
+
} else if (line.startsWith('n/')) {
|
|
1022
|
+
paths.add(line.slice(1));
|
|
1023
|
+
if (!owners.has(line.slice(1))) {
|
|
1024
|
+
owners.set(line.slice(1), pid);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
this.openPathPids = owners;
|
|
1029
|
+
return paths;
|
|
967
1030
|
}
|
|
968
1031
|
return null;
|
|
969
1032
|
}
|
|
@@ -972,6 +1035,26 @@ export class LocalEnv {
|
|
|
972
1035
|
return this.executor.run(argv, options);
|
|
973
1036
|
}
|
|
974
1037
|
|
|
1038
|
+
/** `CFBundleIdentifier` of a macOS bundle, or null. */
|
|
1039
|
+
async bundleIdentifier(bundle) {
|
|
1040
|
+
if (this.platform !== 'darwin') {
|
|
1041
|
+
return null;
|
|
1042
|
+
}
|
|
1043
|
+
const plist = path.join(bundle, 'Contents', 'Info.plist');
|
|
1044
|
+
const text = await fsp.readFile(plist, 'utf8').catch(() => null);
|
|
1045
|
+
if (text === null) {
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
if (!text.startsWith('bplist')) {
|
|
1049
|
+
return parseBundleIdentifier(text);
|
|
1050
|
+
}
|
|
1051
|
+
const result = await this.run(
|
|
1052
|
+
['plutil', '-extract', 'CFBundleIdentifier', 'raw', '-o', '-', plist],
|
|
1053
|
+
{ timeoutMs: 10000 }
|
|
1054
|
+
).catch(() => null);
|
|
1055
|
+
return result?.code === 0 ? result.stdout.trim() || null : null;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
975
1058
|
async which(command) {
|
|
976
1059
|
const dirs = (process.env.PATH ?? '').split(path.delimiter);
|
|
977
1060
|
const extensions =
|
|
@@ -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;
|
|
@@ -209,6 +219,16 @@ export interface Report {
|
|
|
209
219
|
options: Record<string, unknown>;
|
|
210
220
|
environments: EnvironmentDescriptor[];
|
|
211
221
|
items: Item[];
|
|
222
|
+
/**
|
|
223
|
+
* Host items that share every block with an installed app (APFS clones):
|
|
224
|
+
* never offered, listed so the space `du` counts for them is explained.
|
|
225
|
+
*/
|
|
226
|
+
shared?: Array<{
|
|
227
|
+
rule: string;
|
|
228
|
+
path: string;
|
|
229
|
+
apparentBytes: number;
|
|
230
|
+
sharedWith: string;
|
|
231
|
+
}>;
|
|
212
232
|
/** Tier totals exclude items that need explicit consent (`consent`). */
|
|
213
233
|
totals: Record<Tier | 'blocked' | 'consent', TierTotal>;
|
|
214
234
|
docker: {
|
|
@@ -290,6 +310,50 @@ export declare function clean(
|
|
|
290
310
|
/** Escalates tiers until the free-space goal is met, then stops. */
|
|
291
311
|
export declare function emergency(options: EmergencyOptions): Promise<AuditLog>;
|
|
292
312
|
|
|
313
|
+
export interface UsageOptions {
|
|
314
|
+
/** Directories to walk; default the macOS Data volume or `/`. */
|
|
315
|
+
roots?: string[] | null;
|
|
316
|
+
/** Smallest directory in the tree, in bytes; default 1% of a root. */
|
|
317
|
+
minSize?: number;
|
|
318
|
+
verbose?: boolean;
|
|
319
|
+
signal?: AbortSignal;
|
|
320
|
+
auditDir?: string;
|
|
321
|
+
onUsageProgress?: (progress: {
|
|
322
|
+
root: string;
|
|
323
|
+
elapsedMs: number;
|
|
324
|
+
entries: number;
|
|
325
|
+
at: string | null;
|
|
326
|
+
}) => void;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export interface UsageReport {
|
|
330
|
+
schema: 1;
|
|
331
|
+
createdAt: string;
|
|
332
|
+
durationMs: number;
|
|
333
|
+
platform: string;
|
|
334
|
+
scan: { file: string; createdAt: string | null } | null;
|
|
335
|
+
roots: Array<{
|
|
336
|
+
root: string;
|
|
337
|
+
path: string;
|
|
338
|
+
bytes: number;
|
|
339
|
+
entries: number;
|
|
340
|
+
tree: object;
|
|
341
|
+
files: Array<{ path: string; bytes: number }>;
|
|
342
|
+
[key: string]: unknown;
|
|
343
|
+
}>;
|
|
344
|
+
volumes: Array<{
|
|
345
|
+
name: string;
|
|
346
|
+
mount: string | null;
|
|
347
|
+
bytes: number;
|
|
348
|
+
role: string;
|
|
349
|
+
}>;
|
|
350
|
+
snapshots: number | null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Shows where the space of whole volumes goes. Never deletes. */
|
|
354
|
+
export declare function usage(options?: UsageOptions): Promise<UsageReport>;
|
|
355
|
+
export declare function formatUsage(report: UsageReport): string;
|
|
356
|
+
|
|
293
357
|
export declare function formatReport(
|
|
294
358
|
report: Report,
|
|
295
359
|
options?: { verbose?: boolean }
|
package/src/index.js
CHANGED
|
@@ -23,6 +23,7 @@ export {
|
|
|
23
23
|
writeAudit,
|
|
24
24
|
} from './audit.js';
|
|
25
25
|
export { formatReport, formatAudit, formatDisk } from './report.js';
|
|
26
|
+
export { usage, formatUsage } from './usage.js';
|
|
26
27
|
export { runCli, parseCli, USAGE } from './cli.js';
|
|
27
28
|
export { DEFAULTS, resolveOptions, resolveGoal } from './options.js';
|
|
28
29
|
export {
|
package/src/items.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* named flag (or an interactive yes) on top of their tier being selected.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { blockedConditions } from './conditions.js';
|
|
11
|
+
|
|
10
12
|
export const TIERS = ['safe', 'moderate', 'aggressive'];
|
|
11
13
|
|
|
12
14
|
/**
|
|
@@ -111,6 +113,7 @@ export function tierTotals(items) {
|
|
|
111
113
|
unknownBytes: blocked
|
|
112
114
|
.filter((item) => item.sizeUnknown)
|
|
113
115
|
.reduce((sum, item) => sum + (item.totalBytes ?? item.bytes), 0),
|
|
116
|
+
...blockedConditions(blocked),
|
|
114
117
|
};
|
|
115
118
|
return totals;
|
|
116
119
|
}
|