disk-space-saviour 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +35 -12
- package/package.json +1 -1
- package/src/clean.js +13 -0
- package/src/env/local.js +21 -1
- package/src/git-remote.js +168 -0
- package/src/git.js +147 -6
- package/src/index.d.ts +11 -1
- package/src/liveness.js +46 -0
- package/src/playwright-registry.js +77 -0
- package/src/report.js +29 -4
- package/src/rules/apps.js +3 -2
- package/src/rules/downloads.js +5 -3
- package/src/rules/other.js +56 -10
- package/src/scan.js +7 -3
- package/src/scanners/app-clones.js +53 -0
- package/src/scanners/common.js +1 -0
- package/src/scanners/global.js +92 -10
- package/src/scanners/versions.js +9 -6
- package/src/swap.js +286 -28
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser revisions that installed Playwright packages still launch.
|
|
3
|
+
*
|
|
4
|
+
* Playwright records every package it installed browsers for as a file in
|
|
5
|
+
* `<registry>/.links/` holding that package's directory, and the package's
|
|
6
|
+
* `browsers.json` names the revisions it launches. Playwright's own cleanup
|
|
7
|
+
* keeps a revision while a linked package that still exists names it; dss
|
|
8
|
+
* keeps the same revisions, so deleting one never breaks an installed
|
|
9
|
+
* project until its next `playwright install`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Directory of a registry revision: `chromium-headless-shell` → `chromium_headless_shell-1248`. */
|
|
13
|
+
export function browserDirName(name, revision) {
|
|
14
|
+
return `${name.replace(/-/g, '_')}-${revision}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Revision directories one `browsers.json` names, platform overrides included. */
|
|
18
|
+
export function requiredDirs(text) {
|
|
19
|
+
const browsers = JSON.parse(text)?.browsers;
|
|
20
|
+
if (!Array.isArray(browsers)) {
|
|
21
|
+
throw new Error('no browsers list');
|
|
22
|
+
}
|
|
23
|
+
return browsers.flatMap((browser) =>
|
|
24
|
+
[browser.revision, ...Object.values(browser.revisionOverrides ?? {})]
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.map((revision) => browserDirName(browser.name, revision))
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Maps each revision directory name to the linked packages that need it;
|
|
32
|
+
* `unreadable` lists links whose package exists but cannot be read.
|
|
33
|
+
*/
|
|
34
|
+
export async function playwrightUsers(env, registry) {
|
|
35
|
+
const users = new Map();
|
|
36
|
+
const unreadable = [];
|
|
37
|
+
for (const link of await env.list(env.path.join(registry, '.links'))) {
|
|
38
|
+
const pkg = (await env.readText(link.path))?.trim();
|
|
39
|
+
if (!pkg) {
|
|
40
|
+
unreadable.push(link.path);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const manifest = env.path.join(pkg, 'browsers.json');
|
|
44
|
+
if (!(await env.exists(manifest))) {
|
|
45
|
+
// The project or its node_modules is gone; Playwright drops the link too.
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
for (const dir of requiredDirs((await env.readText(manifest)) ?? '')) {
|
|
50
|
+
users.set(dir, [...(users.get(dir) ?? []), pkg]);
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
unreadable.push(manifest);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { users, unreadable };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Why `target`, a revision directory in a Playwright registry, must stay;
|
|
61
|
+
* null when no installed package needs it.
|
|
62
|
+
* @param {Map<string, Promise<object>>} [cache] registry → playwrightUsers
|
|
63
|
+
*/
|
|
64
|
+
export async function playwrightBlocker(env, target, cache = new Map()) {
|
|
65
|
+
const registry = env.path.dirname(target);
|
|
66
|
+
if (!cache.has(registry)) {
|
|
67
|
+
cache.set(registry, playwrightUsers(env, registry));
|
|
68
|
+
}
|
|
69
|
+
const { users, unreadable } = await cache.get(registry);
|
|
70
|
+
if (unreadable.length > 0) {
|
|
71
|
+
return `cannot read Playwright install record ${unreadable[0]}`;
|
|
72
|
+
}
|
|
73
|
+
const needed = users.get(env.path.basename(target));
|
|
74
|
+
return needed
|
|
75
|
+
? `Playwright in ${needed[0]} still uses ${env.path.basename(target)}`
|
|
76
|
+
: null;
|
|
77
|
+
}
|
package/src/report.js
CHANGED
|
@@ -82,7 +82,14 @@ function tierSection(report, tier, options) {
|
|
|
82
82
|
'',
|
|
83
83
|
`${tier.toUpperCase()} ${plural(items.length, 'item')}, ${formatBytes(bytes)}`,
|
|
84
84
|
...limited(
|
|
85
|
-
items.
|
|
85
|
+
items.flatMap((item) => [
|
|
86
|
+
itemLine(item, showEnv),
|
|
87
|
+
...(item.globalPackages?.length
|
|
88
|
+
? [
|
|
89
|
+
` also removes global packages: ${item.globalPackages.join(', ')}`,
|
|
90
|
+
]
|
|
91
|
+
: []),
|
|
92
|
+
]),
|
|
86
93
|
options.limit
|
|
87
94
|
),
|
|
88
95
|
];
|
|
@@ -351,6 +358,23 @@ function timingLines(report, { verbose }) {
|
|
|
351
358
|
});
|
|
352
359
|
}
|
|
353
360
|
|
|
361
|
+
function holderLine(holder) {
|
|
362
|
+
const details = [`${formatBytes(holder.memoryBytes)} memory`];
|
|
363
|
+
if (holder.compressedBytes !== undefined) {
|
|
364
|
+
details.push(`${formatBytes(holder.compressedBytes)} compressed`);
|
|
365
|
+
}
|
|
366
|
+
if (holder.swappedBytes !== undefined) {
|
|
367
|
+
details.push(`${formatBytes(holder.swappedBytes)} swapped`);
|
|
368
|
+
}
|
|
369
|
+
const change = holder.growth?.memoryBytes;
|
|
370
|
+
if (change) {
|
|
371
|
+
details.push(
|
|
372
|
+
`${change > 0 ? '+' : '-'}${formatBytes(Math.abs(change))} since ${new Date(holder.growth.since).toISOString()}`
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return `${holder.pid} ${holder.command}: ${details.join(', ')}`;
|
|
376
|
+
}
|
|
377
|
+
|
|
354
378
|
/**
|
|
355
379
|
* Renders a scan report.
|
|
356
380
|
* @param {object} report
|
|
@@ -368,11 +392,12 @@ export function formatReport(report, options = {}) {
|
|
|
368
392
|
lines.push(
|
|
369
393
|
`Swap: ${formatBytes(host.swap.used)} used of ${formatBytes(host.swap.total)} (managed by macOS)`
|
|
370
394
|
);
|
|
395
|
+
for (const warning of host.swap.warnings ?? []) {
|
|
396
|
+
lines.push(`WARNING: ${warning}`);
|
|
397
|
+
}
|
|
371
398
|
lines.push(host.swap.attribution);
|
|
372
399
|
for (const holder of host.swap.holders) {
|
|
373
|
-
lines.push(
|
|
374
|
-
` ${holder.pid} ${holder.command}: ${formatBytes(holder.memoryBytes)} memory, ${formatBytes(holder.compressedBytes)} compressed`
|
|
375
|
-
);
|
|
400
|
+
lines.push(` ${holderLine(holder)}`);
|
|
376
401
|
}
|
|
377
402
|
lines.push(host.swap.advice);
|
|
378
403
|
}
|
package/src/rules/apps.js
CHANGED
|
@@ -36,13 +36,14 @@ export const APP_RULES = [
|
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
id: 'telegram-downloaded-media',
|
|
39
|
-
excludeNames: ['cache', 'cache-storage', 'short-cache'],
|
|
40
39
|
ecosystem: 'apps',
|
|
41
40
|
tier: 'moderate',
|
|
42
41
|
description:
|
|
43
42
|
'Telegram downloaded media: secret-chat media may not be recoverable',
|
|
43
|
+
// Only media Telegram downloads again from its cloud; `storage` holds
|
|
44
|
+
// the media database and `local-file-*` files the user sent.
|
|
44
45
|
paths: [
|
|
45
|
-
`${telegramAccounts}
|
|
46
|
+
`${telegramAccounts}/telegram-cloud-*`,
|
|
46
47
|
...telegramDesktop.map((root) => `${root}/user_data/downloads`),
|
|
47
48
|
],
|
|
48
49
|
busy: ['Telegram', 'telegram-desktop'],
|
package/src/rules/downloads.js
CHANGED
|
@@ -20,10 +20,12 @@ export const DOWNLOAD_RULES = [
|
|
|
20
20
|
description:
|
|
21
21
|
'Chromium app bundle clones left by exited browsers (an update makes them full copies)',
|
|
22
22
|
// Chrome clones its bundle into the per-user `X` directory next to
|
|
23
|
-
// `$TMPDIR` at launch and removes the clone when it exits. A
|
|
24
|
-
//
|
|
25
|
-
//
|
|
23
|
+
// `$TMPDIR` at launch and removes the clone when it exits. A clone
|
|
24
|
+
// stays while any process runs its hard-linked main executable (see
|
|
25
|
+
// ../scanners/app-clones.js): a browser name would block orphans
|
|
26
|
+
// forever, and open files miss a browser started before the update.
|
|
26
27
|
paths: ['{TMP}/../X/*.code_sign_clone/code_sign_clone.*'],
|
|
28
|
+
appClone: { installed: ['/Applications', '~/Applications'] },
|
|
27
29
|
directoryOnly: true,
|
|
28
30
|
minAge: 'stale',
|
|
29
31
|
busy: [],
|
package/src/rules/other.js
CHANGED
|
@@ -12,7 +12,11 @@
|
|
|
12
12
|
* otherwise each match is its own item. `minAge: 'stale'` only reports
|
|
13
13
|
* matches untouched for the stale-age window, `minAge: 'inactive'` for the
|
|
14
14
|
* project inactivity window. `git: true` refuses matches that are Git work
|
|
15
|
-
* trees with uncommitted, unpushed, or stashed changes.
|
|
15
|
+
* trees with uncommitted, unpushed, or stashed changes. `git: 'clone'`
|
|
16
|
+
* matches only Git work trees with an https or ssh remote, and refuses them
|
|
17
|
+
* unless every worktree is clean and the remote still advertises every
|
|
18
|
+
* local commit. `registry: 'playwright'` keeps revisions that a Playwright
|
|
19
|
+
* package recorded in the registry's `.links` still launches.
|
|
16
20
|
*/
|
|
17
21
|
|
|
18
22
|
export const OTHER_RULES = [
|
|
@@ -29,15 +33,10 @@ export const OTHER_RULES = [
|
|
|
29
33
|
versionPattern: /^(?<family>[a-z_]+)-(?<version>\d+)$/,
|
|
30
34
|
minAge: 'inactive',
|
|
31
35
|
tier: 'safe',
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
'chromium',
|
|
37
|
-
'headless_shell',
|
|
38
|
-
'firefox',
|
|
39
|
-
'webkit',
|
|
40
|
-
],
|
|
36
|
+
registry: 'playwright',
|
|
37
|
+
// A running Playwright launches the revisions its package names, which
|
|
38
|
+
// the registry check keeps; a revision a browser runs from is open.
|
|
39
|
+
busy: [],
|
|
41
40
|
},
|
|
42
41
|
{
|
|
43
42
|
id: 'playwright-go-drivers',
|
|
@@ -519,6 +518,23 @@ export const OTHER_RULES = [
|
|
|
519
518
|
minAge: 'stale',
|
|
520
519
|
busy: ['chrome', 'chromium', 'headless_shell'],
|
|
521
520
|
},
|
|
521
|
+
{
|
|
522
|
+
id: 'tmp-playwright-browsers',
|
|
523
|
+
ecosystem: 'tmp',
|
|
524
|
+
scope: 'tmp',
|
|
525
|
+
description:
|
|
526
|
+
'Playwright browser installs and downloads in temporary directories',
|
|
527
|
+
paths: [
|
|
528
|
+
'{TMP}/playwright-download-*',
|
|
529
|
+
'{TMP}/ms-playwright',
|
|
530
|
+
'{TMP}/ms-playwright-*',
|
|
531
|
+
'{TMP}/*/ms-playwright',
|
|
532
|
+
'{TMP}/*/.cache/ms-playwright',
|
|
533
|
+
'{TMP}/*/Library/Caches/ms-playwright',
|
|
534
|
+
],
|
|
535
|
+
minAge: 'stale',
|
|
536
|
+
busy: ['playwright', 'node', 'chrome', 'chromium', 'headless_shell'],
|
|
537
|
+
},
|
|
522
538
|
{
|
|
523
539
|
id: 'node-tmp-caches',
|
|
524
540
|
ecosystem: 'tmp',
|
|
@@ -533,3 +549,33 @@ export const OTHER_RULES = [
|
|
|
533
549
|
busy: ['node'],
|
|
534
550
|
},
|
|
535
551
|
];
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Checkouts cloned into temporary directories by tools, tests and agents,
|
|
555
|
+
* checked after every named rule so a more specific rule claims its own
|
|
556
|
+
* paths first.
|
|
557
|
+
*/
|
|
558
|
+
export const TEMP_CLONE_RULES = [
|
|
559
|
+
{
|
|
560
|
+
id: 'temp-git-clones',
|
|
561
|
+
ecosystem: 'tmp',
|
|
562
|
+
scope: 'tmp',
|
|
563
|
+
description:
|
|
564
|
+
'Git clones in temporary directories whose commits all exist on a remote',
|
|
565
|
+
tier: 'safe',
|
|
566
|
+
paths: ['{TMP}/*', '{TMP}/*/*'],
|
|
567
|
+
directoryOnly: true,
|
|
568
|
+
minAge: 'stale',
|
|
569
|
+
git: 'clone',
|
|
570
|
+
busy: [
|
|
571
|
+
'git',
|
|
572
|
+
'claude',
|
|
573
|
+
'codex',
|
|
574
|
+
'opencode',
|
|
575
|
+
'agent',
|
|
576
|
+
'node',
|
|
577
|
+
'bun',
|
|
578
|
+
'cargo',
|
|
579
|
+
],
|
|
580
|
+
},
|
|
581
|
+
];
|
package/src/scan.js
CHANGED
|
@@ -530,13 +530,17 @@ function envDescriptor(env, depth, chain) {
|
|
|
530
530
|
};
|
|
531
531
|
}
|
|
532
532
|
|
|
533
|
-
async function withDisk(env, descriptor) {
|
|
533
|
+
async function withDisk(env, descriptor, options) {
|
|
534
534
|
const target = env.platform === 'win32' ? env.currentHome : '/';
|
|
535
535
|
const disk = await env.diskUsage(target).catch(() => null);
|
|
536
536
|
return {
|
|
537
537
|
...descriptor,
|
|
538
538
|
disk: disk ? { path: target, ...disk } : null,
|
|
539
|
-
swap: await readSwap(env
|
|
539
|
+
swap: await readSwap(env, {
|
|
540
|
+
stateDir: options.cacheDir,
|
|
541
|
+
disk,
|
|
542
|
+
now: options.now?.(),
|
|
543
|
+
}),
|
|
540
544
|
};
|
|
541
545
|
}
|
|
542
546
|
|
|
@@ -666,7 +670,7 @@ async function scanScoped(input) {
|
|
|
666
670
|
environments.push(inner);
|
|
667
671
|
}
|
|
668
672
|
environments[0] = await timed(env.timings, 'disk', () =>
|
|
669
|
-
withDisk(env, environments[0])
|
|
673
|
+
withDisk(env, environments[0], options)
|
|
670
674
|
);
|
|
671
675
|
environments[0].timings = env.timings.toJSON();
|
|
672
676
|
const report = buildReport({
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chromium browsers on macOS clone their app bundle at launch with APFS
|
|
3
|
+
* `clonefile` and replace the clone's main executable with a hard link to
|
|
4
|
+
* the installed one (chrome/browser/mac/code_sign_clone_manager.mm). The
|
|
5
|
+
* browser holds no file open inside its clone: it runs the shared inode,
|
|
6
|
+
* which `lsof` reports under the installed path until an update replaces
|
|
7
|
+
* the app. After an update the clone keeps the running browser's code
|
|
8
|
+
* signature valid, so a clone stays while any process runs its executable.
|
|
9
|
+
* Until then the clone shares every block with the installed app, and
|
|
10
|
+
* removing it frees nothing but directory entries.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const BUNDLE_SUFFIX = '.bundle';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Main executables of the bundle cloned in `clone`, and whether the clone
|
|
17
|
+
* still shares its blocks with an app installed in one of `appDirs`.
|
|
18
|
+
* @returns {Promise<{executables: string[], sharedWith: string|null}>}
|
|
19
|
+
*/
|
|
20
|
+
export async function appCloneDetails(env, clone, appDirs) {
|
|
21
|
+
const executables = [];
|
|
22
|
+
let sharedWith = null;
|
|
23
|
+
for (const bundle of await env.list(clone)) {
|
|
24
|
+
if (
|
|
25
|
+
bundle.type !== 'dir' ||
|
|
26
|
+
!bundle.name.endsWith(`.app${BUNDLE_SUFFIX}`)
|
|
27
|
+
) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const appName = bundle.name.slice(0, -BUNDLE_SUFFIX.length);
|
|
31
|
+
const macos = env.path.join(bundle.path, 'Contents', 'MacOS');
|
|
32
|
+
for (const entry of await env.list(macos)) {
|
|
33
|
+
if (entry.type !== 'file') {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
executables.push(entry.path);
|
|
37
|
+
sharedWith ??= await installedCopy(env, entry, appName, appDirs);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { executables, sharedWith };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function installedCopy(env, executable, appName, appDirs) {
|
|
44
|
+
const inode = (await env.stat(executable.path))?.inode;
|
|
45
|
+
for (const dir of appDirs) {
|
|
46
|
+
const app = env.path.join(dir, appName);
|
|
47
|
+
const installed = env.path.join(app, 'Contents', 'MacOS', executable.name);
|
|
48
|
+
if (inode && (await env.stat(installed))?.inode === inode) {
|
|
49
|
+
return app;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
package/src/scanners/common.js
CHANGED
|
@@ -27,6 +27,7 @@ export async function listMany(env, dirs) {
|
|
|
27
27
|
*/
|
|
28
28
|
export async function scanTimeBusy(context, item) {
|
|
29
29
|
await resolvePaths(context, item.paths);
|
|
30
|
+
await context.liveness?.resolveExecutables(item.checks?.executables);
|
|
30
31
|
return context.liveness ? context.liveness.busyReason(item) : null;
|
|
31
32
|
}
|
|
32
33
|
|
package/src/scanners/global.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { scanItems } from '../env/scope.js';
|
|
1
|
+
import { checkSignal, 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
|
|
@@ -7,7 +7,7 @@ import { scanItems } from '../env/scope.js';
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { CACHE_RULES } from '../rules/ecosystems.js';
|
|
10
|
-
import { OTHER_RULES } from '../rules/other.js';
|
|
10
|
+
import { OTHER_RULES, TEMP_CLONE_RULES } from '../rules/other.js';
|
|
11
11
|
import { APP_RULES, expandChromiumCaches } from '../rules/apps.js';
|
|
12
12
|
import { VERSION_RULES, compareVersions } from '../rules/versions.js';
|
|
13
13
|
import { block, makeItem } from '../items.js';
|
|
@@ -17,6 +17,10 @@ import { configuredPattern } from '../env/cache-roots.js';
|
|
|
17
17
|
import { TOOLCHAIN_CACHE_RULES } from '../rules/toolchains.js';
|
|
18
18
|
import { DOWNLOAD_RULES } from '../rules/downloads.js';
|
|
19
19
|
import { timed } from '../timings.js';
|
|
20
|
+
import { appCloneDetails } from './app-clones.js';
|
|
21
|
+
import { gitDirectories } from '../git.js';
|
|
22
|
+
import { networkRemotes } from '../git-remote.js';
|
|
23
|
+
import { playwrightBlocker } from '../playwright-registry.js';
|
|
20
24
|
|
|
21
25
|
export const GLOBAL_RULES = [
|
|
22
26
|
...CACHE_RULES,
|
|
@@ -24,6 +28,7 @@ export const GLOBAL_RULES = [
|
|
|
24
28
|
...APP_RULES,
|
|
25
29
|
...TOOLCHAIN_CACHE_RULES,
|
|
26
30
|
...DOWNLOAD_RULES,
|
|
31
|
+
...TEMP_CLONE_RULES,
|
|
27
32
|
];
|
|
28
33
|
|
|
29
34
|
function scopeBases(rule, homes, tmpDirs) {
|
|
@@ -173,7 +178,13 @@ function olderVersions(env, rule, paths, stats) {
|
|
|
173
178
|
return paths.filter((target) => old.has(target));
|
|
174
179
|
}
|
|
175
180
|
|
|
176
|
-
|
|
181
|
+
/** What cleanup re-verifies right before deleting, by the rule's `git` mode. */
|
|
182
|
+
const GIT_RECHECKS = {
|
|
183
|
+
ifPresent: { type: 'scratch' },
|
|
184
|
+
clone: { type: 'clone' },
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
async function buildItem(context, rule, base, group, homes) {
|
|
177
188
|
const { env } = context;
|
|
178
189
|
const paths = group.map((entry) => entry.path);
|
|
179
190
|
const appRoot = rule.markers
|
|
@@ -195,7 +206,7 @@ async function buildItem(context, rule, base, group) {
|
|
|
195
206
|
reason: rule.minAge
|
|
196
207
|
? `untouched for longer than the ${rule.minAge === 'stale' ? 'activity' : 'inactivity'} window`
|
|
197
208
|
: 're-downloaded or re-created on demand',
|
|
198
|
-
recheck: rule.git
|
|
209
|
+
recheck: GIT_RECHECKS[rule.git],
|
|
199
210
|
action: action ?? { type: 'none' },
|
|
200
211
|
checks: {
|
|
201
212
|
busy: [
|
|
@@ -207,10 +218,31 @@ async function buildItem(context, rule, base, group) {
|
|
|
207
218
|
},
|
|
208
219
|
});
|
|
209
220
|
block(item, blocker);
|
|
221
|
+
if (rule.appClone) {
|
|
222
|
+
await describeAppClone(context, rule, item, homes);
|
|
223
|
+
}
|
|
210
224
|
await addBlockers(context, rule, item);
|
|
211
225
|
return item;
|
|
212
226
|
}
|
|
213
227
|
|
|
228
|
+
async function describeAppClone(context, rule, item, homes) {
|
|
229
|
+
const { env } = context;
|
|
230
|
+
const appDirs = rule.appClone.installed
|
|
231
|
+
.flatMap((dir) =>
|
|
232
|
+
homes.map((home) => expandRulePath(dir, { home, pathApi: env.path }))
|
|
233
|
+
)
|
|
234
|
+
.filter(Boolean);
|
|
235
|
+
const { executables, sharedWith } = await appCloneDetails(env, item.path, [
|
|
236
|
+
...new Set(appDirs),
|
|
237
|
+
]);
|
|
238
|
+
item.checks.executables = executables;
|
|
239
|
+
if (sharedWith) {
|
|
240
|
+
// Every block is shared with the installed app: nothing to reclaim.
|
|
241
|
+
item.bytes = 0;
|
|
242
|
+
item.reason = `shares its disk blocks with ${sharedWith}`;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
214
246
|
async function addBlockers(context, rule, item) {
|
|
215
247
|
const { env, git } = context;
|
|
216
248
|
if (rule.scope === 'system' && !(await env.isRoot())) {
|
|
@@ -218,13 +250,24 @@ async function addBlockers(context, rule, item) {
|
|
|
218
250
|
}
|
|
219
251
|
if (rule.git) {
|
|
220
252
|
for (const target of item.paths) {
|
|
221
|
-
for (const reason of
|
|
222
|
-
|
|
223
|
-
|
|
253
|
+
for (const reason of rule.git === 'clone'
|
|
254
|
+
? await git.cloneBlockers(target)
|
|
255
|
+
: await git.treeBlockers(target, {
|
|
256
|
+
allowNoRepo: rule.git === 'ifPresent',
|
|
257
|
+
})) {
|
|
224
258
|
block(item, reason);
|
|
225
259
|
}
|
|
226
260
|
}
|
|
227
261
|
}
|
|
262
|
+
if (rule.registry === 'playwright') {
|
|
263
|
+
context.playwrightRegistries ??= new Map();
|
|
264
|
+
for (const target of item.paths) {
|
|
265
|
+
block(
|
|
266
|
+
item,
|
|
267
|
+
await playwrightBlocker(env, target, context.playwrightRegistries)
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
228
271
|
const busy = await scanTimeBusy(context, item);
|
|
229
272
|
if (busy) {
|
|
230
273
|
block(item, `busy: ${busy}`);
|
|
@@ -292,9 +335,12 @@ export async function scanGlobal(
|
|
|
292
335
|
|
|
293
336
|
const accepted = [];
|
|
294
337
|
for (const target of candidates) {
|
|
338
|
+
// Acceptance can read Git metadata of thousands of temporary entries
|
|
339
|
+
// without passing through the environment, so honour the budget here.
|
|
340
|
+
checkSignal(env.signal);
|
|
295
341
|
if (
|
|
296
342
|
!(await timed(context.timings, 'global:accept', () =>
|
|
297
|
-
acceptedPath(env, rule, target, stats)
|
|
343
|
+
acceptedPath(env, rule, target, stats, seen)
|
|
298
344
|
))
|
|
299
345
|
) {
|
|
300
346
|
continue;
|
|
@@ -338,7 +384,7 @@ export async function scanGlobal(
|
|
|
338
384
|
: measured.filter(large).map((entry) => [entry]);
|
|
339
385
|
for (const group of groups.filter((g) => g.length > 0)) {
|
|
340
386
|
const item = await timed(context.timings, 'global:item', () =>
|
|
341
|
-
buildItem(context, rule, base, group)
|
|
387
|
+
buildItem(context, rule, base, group, homes)
|
|
342
388
|
);
|
|
343
389
|
items.push(item);
|
|
344
390
|
if ((await context.ready?.([item])) === false) {
|
|
@@ -349,7 +395,40 @@ export async function scanGlobal(
|
|
|
349
395
|
return items;
|
|
350
396
|
}
|
|
351
397
|
|
|
352
|
-
|
|
398
|
+
/**
|
|
399
|
+
* Whether `target` or one of its ancestors is in `claimed`. Both come from
|
|
400
|
+
* the same glob expansion, so ancestors compare as plain strings and the
|
|
401
|
+
* cost is the path depth, not the number of claimed paths.
|
|
402
|
+
*/
|
|
403
|
+
function claimedAbove(env, target, claimed) {
|
|
404
|
+
for (let dir = target; ; dir = env.path.dirname(dir)) {
|
|
405
|
+
if (claimed.has(dir)) {
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
if (env.path.dirname(dir) === dir) {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* A temporary directory holding a Git work tree with a network remote,
|
|
416
|
+
* outside every path another rule (or a parent clone) already claimed.
|
|
417
|
+
*/
|
|
418
|
+
async function tempClone(env, target, seen) {
|
|
419
|
+
if (env.kind !== 'host' || claimedAbove(env, target, seen)) {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
// Other users' temporary directories are unreadable; that is no error.
|
|
424
|
+
const { commonDir } = await gitDirectories(target);
|
|
425
|
+
return (await networkRemotes(commonDir)).length > 0;
|
|
426
|
+
} catch {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function acceptedPath(env, rule, target, stats, seen = new Set()) {
|
|
353
432
|
const name = env.path.basename(target);
|
|
354
433
|
if (rule.excludeNames?.includes(name)) {
|
|
355
434
|
return false;
|
|
@@ -357,6 +436,9 @@ async function acceptedPath(env, rule, target, stats) {
|
|
|
357
436
|
if (rule.directoryOnly && stats.get(target)?.type !== 'dir') {
|
|
358
437
|
return false;
|
|
359
438
|
}
|
|
439
|
+
if (rule.git === 'clone' && !(await tempClone(env, target, seen))) {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
360
442
|
if (rule.fileOnly && stats.get(target)?.type !== 'file') {
|
|
361
443
|
return false;
|
|
362
444
|
}
|
package/src/scanners/versions.js
CHANGED
|
@@ -235,11 +235,13 @@ function referencedInstalls(rule, installs, refs, versionOf) {
|
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
/**
|
|
238
|
-
* Global packages a user installed into a version (`npm install -g`)
|
|
239
|
-
*
|
|
240
|
-
* installed again on demand (`rule.globalPackages.regenerable`) are
|
|
238
|
+
* Global packages a user installed into a version (`npm install -g`) go
|
|
239
|
+
* with it, so the item names them. Packages bundled with the runtime and
|
|
240
|
+
* caches installed again on demand (`rule.globalPackages.regenerable`) are
|
|
241
|
+
* not named. A package that is running keeps the version through the
|
|
242
|
+
* process checks: its binary, arguments or PATH entry lies inside.
|
|
241
243
|
*/
|
|
242
|
-
async function
|
|
244
|
+
async function noteGlobalPackages(env, rule, dir, item) {
|
|
243
245
|
if (!rule.globalPackages) {
|
|
244
246
|
return;
|
|
245
247
|
}
|
|
@@ -262,7 +264,8 @@ async function blockGlobalPackages(env, rule, dir, item) {
|
|
|
262
264
|
if (own.length > 0) {
|
|
263
265
|
const shown = own.slice(0, 5).join(', ');
|
|
264
266
|
const more = own.length > 5 ? ` and ${own.length - 5} more` : '';
|
|
265
|
-
|
|
267
|
+
item.globalPackages = own;
|
|
268
|
+
item.reason += `; also removes its global packages: ${shown}${more}`;
|
|
266
269
|
}
|
|
267
270
|
}
|
|
268
271
|
|
|
@@ -357,7 +360,7 @@ async function ruleItems(context, rule, home, pins) {
|
|
|
357
360
|
: null,
|
|
358
361
|
});
|
|
359
362
|
await blockPyenvEnvironments(env, rule, dir, item);
|
|
360
|
-
await
|
|
363
|
+
await noteGlobalPackages(env, rule, dir, item);
|
|
361
364
|
await blockLiveLock(context, rule, home, version, item);
|
|
362
365
|
const busy = await scanTimeBusy(context, item);
|
|
363
366
|
if (busy) {
|