disk-space-saviour 0.17.0 → 0.19.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.
@@ -2,7 +2,8 @@ import { scanItems } from '../env/scope.js';
2
2
  /**
3
3
  * Global per-user caches (ecosystem download and compilation caches, IDE and
4
4
  * browser caches), system package caches, and leftovers in temporary
5
- * directories. See ../rules/ecosystems.js and ../rules/other.js.
5
+ * directories. See ../rules/ecosystems.js, ../rules/other.js and
6
+ * ../rules/downloads.js.
6
7
  */
7
8
 
8
9
  import { CACHE_RULES } from '../rules/ecosystems.js';
@@ -14,12 +15,15 @@ import { expandGlobPaths, expandRulePath, isWithin } from '../paths.js';
14
15
  import { olderThan, scanTimeBusy, resolvePaths } from './common.js';
15
16
  import { configuredPattern } from '../env/cache-roots.js';
16
17
  import { TOOLCHAIN_CACHE_RULES } from '../rules/toolchains.js';
18
+ import { DOWNLOAD_RULES } from '../rules/downloads.js';
19
+ import { timed } from '../timings.js';
17
20
 
18
21
  export const GLOBAL_RULES = [
19
22
  ...CACHE_RULES,
20
23
  ...expandChromiumCaches(OTHER_RULES),
21
24
  ...APP_RULES,
22
25
  ...TOOLCHAIN_CACHE_RULES,
26
+ ...DOWNLOAD_RULES,
23
27
  ];
24
28
 
25
29
  function scopeBases(rule, homes, tmpDirs) {
@@ -242,9 +246,11 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
242
246
  patterns: rulePatterns(env, rule, base),
243
247
  }))
244
248
  );
245
- const matches = await expandGlobPaths(
246
- env,
247
- planned.flatMap((entry) => entry.patterns)
249
+ const matches = await timed(context.timings, 'global:expand', () =>
250
+ expandGlobPaths(
251
+ env,
252
+ planned.flatMap((entry) => entry.patterns)
253
+ )
248
254
  );
249
255
  const typedPaths = planned
250
256
  .filter(
@@ -253,7 +259,9 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
253
259
  .flatMap(({ patterns }) =>
254
260
  patterns.flatMap((pattern) => matches.get(pattern) ?? [])
255
261
  );
256
- const stats = await env.statMany([...new Set(typedPaths)]);
262
+ const stats = await timed(context.timings, 'global:stat', () =>
263
+ env.statMany([...new Set(typedPaths)])
264
+ );
257
265
  const found = [];
258
266
  const seen = new Set();
259
267
  for (const { rule, base, patterns } of planned) {
@@ -263,7 +271,11 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
263
271
 
264
272
  const accepted = [];
265
273
  for (const target of candidates) {
266
- if (!(await acceptedPath(env, rule, target, stats))) {
274
+ if (
275
+ !(await timed(context.timings, 'global:accept', () =>
276
+ acceptedPath(env, rule, target, stats)
277
+ ))
278
+ ) {
267
279
  continue;
268
280
  }
269
281
  accepted.push(target);
@@ -274,9 +286,11 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
274
286
  found.push({ rule, base, paths });
275
287
  }
276
288
  }
277
- await resolvePaths(
278
- context,
279
- found.flatMap((entry) => entry.paths)
289
+ await timed(context.timings, 'global:resolve', () =>
290
+ resolvePaths(
291
+ context,
292
+ found.flatMap((entry) => entry.paths)
293
+ )
280
294
  );
281
295
  const items = scanItems(context);
282
296
  // Deliver cheap native measurements before rules needing recursive activity
@@ -284,20 +298,27 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
284
298
  found.sort(
285
299
  (a, b) => Number(requiresMtime(a.rule)) - Number(requiresMtime(b.rule))
286
300
  );
301
+ // The report drops items below `minSize`; skipping them here saves their
302
+ // liveness checks (Telegram media alone can hold 100k small files). In a
303
+ // container the writable layer re-measures sizes later, so all are kept.
304
+ const large = (entry) =>
305
+ Boolean(env.writableLayer) ||
306
+ entry.usage.bytes >= (options.minSizeBytes ?? 0);
287
307
  for (const { rule, base, paths } of found) {
288
- const usages = await env.usageMany(paths, {
289
- mtime: requiresMtime(rule),
290
- cache: true,
291
- });
308
+ const usages = await timed(context.timings, 'global:measure', () =>
309
+ env.usageMany(paths, { mtime: requiresMtime(rule), cache: true })
310
+ );
292
311
  const accept = ageFilter(context, rule);
293
312
  const measured = paths
294
313
  .map((target) => ({ path: target, usage: usages.get(target) }))
295
314
  .filter((entry) => entry.usage && accept(entry.usage));
296
315
  const groups = rule.aggregate
297
316
  ? [measured]
298
- : measured.map((entry) => [entry]);
317
+ : measured.filter(large).map((entry) => [entry]);
299
318
  for (const group of groups.filter((g) => g.length > 0)) {
300
- const item = await buildItem(context, rule, base, group);
319
+ const item = await timed(context.timings, 'global:item', () =>
320
+ buildItem(context, rule, base, group)
321
+ );
301
322
  items.push(item);
302
323
  if ((await context.ready?.([item])) === false) {
303
324
  return items;
@@ -1,3 +1,5 @@
1
+ import { performance } from 'node:perf_hooks';
2
+
1
3
  import { scanItems } from '../env/scope.js';
2
4
  /**
3
5
  * Per-project dependency, build and cache directories (node_modules, target,
@@ -16,8 +18,9 @@ import { scanItems } from '../env/scope.js';
16
18
  import { ECOSYSTEMS, PROJECT_RULES } from '../rules/ecosystems.js';
17
19
  import { block, makeItem } from '../items.js';
18
20
  import { isWithin, matchesGlob } from '../paths.js';
19
- import { listMany, olderThan, scanTimeBusy } from './common.js';
21
+ import { listMany, scanTimeBusy } from './common.js';
20
22
  import { analyzeRustProfile, findRustProfiles } from './rust.js';
23
+ import { timed } from '../timings.js';
21
24
 
22
25
  const ECOSYSTEM_NAMES = new Map(ECOSYSTEMS.map((e) => [e.id, e.name]));
23
26
 
@@ -183,106 +186,38 @@ export async function findProjectDirs(env, roots, options = {}) {
183
186
  return matches;
184
187
  }
185
188
 
186
- function siblingActivity(candidate, globs) {
187
- return (candidate.siblings ?? [])
188
- .filter(
189
- (entry) =>
190
- entry.name !== candidate.name &&
191
- !globs.some((glob) => matchesGlob(entry.name, glob))
192
- )
193
- .reduce((newest, entry) => Math.max(newest, entry.mtimeMs ?? 0), 0);
194
- }
195
-
196
- function projectTier(context, rule, lastActivityMs) {
197
- if (rule.kind === 'cache') {
198
- return { tier: 'safe', reason: 'cache, re-created on demand' };
199
- }
200
- const { inactiveMs } = context.options;
201
- if (olderThan(context, lastActivityMs, inactiveMs)) {
202
- return {
203
- tier: rule.id === 'node-modules' ? 'safe' : 'moderate',
204
- reason: 'project inactive, re-created by the build or install step',
205
- };
206
- }
207
- return {
208
- tier: 'aggressive',
209
- reason: 'project recently active, re-created by the build or install step',
210
- };
211
- }
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;
189
+ /**
190
+ * Project caches, build outputs and dependencies are all regenerable, so how
191
+ * recently the project was used does not matter: only liveness (a tool
192
+ * running in the project, open files, recent writes) and Git safety block
193
+ * them.
194
+ */
195
+ function projectTier(rule) {
196
+ return rule.kind === 'cache'
197
+ ? { tier: 'safe', reason: 'cache, re-created on demand' }
198
+ : { tier: 'safe', reason: 're-created by the build or install step' };
234
199
  }
235
200
 
236
201
  async function addBlockers(context, item, requireClean) {
237
- for (const reason of await context.git.pathBlockers(item.path, {
238
- requireClean,
239
- })) {
202
+ const reasons = await timed(context.timings, 'projects:git', () =>
203
+ context.git.pathBlockers(item.path, { requireClean })
204
+ );
205
+ for (const reason of reasons) {
240
206
  block(item, reason);
241
207
  }
242
- const busy = await scanTimeBusy(context, item);
208
+ const busy = await timed(context.timings, 'projects:busy', () =>
209
+ scanTimeBusy(context, item)
210
+ );
243
211
  if (busy) {
244
212
  block(item, `busy: ${busy}`);
245
213
  }
246
214
  }
247
215
 
248
- async function projectActivity(context, project, candidate, usage, globs) {
249
- const { env, git } = context;
250
- const repo = await git.findRepoRoot(project);
251
- const lastCommitMs = repo ? (await git.repoState(repo)).lastCommitMs : 0;
252
- context.projectUsage ??= new Map();
253
- if (!context.projectUsage.has(project)) {
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
- );
264
- }
265
- const projectUsage = context.projectUsage.get(project);
266
- return Math.max(
267
- usage?.newestMtimeMs ?? 0,
268
- projectUsage ?? 0,
269
- siblingActivity(candidate, globs),
270
- lastCommitMs
271
- );
272
- }
273
-
274
- async function projectItem(context, { candidate, rule }, usage, globs) {
216
+ async function projectItem(context, { candidate, rule }, usage) {
275
217
  const { env } = context;
276
218
  const project = rule.parentName
277
219
  ? env.path.dirname(candidate.parent)
278
220
  : candidate.parent;
279
- const lastActivityMs = await projectActivity(
280
- context,
281
- project,
282
- candidate,
283
- usage,
284
- globs
285
- );
286
221
  const item = makeItem(env, {
287
222
  rule: rule.id,
288
223
  kind: rule.kind,
@@ -292,10 +227,9 @@ async function projectItem(context, { candidate, rule }, usage, globs) {
292
227
  bytes: usage?.bytes ?? 0,
293
228
  sizeEstimated: Boolean(usage?.estimated),
294
229
  newestMtimeMs: usage?.newestMtimeMs ?? 0,
295
- lastActivityMs,
296
230
  project,
297
231
  lockfiles: rule.lockfiles ?? null,
298
- ...projectTier(context, rule, lastActivityMs),
232
+ ...projectTier(rule),
299
233
  checks: { busy: rule.busy ?? [], cwd: project, mtime: true },
300
234
  });
301
235
  await addBlockers(context, item, false);
@@ -397,29 +331,51 @@ async function rustItems(context, targetItem, rule) {
397
331
  */
398
332
  export async function scanProjects(context, { rules = PROJECT_RULES } = {}) {
399
333
  const { env, options } = context;
400
- const globs = [...new Set(rules.flatMap((rule) => rule.names))];
401
334
  const items = scanItems(context);
402
- await findProjectDirs(env, options.roots, {
403
- rules,
404
- maxDepth: options.maxDepth,
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;
335
+ const started = performance.now();
336
+ // Matches are measured while the walk continues; `projects:walk` excludes
337
+ // that time, which `projects:measure`, `projects:git` and `projects:busy`
338
+ // record.
339
+ let streamedMs = 0;
340
+ try {
341
+ await findProjectDirs(env, options.roots, {
342
+ rules,
343
+ maxDepth: options.maxDepth,
344
+ includeRoots: options.includeRoots,
345
+ onMatches: async (matches) => {
346
+ const streamStarted = performance.now();
347
+ try {
348
+ await projectMatches(context, matches, items);
349
+ } finally {
350
+ streamedMs += performance.now() - streamStarted;
415
351
  }
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
- },
423
- });
352
+ },
353
+ });
354
+ } finally {
355
+ context.timings?.record(
356
+ 'projects:walk',
357
+ performance.now() - started - streamedMs
358
+ );
359
+ }
424
360
  return items;
425
361
  }
362
+
363
+ async function projectMatches(context, matches, items) {
364
+ const usages = await timed(context.timings, 'projects:measure', () =>
365
+ context.env.usageMany(
366
+ matches.map((match) => match.candidate.path),
367
+ { cache: true }
368
+ )
369
+ );
370
+ for (const match of matches) {
371
+ const usage = usages.get(match.candidate.path);
372
+ if (!usage) {
373
+ continue;
374
+ }
375
+ const item = await projectItem(context, match, usage);
376
+ items.push(item);
377
+ if (match.rule.id === 'cargo-target') {
378
+ items.push(...(await rustItems(context, item, match.rule)));
379
+ }
380
+ }
381
+ }
@@ -6,7 +6,9 @@ import { scanItems } from '../env/scope.js';
6
6
  * Kept: the manager's default and alias targets, versions pinned by any
7
7
  * project below the scan roots, the newest install of each group, and
8
8
  * anything a running process executes from (checked by the liveness probe).
9
- * Everything else is reported as `moderate`.
9
+ * Everything else is reported as `moderate`, and blocked while it holds
10
+ * data that reinstalling the version would not bring back (pyenv virtual
11
+ * environments, globally installed npm packages).
10
12
  */
11
13
 
12
14
  import {
@@ -171,6 +173,98 @@ function newestInstalls(env, rule, installs, usages) {
171
173
  return newest;
172
174
  }
173
175
 
176
+ /**
177
+ * Follows a chain of manager aliases (`default` -> `lts/iron` -> `v20.20.2`).
178
+ * An LTS alias also keeps its release line: nvm records the newest remote
179
+ * release there, which may not be the one installed.
180
+ * @returns {Promise<string[]>}
181
+ */
182
+ async function resolveAlias(env, aliasDir, ref) {
183
+ const refs = [ref];
184
+ let current = ref;
185
+ for (let hop = 0; hop < 8 && !/^v?\d/.test(current); hop++) {
186
+ if (current.split('/').some((part) => part === '..' || part === '')) {
187
+ break;
188
+ }
189
+ const text = await env.readText(env.path.join(aliasDir, current), 4096);
190
+ const next = (text ?? '').split(/\s+/).find(Boolean);
191
+ if (!next || refs.includes(next)) {
192
+ break;
193
+ }
194
+ refs.push(next);
195
+ current = next;
196
+ }
197
+ if (ref.startsWith('lts/') && /^v?\d/.test(current)) {
198
+ refs.push(current.replace(/^v?(\d+).*$/, 'v$1'));
199
+ }
200
+ return refs;
201
+ }
202
+
203
+ async function resolveAliases(env, rule, home, refs) {
204
+ if (!rule.aliasDir) {
205
+ return refs;
206
+ }
207
+ const aliasDir = expandRulePath(configuredPattern(env, home, rule.aliasDir), {
208
+ home,
209
+ pathApi: env.path,
210
+ });
211
+ const resolved = [];
212
+ for (const ref of refs) {
213
+ resolved.push(...(await resolveAlias(env, aliasDir, ref)));
214
+ }
215
+ return [...new Set(resolved)];
216
+ }
217
+
218
+ /**
219
+ * Installs that the refs keep. With `refsPickNewest` a ref keeps only the
220
+ * newest install it matches, as nvm resolves `20` to the newest 20.x.
221
+ * @returns {Set<string>}
222
+ */
223
+ function referencedInstalls(rule, installs, refs, versionOf) {
224
+ const kept = new Set();
225
+ for (const ref of refs) {
226
+ const matching = installs
227
+ .filter((dir) => versionMatches(versionOf(dir), ref))
228
+ .sort((a, b) => compareVersions(versionOf(b), versionOf(a)));
229
+ for (const dir of rule.refsPickNewest ? matching.slice(0, 1) : matching) {
230
+ kept.add(dir);
231
+ }
232
+ }
233
+ return kept;
234
+ }
235
+
236
+ /**
237
+ * Global packages a user installed into a version (`npm install -g`) are
238
+ * lost with it. Packages bundled with the runtime and caches that are
239
+ * installed again on demand (`rule.globalPackages.regenerable`) are not.
240
+ */
241
+ async function blockGlobalPackages(env, rule, dir, item) {
242
+ if (!rule.globalPackages) {
243
+ return;
244
+ }
245
+ const names = [];
246
+ const root = env.path.join(dir, rule.globalPackages.dir);
247
+ for (const entry of await env.list(root)) {
248
+ if (entry.name.startsWith('.')) {
249
+ continue;
250
+ }
251
+ if (!entry.name.startsWith('@')) {
252
+ names.push(entry.name);
253
+ continue;
254
+ }
255
+ const scoped = await env.list(entry.path);
256
+ names.push(...scoped.map((child) => `${entry.name}/${child.name}`));
257
+ }
258
+ const own = names
259
+ .filter((name) => !rule.globalPackages.regenerable(name))
260
+ .sort();
261
+ if (own.length > 0) {
262
+ const shown = own.slice(0, 5).join(', ');
263
+ const more = own.length > 5 ? ` and ${own.length - 5} more` : '';
264
+ block(item, `has globally installed packages: ${shown}${more}`);
265
+ }
266
+ }
267
+
174
268
  async function blockPyenvEnvironments(env, rule, dir, item) {
175
269
  if (rule.id !== 'pyenv-python') {
176
270
  return;
@@ -189,17 +283,21 @@ async function ruleItems(context, rule, home, pins) {
189
283
  if (rule.protectAll) {
190
284
  return [];
191
285
  }
192
- const refs = [...(await managerRefs(env, rule, home, installs)), ...pins];
286
+ const refs = await resolveAliases(env, rule, home, [
287
+ ...(await managerRefs(env, rule, home, installs)),
288
+ ...pins,
289
+ ]);
193
290
  const usages = await env.usageMany(installs, {
194
291
  cache: !context.options.freshSizes,
195
292
  });
196
293
  await resolvePaths(context, installs);
197
294
  const newest = newestInstalls(env, rule, installs, usages);
198
295
  const versionOf = rule.versionOf ?? ((dir) => env.path.basename(dir));
296
+ const referenced = referencedInstalls(rule, installs, refs, versionOf);
199
297
  const items = scanItems(context);
200
298
  for (const dir of installs) {
201
299
  const version = versionOf(dir);
202
- if (newest.has(dir) || refs.some((ref) => versionMatches(version, ref))) {
300
+ if (newest.has(dir) || referenced.has(dir)) {
203
301
  continue;
204
302
  }
205
303
  const usage = usages.get(dir) ?? {};
@@ -222,6 +320,7 @@ async function ruleItems(context, rule, home, pins) {
222
320
  : null,
223
321
  });
224
322
  await blockPyenvEnvironments(env, rule, dir, item);
323
+ await blockGlobalPackages(env, rule, dir, item);
225
324
  const busy = await scanTimeBusy(context, item);
226
325
  if (busy) {
227
326
  block(item, `busy: ${busy}`);
package/src/timings.js ADDED
@@ -0,0 +1,54 @@
1
+ import { performance } from 'node:perf_hooks';
2
+
3
+ /**
4
+ * Wall-clock phase timings.
5
+ *
6
+ * Collected on every run, not only with `--verbose`, so reports and audit
7
+ * logs show which phase was slow. A phase that throws or is aborted (for
8
+ * example by the scan budget) still records the time it consumed.
9
+ */
10
+
11
+ export class PhaseTimer {
12
+ constructor() {
13
+ this.phases = new Map();
14
+ }
15
+
16
+ /**
17
+ * Runs `fn` and adds its duration to `name`.
18
+ * @template T
19
+ * @param {string} name
20
+ * @param {() => Promise<T>|T} fn
21
+ * @returns {Promise<T>}
22
+ */
23
+ async measure(name, fn) {
24
+ const started = performance.now();
25
+ try {
26
+ return await fn();
27
+ } finally {
28
+ this.record(name, performance.now() - started);
29
+ }
30
+ }
31
+
32
+ record(name, ms) {
33
+ const phase = this.phases.get(name) ?? { phase: name, ms: 0, count: 0 };
34
+ phase.ms += ms;
35
+ phase.count++;
36
+ this.phases.set(name, phase);
37
+ }
38
+
39
+ /**
40
+ * @returns {Array<{phase: string, ms: number, count: number}>} slowest first
41
+ */
42
+ toJSON() {
43
+ return [...this.phases.values()]
44
+ .map((phase) => ({ ...phase, ms: Math.round(phase.ms) }))
45
+ .sort((a, b) => b.ms - a.ms);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Measures `fn` when `timer` exists, otherwise just runs it.
51
+ */
52
+ export function timed(timer, name, fn) {
53
+ return timer ? timer.measure(name, fn) : fn();
54
+ }