disk-space-saviour 0.21.0 → 0.22.1
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 +22 -0
- package/README.md +55 -3
- package/package.json +1 -1
- package/src/app-owners.js +87 -0
- package/src/audit.js +32 -1
- package/src/clean.js +24 -5
- package/src/cli.js +32 -3
- package/src/conditions.js +152 -0
- package/src/emergency.js +4 -4
- package/src/env/local.js +70 -7
- package/src/index.d.ts +58 -0
- package/src/index.js +1 -0
- package/src/items.js +3 -0
- package/src/liveness.js +182 -11
- package/src/report.js +123 -30
- package/src/rules/downloads.js +27 -0
- package/src/rules/other.js +9 -0
- package/src/scan.js +31 -1
- package/src/scanners/global.js +4 -0
- package/src/scanners/node-modules.js +89 -0
- package/src/scanners/projects.js +27 -2
- package/src/swap.js +9 -2
- package/src/usage.js +687 -0
package/src/report.js
CHANGED
|
@@ -114,6 +114,72 @@ function blockedSection(report, options) {
|
|
|
114
114
|
];
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Clones that share every block with an installed app: `du` and Finder
|
|
119
|
+
* count them at full size, deleting them frees nothing.
|
|
120
|
+
*/
|
|
121
|
+
export function sharedSection(shared) {
|
|
122
|
+
if (!shared?.length) {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
const apparent = shared.reduce((sum, item) => sum + item.apparentBytes, 0);
|
|
126
|
+
return [
|
|
127
|
+
'',
|
|
128
|
+
`SHARED WITH INSTALLED APPS ${plural(shared.length, 'item')}, ${formatBytes(apparent)} as du counts it, about 0 B to free (APFS clones; the app recreates them at launch)`,
|
|
129
|
+
...shared.map(
|
|
130
|
+
(item) =>
|
|
131
|
+
` ${pad(formatBytes(item.apparentBytes), 10)} ${item.rule} ${item.path} (shares its blocks with ${item.sharedWith})`
|
|
132
|
+
),
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function tierBreakdown(tiers) {
|
|
137
|
+
return TIERS.filter((tier) => tiers[tier])
|
|
138
|
+
.map((tier) => `${tier} ${formatBytes(tiers[tier])}`)
|
|
139
|
+
.join(', ');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** One-line warning naming the largest conditions, or null. */
|
|
143
|
+
export function conditionsWarning(blocked) {
|
|
144
|
+
const conditions = blocked?.conditions ?? [];
|
|
145
|
+
if (conditions.length === 0) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const bytes = conditions.reduce((sum, group) => sum + group.bytes, 0);
|
|
149
|
+
const top = conditions
|
|
150
|
+
.slice(0, 3)
|
|
151
|
+
.map((group) => `${group.action} (${formatBytes(group.bytes)})`);
|
|
152
|
+
const more = conditions.length > 3 ? `, ${conditions.length - 3} more` : '';
|
|
153
|
+
return `${formatBytes(bytes)} more frees only once something stops using it: ${top.join('; ')}${more}. See FREEABLE ON CONDITIONS.`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function conditionsWarningLines(blocked) {
|
|
157
|
+
const warning = conditionsWarning(blocked);
|
|
158
|
+
return warning ? [`WARNING: ${warning}`] : [];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Blocked space grouped by the condition that frees it. */
|
|
162
|
+
export function conditionsSection(blocked, options = {}) {
|
|
163
|
+
const conditions = blocked?.conditions ?? [];
|
|
164
|
+
if (conditions.length === 0) {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
const lines = conditions.map(
|
|
168
|
+
(group) =>
|
|
169
|
+
` ${pad(formatBytes(group.bytes), 9)} ${group.action} (${plural(group.items, 'item')}: ${tierBreakdown(group.tiers)})`
|
|
170
|
+
);
|
|
171
|
+
if (blocked.other?.items) {
|
|
172
|
+
lines.push(
|
|
173
|
+
` ${pad(formatBytes(blocked.other.bytes), 9)} kept for other reasons (Git state, still needed, protected or unverifiable): ${plural(blocked.other.items, 'item')}`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return [
|
|
177
|
+
'',
|
|
178
|
+
'FREEABLE ON CONDITIONS (blocked items free once these happen; rerun dss afterwards)',
|
|
179
|
+
...limited(lines, options.limit ?? DEFAULT_LIMIT),
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
|
|
117
183
|
function repoSummary(repo) {
|
|
118
184
|
if (repo.error) {
|
|
119
185
|
return `${repo.root}: ${repo.error}`;
|
|
@@ -372,7 +438,39 @@ function holderLine(holder) {
|
|
|
372
438
|
`${change > 0 ? '+' : '-'}${formatBytes(Math.abs(change))} since ${new Date(holder.growth.since).toISOString()}`
|
|
373
439
|
);
|
|
374
440
|
}
|
|
375
|
-
|
|
441
|
+
const self = holder.self ? ' (this dss run)' : '';
|
|
442
|
+
return `${holder.pid} ${holder.command}${self}: ${details.join(', ')}`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function swapLines(swap) {
|
|
446
|
+
if (!swap) {
|
|
447
|
+
return [];
|
|
448
|
+
}
|
|
449
|
+
return [
|
|
450
|
+
`Swap: ${formatBytes(swap.used)} used of ${formatBytes(swap.total)} (managed by macOS)`,
|
|
451
|
+
...(swap.warnings ?? []).map((warning) => `WARNING: ${warning}`),
|
|
452
|
+
swap.attribution,
|
|
453
|
+
...swap.holders.map((holder) => ` ${holderLine(holder)}`),
|
|
454
|
+
swap.advice,
|
|
455
|
+
];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function environmentStatusLines(env) {
|
|
459
|
+
const lines = [];
|
|
460
|
+
if (env.layerSource) {
|
|
461
|
+
lines.push(` ${env.label}: layer source ${env.layerSource}`);
|
|
462
|
+
}
|
|
463
|
+
if (env.scanStatus === 'goal-reached') {
|
|
464
|
+
lines.push(
|
|
465
|
+
` ${env.label}: scan stopped after the free-space goal was met`
|
|
466
|
+
);
|
|
467
|
+
} else if (env.scanStatus && env.scanStatus !== 'complete') {
|
|
468
|
+
lines.push(` ${env.label}: scan incomplete (${env.scanStatus})`);
|
|
469
|
+
}
|
|
470
|
+
if (env.hint) {
|
|
471
|
+
lines.push(` ${env.label}: ${env.hint}`);
|
|
472
|
+
}
|
|
473
|
+
return lines;
|
|
376
474
|
}
|
|
377
475
|
|
|
378
476
|
/**
|
|
@@ -388,38 +486,15 @@ export function formatReport(report, options = {}) {
|
|
|
388
486
|
`disk-space-saviour scan of ${host.label} in ${(report.durationMs / 1000).toFixed(1)}s (report only, nothing was deleted)`,
|
|
389
487
|
formatDisk(host.disk),
|
|
390
488
|
];
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
`Swap: ${formatBytes(host.swap.used)} used of ${formatBytes(host.swap.total)} (managed by macOS)`
|
|
394
|
-
);
|
|
395
|
-
for (const warning of host.swap.warnings ?? []) {
|
|
396
|
-
lines.push(`WARNING: ${warning}`);
|
|
397
|
-
}
|
|
398
|
-
lines.push(host.swap.attribution);
|
|
399
|
-
for (const holder of host.swap.holders) {
|
|
400
|
-
lines.push(` ${holderLine(holder)}`);
|
|
401
|
-
}
|
|
402
|
-
lines.push(host.swap.advice);
|
|
403
|
-
}
|
|
489
|
+
lines.push(...swapLines(host.swap));
|
|
490
|
+
lines.push(...conditionsWarningLines(report.totals?.blocked));
|
|
404
491
|
for (const tier of TIERS) {
|
|
405
492
|
lines.push(...tierSection(report, tier, { limit }));
|
|
406
493
|
}
|
|
407
|
-
|
|
408
|
-
if (env.layerSource) {
|
|
409
|
-
lines.push(` ${env.label}: layer source ${env.layerSource}`);
|
|
410
|
-
}
|
|
411
|
-
if (env.scanStatus === 'goal-reached') {
|
|
412
|
-
lines.push(
|
|
413
|
-
` ${env.label}: scan stopped after the free-space goal was met`
|
|
414
|
-
);
|
|
415
|
-
} else if (env.scanStatus && env.scanStatus !== 'complete') {
|
|
416
|
-
lines.push(` ${env.label}: scan incomplete (${env.scanStatus})`);
|
|
417
|
-
}
|
|
418
|
-
if (env.hint) {
|
|
419
|
-
lines.push(` ${env.label}: ${env.hint}`);
|
|
420
|
-
}
|
|
421
|
-
}
|
|
494
|
+
lines.push(...report.environments.flatMap(environmentStatusLines));
|
|
422
495
|
lines.push(...blockedSection(report, { limit }));
|
|
496
|
+
lines.push(...sharedSection(report.shared));
|
|
497
|
+
lines.push(...conditionsSection(report.totals?.blocked, { limit }));
|
|
423
498
|
lines.push(...dockerSection(report));
|
|
424
499
|
lines.push(...errorsSection(report.errors));
|
|
425
500
|
lines.push(...totalsSection(report));
|
|
@@ -499,6 +574,14 @@ function emergencyLines(audit) {
|
|
|
499
574
|
* @param {object} audit
|
|
500
575
|
* @param {{verbose?: boolean}} [options]
|
|
501
576
|
*/
|
|
577
|
+
/** Warns that a plan built on an incomplete scan misses what it never reached. */
|
|
578
|
+
export function incompleteScanLines(incomplete) {
|
|
579
|
+
return (incomplete ?? []).map(
|
|
580
|
+
(env) =>
|
|
581
|
+
`WARNING: the scan of ${env.label} behind this plan stopped early (${env.status}), so items it did not reach were not considered; rerun with a larger --scan-budget (for example --scan-budget 5m).`
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
502
585
|
export function formatAudit(audit, options = {}) {
|
|
503
586
|
const limit = options.verbose ? Infinity : DEFAULT_LIMIT * 2;
|
|
504
587
|
const mode = audit.dryRun ? 'dry run, nothing was deleted' : 'deleting';
|
|
@@ -527,7 +610,17 @@ export function formatAudit(audit, options = {}) {
|
|
|
527
610
|
? `Would free ${formatBytes(audit.plannedBytes)} (run with --yes to delete)`
|
|
528
611
|
: `Freed ${formatBytes(audit.freedBytes)}`
|
|
529
612
|
);
|
|
530
|
-
|
|
613
|
+
lines.push(...incompleteScanLines(audit.incomplete));
|
|
614
|
+
const conditions = conditionsWarning(audit.blocked);
|
|
615
|
+
if (conditions) {
|
|
616
|
+
lines.push(
|
|
617
|
+
conditions,
|
|
618
|
+
...conditionsSection(audit.blocked, options).slice(2)
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
if (audit.auditError) {
|
|
622
|
+
lines.push(`Audit log: not written (${audit.auditError.message})`);
|
|
623
|
+
} else if (audit.file) {
|
|
531
624
|
lines.push(`Audit log: ${audit.file}`);
|
|
532
625
|
}
|
|
533
626
|
return lines.join('\n');
|
package/src/rules/downloads.js
CHANGED
|
@@ -184,4 +184,31 @@ export const DOWNLOAD_RULES = [
|
|
|
184
184
|
],
|
|
185
185
|
minAge: 'stale',
|
|
186
186
|
},
|
|
187
|
+
{
|
|
188
|
+
id: 'crashpad-pending-reports',
|
|
189
|
+
ecosystem: 'system',
|
|
190
|
+
description:
|
|
191
|
+
'Chromium and Electron app crash reports never sent: after a month no app uploads them',
|
|
192
|
+
tier: 'moderate',
|
|
193
|
+
aggregate: true,
|
|
194
|
+
paths: [
|
|
195
|
+
'~/Library/Application Support/*/Crashpad/pending/*',
|
|
196
|
+
...['Google', 'Yandex', 'BraveSoftware'].map(
|
|
197
|
+
(vendor) =>
|
|
198
|
+
`~/Library/Application Support/${vendor}/*/Crashpad/pending/*`
|
|
199
|
+
),
|
|
200
|
+
'~/.config/*/Crashpad/pending/*',
|
|
201
|
+
],
|
|
202
|
+
minAge: 'inactive',
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
id: 'gh-run-log-cache',
|
|
206
|
+
ecosystem: 'system',
|
|
207
|
+
description:
|
|
208
|
+
'GitHub CLI cached workflow run logs: gh downloads them again while GitHub keeps them',
|
|
209
|
+
tier: 'moderate',
|
|
210
|
+
aggregate: true,
|
|
211
|
+
paths: ['~/.cache/gh/run-log-*.zip'],
|
|
212
|
+
minAge: 'stale',
|
|
213
|
+
},
|
|
187
214
|
];
|
package/src/rules/other.js
CHANGED
|
@@ -414,6 +414,15 @@ export const OTHER_RULES = [
|
|
|
414
414
|
aggregate: true,
|
|
415
415
|
paths: ['~/Library/Logs/DiagnosticReports/*'],
|
|
416
416
|
},
|
|
417
|
+
{
|
|
418
|
+
id: 'rotated-app-logs',
|
|
419
|
+
ecosystem: 'system',
|
|
420
|
+
description:
|
|
421
|
+
'rotated app logs (warp.log.old.2, app.log.1): apps write only the current log',
|
|
422
|
+
tier: 'moderate',
|
|
423
|
+
paths: ['~/Library/Logs/*.log.*', '~/Library/Logs/*/*.log.*'],
|
|
424
|
+
minAge: 'stale',
|
|
425
|
+
},
|
|
417
426
|
{
|
|
418
427
|
id: 'apt-archives',
|
|
419
428
|
ecosystem: 'system',
|
package/src/scan.js
CHANGED
|
@@ -173,6 +173,7 @@ export async function environmentContext(env, options, overrides = {}) {
|
|
|
173
173
|
});
|
|
174
174
|
const liveness = new LivenessProbe(env, {
|
|
175
175
|
staleAgeMs: options.staleAgeMs,
|
|
176
|
+
inactiveMs: options.inactiveMs,
|
|
176
177
|
now: options.now,
|
|
177
178
|
});
|
|
178
179
|
await liveness.refresh(true);
|
|
@@ -377,13 +378,20 @@ async function scanEnvironmentScoped(
|
|
|
377
378
|
block(item, env.scanPolicy.removalReason(target));
|
|
378
379
|
}
|
|
379
380
|
}
|
|
380
|
-
for (const message of env
|
|
381
|
+
for (const message of await accessErrorMessages(env)) {
|
|
381
382
|
errors.push({ env: env.id, scanner: 'filesystem', message });
|
|
382
383
|
}
|
|
383
384
|
env.scanStatus = incomplete ? 'error' : 'complete';
|
|
384
385
|
return items;
|
|
385
386
|
}
|
|
386
387
|
|
|
388
|
+
/** Report messages for paths the scan could not inspect. */
|
|
389
|
+
function accessErrorMessages(env) {
|
|
390
|
+
return env.accessErrorMessages
|
|
391
|
+
? env.accessErrorMessages()
|
|
392
|
+
: [...(env.accessErrors?.values() ?? [])];
|
|
393
|
+
}
|
|
394
|
+
|
|
387
395
|
async function attributeStorage(env, items) {
|
|
388
396
|
if (env.writableLayer) {
|
|
389
397
|
const usages = await env.usageMany([
|
|
@@ -516,6 +524,24 @@ export function filterItems(items, options, daemons = [], pathApi) {
|
|
|
516
524
|
);
|
|
517
525
|
}
|
|
518
526
|
|
|
527
|
+
/**
|
|
528
|
+
* Items that share every block with an installed app (APFS clones): they
|
|
529
|
+
* free nothing, so they are not offered, but `du` counts them at full size.
|
|
530
|
+
*/
|
|
531
|
+
function sharedItems(items, options, pathApi) {
|
|
532
|
+
return filterItems(
|
|
533
|
+
items.filter((item) => item.sharedWith),
|
|
534
|
+
{ ...options, minSizeBytes: 0 },
|
|
535
|
+
[],
|
|
536
|
+
pathApi
|
|
537
|
+
).map((item) => ({
|
|
538
|
+
rule: item.rule,
|
|
539
|
+
path: item.path,
|
|
540
|
+
apparentBytes: item.apparentBytes,
|
|
541
|
+
sharedWith: item.sharedWith,
|
|
542
|
+
}));
|
|
543
|
+
}
|
|
544
|
+
|
|
519
545
|
function envDescriptor(env, depth, chain) {
|
|
520
546
|
return {
|
|
521
547
|
id: env.id,
|
|
@@ -665,6 +691,7 @@ async function scanScoped(input) {
|
|
|
665
691
|
docker?.daemons ?? [],
|
|
666
692
|
env.path
|
|
667
693
|
);
|
|
694
|
+
const shared = sharedItems(hostItems, options, env.path);
|
|
668
695
|
const environments = [envDescriptor(env, 0, [])];
|
|
669
696
|
for (const inner of docker?.environments ?? []) {
|
|
670
697
|
environments.push(inner);
|
|
@@ -677,6 +704,7 @@ async function scanScoped(input) {
|
|
|
677
704
|
options,
|
|
678
705
|
env,
|
|
679
706
|
items,
|
|
707
|
+
shared,
|
|
680
708
|
environments,
|
|
681
709
|
docker,
|
|
682
710
|
errors,
|
|
@@ -707,6 +735,7 @@ export function buildReport({
|
|
|
707
735
|
options,
|
|
708
736
|
env,
|
|
709
737
|
items,
|
|
738
|
+
shared = [],
|
|
710
739
|
environments,
|
|
711
740
|
docker,
|
|
712
741
|
errors,
|
|
@@ -722,6 +751,7 @@ export function buildReport({
|
|
|
722
751
|
options: reportOptions(options),
|
|
723
752
|
environments,
|
|
724
753
|
items,
|
|
754
|
+
shared,
|
|
725
755
|
totals: tierTotals(items),
|
|
726
756
|
docker: docker
|
|
727
757
|
? {
|
package/src/scanners/global.js
CHANGED
|
@@ -238,6 +238,10 @@ async function describeAppClone(context, rule, item, homes) {
|
|
|
238
238
|
item.checks.executables = executables;
|
|
239
239
|
if (sharedWith) {
|
|
240
240
|
// Every block is shared with the installed app: nothing to reclaim.
|
|
241
|
+
// The report lists it under `shared` so the space `du` counts for it
|
|
242
|
+
// is explained.
|
|
243
|
+
item.sharedWith = sharedWith;
|
|
244
|
+
item.apparentBytes = item.bytes;
|
|
241
245
|
item.bytes = 0;
|
|
242
246
|
item.reason = `shares its disk blocks with ${sharedWith}`;
|
|
243
247
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A node_modules directory is regenerable only as far as package managers
|
|
3
|
+
* fill it. Other data kept there (verification runs, evidence, hand-made
|
|
4
|
+
* fixtures) survives no reinstall, so it keeps the whole directory.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { formatBytes } from '../units.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Top-level entries package managers and the tools they run create:
|
|
11
|
+
* links, lockfile copies, store and workspace state, and caches that
|
|
12
|
+
* re-create themselves.
|
|
13
|
+
*/
|
|
14
|
+
const MANAGED_ENTRIES = new Set([
|
|
15
|
+
'.bin',
|
|
16
|
+
'.package-lock.json',
|
|
17
|
+
'.modules.yaml',
|
|
18
|
+
'.pnpm',
|
|
19
|
+
'.pnpm-workspace-state.json',
|
|
20
|
+
'.pnpm-workspace-state-v1.json',
|
|
21
|
+
'.yarn-state.yml',
|
|
22
|
+
'.yarn-integrity',
|
|
23
|
+
'.yarn-metadata.json',
|
|
24
|
+
'.store',
|
|
25
|
+
'.staging',
|
|
26
|
+
'.ignored',
|
|
27
|
+
'.cache',
|
|
28
|
+
'.vite',
|
|
29
|
+
'.vite-temp',
|
|
30
|
+
'.vitest',
|
|
31
|
+
'.vitest-cache',
|
|
32
|
+
'.deno',
|
|
33
|
+
'.prisma',
|
|
34
|
+
'.astro',
|
|
35
|
+
'.mf',
|
|
36
|
+
'.tmp',
|
|
37
|
+
'.DS_Store',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Entries of `dir` no package manager installs, with their size; empty
|
|
42
|
+
* leftovers are left out.
|
|
43
|
+
* @returns {Promise<Array<{name: string, bytes: number}>>}
|
|
44
|
+
*/
|
|
45
|
+
export async function foreignEntries(env, dir) {
|
|
46
|
+
const candidates = [];
|
|
47
|
+
for (const entry of await env.list(dir)) {
|
|
48
|
+
if (
|
|
49
|
+
MANAGED_ENTRIES.has(entry.name) ||
|
|
50
|
+
entry.name.startsWith('@') ||
|
|
51
|
+
entry.type === 'symlink'
|
|
52
|
+
) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (
|
|
56
|
+
entry.type === 'dir' &&
|
|
57
|
+
(await env.stat(env.path.join(entry.path, 'package.json')))
|
|
58
|
+
) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
candidates.push(entry);
|
|
62
|
+
}
|
|
63
|
+
if (candidates.length === 0) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
const usages = await env.usageMany(
|
|
67
|
+
candidates.map((entry) => entry.path),
|
|
68
|
+
{ cache: true }
|
|
69
|
+
);
|
|
70
|
+
return candidates
|
|
71
|
+
.map((entry) => ({ name: entry.name, usage: usages.get(entry.path) }))
|
|
72
|
+
.filter(({ usage }) => usage && (usage.files ?? 1) > 0)
|
|
73
|
+
.map(({ name, usage }) => ({ name, bytes: usage.bytes }))
|
|
74
|
+
.sort((a, b) => b.bytes - a.bytes);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Blocker naming the foreign data inside `dir`, or null. */
|
|
78
|
+
export async function foreignDataBlocker(env, dir) {
|
|
79
|
+
const foreign = await foreignEntries(env, dir);
|
|
80
|
+
if (foreign.length === 0) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const names = foreign
|
|
84
|
+
.slice(0, 3)
|
|
85
|
+
.map((entry) => `${entry.name} (${formatBytes(entry.bytes)})`)
|
|
86
|
+
.join(', ');
|
|
87
|
+
const more = foreign.length > 3 ? ` and ${foreign.length - 3} more` : '';
|
|
88
|
+
return `holds data no package manager installs: ${names}${more}; a reinstall does not re-create it, so move it out of node_modules first`;
|
|
89
|
+
}
|
package/src/scanners/projects.js
CHANGED
|
@@ -9,6 +9,9 @@ import { scanItems } from '../env/scope.js';
|
|
|
9
9
|
* are present. Generic names (`build`, `lib`, `target`, `bin`) that fail
|
|
10
10
|
* every rule are searched again below, with the remaining depth, so a
|
|
11
11
|
* non-matching `build` directory never hides real projects inside it.
|
|
12
|
+
* Git worktree folders (`.worktrees`, `worktrees`, `.claude/worktrees`)
|
|
13
|
+
* hold whole checkouts, so the search below them starts with the full
|
|
14
|
+
* depth again: an agent's worktree deep in a project is a project too.
|
|
12
15
|
*
|
|
13
16
|
* Cargo `target` directories additionally yield `cargo-superseded` items:
|
|
14
17
|
* older build hashes that a newer build of the same unit replaced (see
|
|
@@ -20,9 +23,11 @@ import { block, makeItem } from '../items.js';
|
|
|
20
23
|
import { isWithin, matchesGlob } from '../paths.js';
|
|
21
24
|
import { listMany, scanTimeBusy } from './common.js';
|
|
22
25
|
import { analyzeRustProfile, findRustProfiles } from './rust.js';
|
|
26
|
+
import { foreignDataBlocker } from './node-modules.js';
|
|
23
27
|
import { timed } from '../timings.js';
|
|
24
28
|
|
|
25
29
|
const ECOSYSTEM_NAMES = new Map(ECOSYSTEMS.map((e) => [e.id, e.name]));
|
|
30
|
+
const WORKTREE_DIRS = ['.worktrees', 'worktrees'];
|
|
26
31
|
|
|
27
32
|
function anyMatch(entries, globs) {
|
|
28
33
|
return (entries ?? []).some((entry) =>
|
|
@@ -37,6 +42,13 @@ function depthBelow(env, roots, target) {
|
|
|
37
42
|
return env.path.relative(root, target).split(/[\\/]/).filter(Boolean).length;
|
|
38
43
|
}
|
|
39
44
|
|
|
45
|
+
/** Depth left below a directory no rule claims; worktrees start afresh. */
|
|
46
|
+
function remainingDepth(env, dirs, candidate, { depth, maxDepth }) {
|
|
47
|
+
return WORKTREE_DIRS.includes(candidate.name)
|
|
48
|
+
? maxDepth
|
|
49
|
+
: depth - depthBelow(env, dirs, candidate.path);
|
|
50
|
+
}
|
|
51
|
+
|
|
40
52
|
function ruleAccepts(env, rule, candidate, selfListing, grandListing) {
|
|
41
53
|
if (rule.markers && !anyMatch(candidate.siblings, rule.markers)) {
|
|
42
54
|
return false;
|
|
@@ -139,7 +151,9 @@ async function indexedMatches(env, roots, globs, maxDepth, rules) {
|
|
|
139
151
|
export async function findProjectDirs(env, roots, options = {}) {
|
|
140
152
|
const rules = options.rules ?? PROJECT_RULES;
|
|
141
153
|
const maxDepth = options.maxDepth ?? 6;
|
|
142
|
-
const globs = [
|
|
154
|
+
const globs = [
|
|
155
|
+
...new Set([...rules.flatMap((rule) => rule.names), ...WORKTREE_DIRS]),
|
|
156
|
+
];
|
|
143
157
|
const matches = [];
|
|
144
158
|
const seen = new Set();
|
|
145
159
|
const accept = (match) => {
|
|
@@ -171,7 +185,10 @@ export async function findProjectDirs(env, roots, options = {}) {
|
|
|
171
185
|
}
|
|
172
186
|
continue;
|
|
173
187
|
}
|
|
174
|
-
const remaining =
|
|
188
|
+
const remaining = remainingDepth(env, dirs, match.candidate, {
|
|
189
|
+
depth,
|
|
190
|
+
maxDepth,
|
|
191
|
+
});
|
|
175
192
|
if (remaining > 0) {
|
|
176
193
|
next.set(remaining, [
|
|
177
194
|
...(next.get(remaining) ?? []),
|
|
@@ -232,6 +249,14 @@ async function projectItem(context, { candidate, rule }, usage) {
|
|
|
232
249
|
...projectTier(rule),
|
|
233
250
|
checks: { busy: rule.busy ?? [], cwd: project, mtime: true },
|
|
234
251
|
});
|
|
252
|
+
if (rule.id === 'node-modules') {
|
|
253
|
+
block(
|
|
254
|
+
item,
|
|
255
|
+
await timed(context.timings, 'projects:contents', () =>
|
|
256
|
+
foreignDataBlocker(env, candidate.path)
|
|
257
|
+
)
|
|
258
|
+
);
|
|
259
|
+
}
|
|
235
260
|
await addBlockers(context, item, false);
|
|
236
261
|
return item;
|
|
237
262
|
}
|
package/src/swap.js
CHANGED
|
@@ -260,8 +260,11 @@ export function swapWarnings(swap, disk) {
|
|
|
260
260
|
if (reasons.length === 0) {
|
|
261
261
|
return [];
|
|
262
262
|
}
|
|
263
|
-
// The kernel (PID 0) cannot be quit
|
|
264
|
-
|
|
263
|
+
// The kernel (PID 0) cannot be quit and this dss run ends by itself, so
|
|
264
|
+
// naming either helps nobody.
|
|
265
|
+
const candidates = swap.holders.filter(
|
|
266
|
+
(holder) => holder.pid > 0 && !holder.self
|
|
267
|
+
);
|
|
265
268
|
const growing = candidates.filter(
|
|
266
269
|
(holder) => holder.growth?.memoryBytes >= GROWTH_WARNING_BYTES
|
|
267
270
|
);
|
|
@@ -308,6 +311,10 @@ export async function readSwap(env, options = {}) {
|
|
|
308
311
|
if (!swap) {
|
|
309
312
|
return null;
|
|
310
313
|
}
|
|
314
|
+
// This dss run shows up among the holders while it scans; it ends on its own.
|
|
315
|
+
swap.holders = swap.holders.map((holder) =>
|
|
316
|
+
holder.pid === process.pid ? { ...holder, self: true } : holder
|
|
317
|
+
);
|
|
311
318
|
const history = await loadSwapHistory(stateDir, now);
|
|
312
319
|
const grown = withGrowth(swap, history);
|
|
313
320
|
await saveSwapHistory(stateDir, [
|