disk-space-saviour 0.16.0 → 0.17.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 +6 -0
- package/README.md +40 -1
- package/package.json +1 -1
- package/src/clean.js +29 -5
- package/src/cli.js +79 -13
- package/src/emergency.js +37 -6
- package/src/env/local.js +245 -64
- package/src/env/parallel.js +23 -0
- package/src/env/platform-index.js +87 -0
- package/src/env/shell.js +41 -1
- package/src/fast-clean.js +39 -0
- package/src/health.js +3 -3
- package/src/index.d.ts +27 -0
- package/src/options.js +3 -1
- package/src/report.js +18 -2
- package/src/scan-state.js +239 -0
- package/src/scan.js +135 -19
- package/src/scanners/global.js +19 -2
- package/src/scanners/projects.js +89 -20
- package/src/scanners/versions.js +7 -4
- package/src/swap.js +69 -0
package/src/scanners/projects.js
CHANGED
|
@@ -65,6 +65,14 @@ function ruleAccepts(env, rule, candidate, selfListing, grandListing) {
|
|
|
65
65
|
* Pairs every found directory with the first rule it satisfies (or null).
|
|
66
66
|
*/
|
|
67
67
|
async function matchRules(env, found, rules) {
|
|
68
|
+
// Refresh markers and activity; the discovery index only supplies hints.
|
|
69
|
+
const parents = await listMany(env, [
|
|
70
|
+
...new Set(found.map((candidate) => candidate.parent)),
|
|
71
|
+
]);
|
|
72
|
+
found = found.map((candidate) => ({
|
|
73
|
+
...candidate,
|
|
74
|
+
siblings: parents.get(candidate.parent) ?? [],
|
|
75
|
+
}));
|
|
68
76
|
const options = found.map((candidate) => ({
|
|
69
77
|
candidate,
|
|
70
78
|
rules: rules.filter((rule) =>
|
|
@@ -116,6 +124,11 @@ async function rootCandidates(env, roots) {
|
|
|
116
124
|
return candidates;
|
|
117
125
|
}
|
|
118
126
|
|
|
127
|
+
async function indexedMatches(env, roots, globs, maxDepth, rules) {
|
|
128
|
+
const candidates = (await env.indexedDirs?.(roots, globs, maxDepth)) ?? [];
|
|
129
|
+
return matchRules(env, candidates, rules);
|
|
130
|
+
}
|
|
131
|
+
|
|
119
132
|
/**
|
|
120
133
|
* Finds every rule match below the roots.
|
|
121
134
|
* @returns {Promise<Array<{candidate: object, rule: object}>>}
|
|
@@ -125,18 +138,34 @@ export async function findProjectDirs(env, roots, options = {}) {
|
|
|
125
138
|
const maxDepth = options.maxDepth ?? 6;
|
|
126
139
|
const globs = [...new Set(rules.flatMap((rule) => rule.names))];
|
|
127
140
|
const matches = [];
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
const accept = (match) => {
|
|
143
|
+
if (!match.rule || seen.has(match.candidate.path)) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
seen.add(match.candidate.path);
|
|
147
|
+
matches.push(match);
|
|
148
|
+
return match;
|
|
149
|
+
};
|
|
150
|
+
const indexed = await indexedMatches(env, roots, globs, maxDepth, rules);
|
|
151
|
+
await options.onMatches?.(indexed.map(accept).filter(Boolean));
|
|
128
152
|
if (options.includeRoots) {
|
|
129
153
|
const self = await matchRules(env, await rootCandidates(env, roots), rules);
|
|
130
|
-
|
|
154
|
+
const accepted = self.map(accept).filter(Boolean);
|
|
155
|
+
await options.onMatches?.(accepted);
|
|
131
156
|
}
|
|
132
157
|
let frontier = new Map([[maxDepth, roots]]);
|
|
133
158
|
while (frontier.size > 0) {
|
|
134
159
|
const next = new Map();
|
|
135
160
|
for (const [depth, dirs] of frontier) {
|
|
136
161
|
const found = await env.findDirs(dirs, { globs, maxDepth: depth });
|
|
162
|
+
const accepted = [];
|
|
137
163
|
for (const match of await matchRules(env, found, rules)) {
|
|
138
164
|
if (match.rule) {
|
|
139
|
-
|
|
165
|
+
const fresh = accept(match);
|
|
166
|
+
if (fresh) {
|
|
167
|
+
accepted.push(fresh);
|
|
168
|
+
}
|
|
140
169
|
continue;
|
|
141
170
|
}
|
|
142
171
|
const remaining = depth - depthBelow(env, dirs, match.candidate.path);
|
|
@@ -147,6 +176,7 @@ export async function findProjectDirs(env, roots, options = {}) {
|
|
|
147
176
|
]);
|
|
148
177
|
}
|
|
149
178
|
}
|
|
179
|
+
await options.onMatches?.(accepted);
|
|
150
180
|
}
|
|
151
181
|
frontier = next;
|
|
152
182
|
}
|
|
@@ -180,6 +210,29 @@ function projectTier(context, rule, lastActivityMs) {
|
|
|
180
210
|
};
|
|
181
211
|
}
|
|
182
212
|
|
|
213
|
+
/** Re-evaluate activity before cleaning a reused project plan. */
|
|
214
|
+
export async function currentProjectTier(context, item) {
|
|
215
|
+
const rule = PROJECT_RULES.find((entry) => entry.id === item.rule);
|
|
216
|
+
if (!rule || rule.kind === 'cache') {
|
|
217
|
+
return item.tier;
|
|
218
|
+
}
|
|
219
|
+
const globs = [...new Set(PROJECT_RULES.flatMap((entry) => entry.names))];
|
|
220
|
+
const parent = context.env.path.dirname(item.path);
|
|
221
|
+
const candidate = {
|
|
222
|
+
name: context.env.path.basename(item.path),
|
|
223
|
+
siblings: await context.env.list(parent),
|
|
224
|
+
};
|
|
225
|
+
context.git.invalidate();
|
|
226
|
+
const activity = await projectActivity(
|
|
227
|
+
context,
|
|
228
|
+
item.project,
|
|
229
|
+
candidate,
|
|
230
|
+
{ newestMtimeMs: item.newestMtimeMs },
|
|
231
|
+
globs
|
|
232
|
+
);
|
|
233
|
+
return projectTier(context, rule, activity).tier;
|
|
234
|
+
}
|
|
235
|
+
|
|
183
236
|
async function addBlockers(context, item, requireClean) {
|
|
184
237
|
for (const reason of await context.git.pathBlockers(item.path, {
|
|
185
238
|
requireClean,
|
|
@@ -198,12 +251,21 @@ async function projectActivity(context, project, candidate, usage, globs) {
|
|
|
198
251
|
const lastCommitMs = repo ? (await git.repoState(repo)).lastCommitMs : 0;
|
|
199
252
|
context.projectUsage ??= new Map();
|
|
200
253
|
if (!context.projectUsage.has(project)) {
|
|
201
|
-
context.projectUsage.set(
|
|
254
|
+
context.projectUsage.set(
|
|
255
|
+
project,
|
|
256
|
+
env.projectActivity
|
|
257
|
+
? await env.projectActivity(
|
|
258
|
+
project,
|
|
259
|
+
globs,
|
|
260
|
+
context.now - context.options.inactiveMs
|
|
261
|
+
)
|
|
262
|
+
: (await env.usage(project))?.newestMtimeMs
|
|
263
|
+
);
|
|
202
264
|
}
|
|
203
265
|
const projectUsage = context.projectUsage.get(project);
|
|
204
266
|
return Math.max(
|
|
205
267
|
usage?.newestMtimeMs ?? 0,
|
|
206
|
-
projectUsage
|
|
268
|
+
projectUsage ?? 0,
|
|
207
269
|
siblingActivity(candidate, globs),
|
|
208
270
|
lastCommitMs
|
|
209
271
|
);
|
|
@@ -228,6 +290,7 @@ async function projectItem(context, { candidate, rule }, usage, globs) {
|
|
|
228
290
|
description: `${ECOSYSTEM_NAMES.get(rule.ecosystem)} ${rule.description}`,
|
|
229
291
|
path: candidate.path,
|
|
230
292
|
bytes: usage?.bytes ?? 0,
|
|
293
|
+
sizeEstimated: Boolean(usage?.estimated),
|
|
231
294
|
newestMtimeMs: usage?.newestMtimeMs ?? 0,
|
|
232
295
|
lastActivityMs,
|
|
233
296
|
project,
|
|
@@ -264,7 +327,7 @@ async function rustItems(context, targetItem, rule) {
|
|
|
264
327
|
const { env, liveness, options, now } = context;
|
|
265
328
|
const items = scanItems(context);
|
|
266
329
|
const semverPath = env.path.join(targetItem.path, 'semver-checks');
|
|
267
|
-
const semverUsage = await env.usage(semverPath);
|
|
330
|
+
const semverUsage = await env.usage(semverPath, { cache: true });
|
|
268
331
|
if (semverUsage) {
|
|
269
332
|
const semver = makeItem(env, {
|
|
270
333
|
rule: 'cargo-semver-checks-cache',
|
|
@@ -273,6 +336,7 @@ async function rustItems(context, targetItem, rule) {
|
|
|
273
336
|
description: 'cargo-semver-checks baseline builds',
|
|
274
337
|
path: semverPath,
|
|
275
338
|
bytes: semverUsage.bytes,
|
|
339
|
+
sizeEstimated: Boolean(semverUsage.estimated),
|
|
276
340
|
newestMtimeMs: semverUsage.newestMtimeMs,
|
|
277
341
|
project: targetItem.project,
|
|
278
342
|
parentId: targetItem.id,
|
|
@@ -333,24 +397,29 @@ async function rustItems(context, targetItem, rule) {
|
|
|
333
397
|
*/
|
|
334
398
|
export async function scanProjects(context, { rules = PROJECT_RULES } = {}) {
|
|
335
399
|
const { env, options } = context;
|
|
336
|
-
const
|
|
400
|
+
const globs = [...new Set(rules.flatMap((rule) => rule.names))];
|
|
401
|
+
const items = scanItems(context);
|
|
402
|
+
await findProjectDirs(env, options.roots, {
|
|
337
403
|
rules,
|
|
338
404
|
maxDepth: options.maxDepth,
|
|
339
405
|
includeRoots: options.includeRoots,
|
|
406
|
+
onMatches: async (matches) => {
|
|
407
|
+
const usages = await env.usageMany(
|
|
408
|
+
matches.map((match) => match.candidate.path),
|
|
409
|
+
{ cache: true }
|
|
410
|
+
);
|
|
411
|
+
for (const match of matches) {
|
|
412
|
+
const usage = usages.get(match.candidate.path);
|
|
413
|
+
if (!usage) {
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const item = await projectItem(context, match, usage, globs);
|
|
417
|
+
items.push(item);
|
|
418
|
+
if (match.rule.id === 'cargo-target') {
|
|
419
|
+
items.push(...(await rustItems(context, item, match.rule)));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
},
|
|
340
423
|
});
|
|
341
|
-
const usages = await env.usageMany(matches.map((m) => m.candidate.path));
|
|
342
|
-
const globs = [...new Set(rules.flatMap((rule) => rule.names))];
|
|
343
|
-
const items = scanItems(context);
|
|
344
|
-
for (const match of matches) {
|
|
345
|
-
const usage = usages.get(match.candidate.path);
|
|
346
|
-
if (!usage) {
|
|
347
|
-
continue;
|
|
348
|
-
}
|
|
349
|
-
const item = await projectItem(context, match, usage, globs);
|
|
350
|
-
items.push(item);
|
|
351
|
-
if (match.rule.id === 'cargo-target') {
|
|
352
|
-
items.push(...(await rustItems(context, item, match.rule)));
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
424
|
return items;
|
|
356
425
|
}
|
package/src/scanners/versions.js
CHANGED
|
@@ -190,7 +190,9 @@ async function ruleItems(context, rule, home, pins) {
|
|
|
190
190
|
return [];
|
|
191
191
|
}
|
|
192
192
|
const refs = [...(await managerRefs(env, rule, home, installs)), ...pins];
|
|
193
|
-
const usages = await env.usageMany(installs
|
|
193
|
+
const usages = await env.usageMany(installs, {
|
|
194
|
+
cache: !context.options.freshSizes,
|
|
195
|
+
});
|
|
194
196
|
await resolvePaths(context, installs);
|
|
195
197
|
const newest = newestInstalls(env, rule, installs, usages);
|
|
196
198
|
const versionOf = rule.versionOf ?? ((dir) => env.path.basename(dir));
|
|
@@ -200,15 +202,16 @@ async function ruleItems(context, rule, home, pins) {
|
|
|
200
202
|
if (newest.has(dir) || refs.some((ref) => versionMatches(version, ref))) {
|
|
201
203
|
continue;
|
|
202
204
|
}
|
|
203
|
-
const usage = usages.get(dir);
|
|
205
|
+
const usage = usages.get(dir) ?? {};
|
|
204
206
|
const item = makeItem(env, {
|
|
205
207
|
rule: rule.id,
|
|
206
208
|
kind: 'toolchain',
|
|
207
209
|
ecosystem: rule.ecosystem,
|
|
208
210
|
description: `${rule.description} ${version}`,
|
|
209
211
|
path: dir,
|
|
210
|
-
bytes: usage
|
|
211
|
-
|
|
212
|
+
bytes: usage.bytes ?? 0,
|
|
213
|
+
sizeEstimated: Boolean(usage.estimated),
|
|
214
|
+
newestMtimeMs: usage.newestMtimeMs ?? 0,
|
|
212
215
|
tier: 'moderate',
|
|
213
216
|
reason: `not the ${rule.manager} default, not pinned by a scanned project, not the newest`,
|
|
214
217
|
checks: { busy: rule.busy ?? [], cwd: dir, mtime: true },
|
package/src/swap.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Swap is a capacity diagnostic, never a deletion candidate. */
|
|
2
|
+
import { parseSize } from './units.js';
|
|
3
|
+
|
|
4
|
+
export function parseSwapUsage(text) {
|
|
5
|
+
const match =
|
|
6
|
+
/total\s*=\s*([\d.]+[KMGT]?B?)\s+used\s*=\s*([\d.]+[KMGT]?B?)\s+free\s*=\s*([\d.]+[KMGT]?B?)/i.exec(
|
|
7
|
+
text
|
|
8
|
+
);
|
|
9
|
+
return match
|
|
10
|
+
? {
|
|
11
|
+
total: parseSize(match[1]),
|
|
12
|
+
used: parseSize(match[2]),
|
|
13
|
+
free: parseSize(match[3]),
|
|
14
|
+
}
|
|
15
|
+
: null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseMemoryHolders(text) {
|
|
19
|
+
return text
|
|
20
|
+
.split('\n')
|
|
21
|
+
.map((line) =>
|
|
22
|
+
/^\s*(\d+)\s+(.+?)\s+([\d.]+[KMGT]?B?[+-]?)\s+([\d.]+[KMGT]?B?[+-]?)\s*$/i.exec(
|
|
23
|
+
line
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.map((match) => ({
|
|
28
|
+
pid: Number(match[1]),
|
|
29
|
+
command: match[2],
|
|
30
|
+
memoryBytes: parseSize(match[3].replace(/[+-]$/, '')),
|
|
31
|
+
compressedBytes: parseSize(match[4].replace(/[+-]$/, '')),
|
|
32
|
+
}))
|
|
33
|
+
.sort((a, b) => b.memoryBytes - a.memoryBytes)
|
|
34
|
+
.slice(0, 10);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function readSwap(env) {
|
|
38
|
+
if (env.kind !== 'host' || env.platform !== 'darwin') {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const usage = await env.run(['sysctl', 'vm.swapusage']);
|
|
43
|
+
const swap = usage.code === 0 ? parseSwapUsage(usage.stdout) : null;
|
|
44
|
+
if (!swap) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const top = await env.run([
|
|
48
|
+
'top',
|
|
49
|
+
'-l',
|
|
50
|
+
'1',
|
|
51
|
+
'-o',
|
|
52
|
+
'mem',
|
|
53
|
+
'-n',
|
|
54
|
+
'10',
|
|
55
|
+
'-stats',
|
|
56
|
+
'pid,command,mem,cmprs',
|
|
57
|
+
]);
|
|
58
|
+
return {
|
|
59
|
+
...swap,
|
|
60
|
+
holders: top.code === 0 ? parseMemoryHolders(top.stdout) : [],
|
|
61
|
+
attribution:
|
|
62
|
+
'Largest resident/compressed memory consumers; macOS does not expose per-process swap ownership here.',
|
|
63
|
+
advice:
|
|
64
|
+
'If Docker Desktop is consuming memory, restart it after saving running work or lower its VM memory limit. Swap files are managed by macOS; dss never deletes them.',
|
|
65
|
+
};
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|