disk-space-saviour 0.21.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 +6 -0
- package/README.md +35 -0
- package/package.json +1 -1
- package/src/app-owners.js +87 -0
- package/src/clean.js +11 -0
- package/src/cli.js +32 -3
- package/src/conditions.js +145 -0
- package/src/env/local.js +70 -7
- package/src/index.d.ts +54 -0
- package/src/index.js +1 -0
- package/src/items.js +3 -0
- package/src/liveness.js +67 -4
- package/src/report.js +118 -28
- package/src/scan.js +30 -1
- package/src/scanners/global.js +4 -0
- package/src/scanners/projects.js +18 -2
- package/src/usage.js +601 -0
package/src/liveness.js
CHANGED
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
|
|
20
|
+
import {
|
|
21
|
+
bundleRoots,
|
|
22
|
+
bundlesOwn,
|
|
23
|
+
ownerBundleId,
|
|
24
|
+
processBundles,
|
|
25
|
+
} from './app-owners.js';
|
|
20
26
|
import { isWithin } from './paths.js';
|
|
21
27
|
import { formatDuration } from './units.js';
|
|
22
28
|
|
|
@@ -195,6 +201,12 @@ export function matchingName(proc, names) {
|
|
|
195
201
|
return null;
|
|
196
202
|
}
|
|
197
203
|
|
|
204
|
+
/** `matched`, prefixed with the app it runs from: `Warp (stable)`. */
|
|
205
|
+
function appLabel(proc, matched) {
|
|
206
|
+
const app = proc.bundles?.[0]?.name;
|
|
207
|
+
return app && app !== matched ? `${app} (${matched})` : matched;
|
|
208
|
+
}
|
|
209
|
+
|
|
198
210
|
export class LivenessProbe {
|
|
199
211
|
/**
|
|
200
212
|
* @param {object} env environment adapter
|
|
@@ -213,6 +225,8 @@ export class LivenessProbe {
|
|
|
213
225
|
this.pathOwners = new Map();
|
|
214
226
|
this.pathStats = new Map();
|
|
215
227
|
this.inodes = new Map();
|
|
228
|
+
// Bundle identifiers never change, so they outlive refreshes.
|
|
229
|
+
this.bundleIds = new Map();
|
|
216
230
|
}
|
|
217
231
|
|
|
218
232
|
async refresh(force = false) {
|
|
@@ -224,6 +238,7 @@ export class LivenessProbe {
|
|
|
224
238
|
this.listed = processes !== null;
|
|
225
239
|
this.processes = processes ?? [];
|
|
226
240
|
this.bunCacheUsers = await this.#bunCacheUsers();
|
|
241
|
+
await this.#resolveBundles();
|
|
227
242
|
this.probeError =
|
|
228
243
|
(processes === null || this.openPaths === null) &&
|
|
229
244
|
(this.env.platform === 'linux' || this.env.platform === 'darwin');
|
|
@@ -234,6 +249,52 @@ export class LivenessProbe {
|
|
|
234
249
|
this.inodes.clear();
|
|
235
250
|
}
|
|
236
251
|
|
|
252
|
+
/** Reads the identifiers of the app bundles running processes come from. */
|
|
253
|
+
async #resolveBundles() {
|
|
254
|
+
if (!this.env.bundleIdentifier) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
for (const proc of this.processes) {
|
|
258
|
+
const roots = bundleRoots(proc.executable);
|
|
259
|
+
for (const root of roots) {
|
|
260
|
+
if (!this.bundleIds.has(root)) {
|
|
261
|
+
this.bundleIds.set(
|
|
262
|
+
root,
|
|
263
|
+
await this.env.bundleIdentifier(root).catch(() => null)
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
proc.bundles = processBundles(roots, this.bundleIds);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** ` (pid N, name)` of the process holding `open`, when the probe knows it. */
|
|
272
|
+
#holder(open) {
|
|
273
|
+
const pid = this.env.openPathPids?.get(open);
|
|
274
|
+
if (!pid) {
|
|
275
|
+
return '';
|
|
276
|
+
}
|
|
277
|
+
const proc = this.processes.find((entry) => entry.pid === pid);
|
|
278
|
+
return proc
|
|
279
|
+
? ` (pid ${pid}, ${appLabel(proc, proc.name)})`
|
|
280
|
+
: ` (pid ${pid})`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** A running app that owns a per-app Library folder of `paths`. */
|
|
284
|
+
appUsage(paths) {
|
|
285
|
+
const owners = [...new Set(paths.map(ownerBundleId).filter(Boolean))];
|
|
286
|
+
if (owners.length === 0) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
for (const proc of this.processes) {
|
|
290
|
+
const owner = owners.find((id) => bundlesOwn(proc.bundles ?? [], id));
|
|
291
|
+
if (owner) {
|
|
292
|
+
return `${appLabel(proc, proc.name)} is running (pid ${proc.pid}) and owns ${owner}`;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
237
298
|
/**
|
|
238
299
|
* Inodes of `executables` and of the open files that share their names,
|
|
239
300
|
* because a process running a hard link of an executable reports
|
|
@@ -405,10 +466,11 @@ export class LivenessProbe {
|
|
|
405
466
|
) {
|
|
406
467
|
continue;
|
|
407
468
|
}
|
|
408
|
-
const
|
|
409
|
-
if (!
|
|
469
|
+
const matched = matchingName(proc, names);
|
|
470
|
+
if (!matched) {
|
|
410
471
|
continue;
|
|
411
472
|
}
|
|
473
|
+
const name = appLabel(proc, matched);
|
|
412
474
|
if (!cwd) {
|
|
413
475
|
return `${name} is running (pid ${proc.pid})`;
|
|
414
476
|
}
|
|
@@ -431,7 +493,7 @@ export class LivenessProbe {
|
|
|
431
493
|
for (const open of this.openPaths) {
|
|
432
494
|
for (const target of targets) {
|
|
433
495
|
if (isWithin(open, target, pathApi)) {
|
|
434
|
-
return `in use: ${open}`;
|
|
496
|
+
return `in use: ${open}${this.#holder(open)}`;
|
|
435
497
|
}
|
|
436
498
|
}
|
|
437
499
|
}
|
|
@@ -466,7 +528,7 @@ export class LivenessProbe {
|
|
|
466
528
|
candidate &&
|
|
467
529
|
paths.some((target) => isWithin(candidate, target, this.env.path))
|
|
468
530
|
) {
|
|
469
|
-
return `in use: ${candidate} (pid ${proc.pid})`;
|
|
531
|
+
return `in use: ${candidate} (pid ${proc.pid}, ${appLabel(proc, proc.name)})`;
|
|
470
532
|
}
|
|
471
533
|
}
|
|
472
534
|
}
|
|
@@ -555,6 +617,7 @@ export class LivenessProbe {
|
|
|
555
617
|
this.probeBlocker() ??
|
|
556
618
|
(checks.mtime === false ? null : this.recentWrite(item.newestMtimeMs)) ??
|
|
557
619
|
this.runningTool(checks.busy, checks.cwd, item) ??
|
|
620
|
+
this.appUsage(item.paths ?? []) ??
|
|
558
621
|
this.processUsage(paths) ??
|
|
559
622
|
this.executableUsage(checks.executables) ??
|
|
560
623
|
this.uncertainUsage(item.paths ?? [])
|
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}`;
|
|
@@ -375,6 +441,37 @@ function holderLine(holder) {
|
|
|
375
441
|
return `${holder.pid} ${holder.command}: ${details.join(', ')}`;
|
|
376
442
|
}
|
|
377
443
|
|
|
444
|
+
function swapLines(swap) {
|
|
445
|
+
if (!swap) {
|
|
446
|
+
return [];
|
|
447
|
+
}
|
|
448
|
+
return [
|
|
449
|
+
`Swap: ${formatBytes(swap.used)} used of ${formatBytes(swap.total)} (managed by macOS)`,
|
|
450
|
+
...(swap.warnings ?? []).map((warning) => `WARNING: ${warning}`),
|
|
451
|
+
swap.attribution,
|
|
452
|
+
...swap.holders.map((holder) => ` ${holderLine(holder)}`),
|
|
453
|
+
swap.advice,
|
|
454
|
+
];
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function environmentStatusLines(env) {
|
|
458
|
+
const lines = [];
|
|
459
|
+
if (env.layerSource) {
|
|
460
|
+
lines.push(` ${env.label}: layer source ${env.layerSource}`);
|
|
461
|
+
}
|
|
462
|
+
if (env.scanStatus === 'goal-reached') {
|
|
463
|
+
lines.push(
|
|
464
|
+
` ${env.label}: scan stopped after the free-space goal was met`
|
|
465
|
+
);
|
|
466
|
+
} else if (env.scanStatus && env.scanStatus !== 'complete') {
|
|
467
|
+
lines.push(` ${env.label}: scan incomplete (${env.scanStatus})`);
|
|
468
|
+
}
|
|
469
|
+
if (env.hint) {
|
|
470
|
+
lines.push(` ${env.label}: ${env.hint}`);
|
|
471
|
+
}
|
|
472
|
+
return lines;
|
|
473
|
+
}
|
|
474
|
+
|
|
378
475
|
/**
|
|
379
476
|
* Renders a scan report.
|
|
380
477
|
* @param {object} report
|
|
@@ -388,38 +485,15 @@ export function formatReport(report, options = {}) {
|
|
|
388
485
|
`disk-space-saviour scan of ${host.label} in ${(report.durationMs / 1000).toFixed(1)}s (report only, nothing was deleted)`,
|
|
389
486
|
formatDisk(host.disk),
|
|
390
487
|
];
|
|
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
|
-
}
|
|
488
|
+
lines.push(...swapLines(host.swap));
|
|
489
|
+
lines.push(...conditionsWarningLines(report.totals?.blocked));
|
|
404
490
|
for (const tier of TIERS) {
|
|
405
491
|
lines.push(...tierSection(report, tier, { limit }));
|
|
406
492
|
}
|
|
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
|
-
}
|
|
493
|
+
lines.push(...report.environments.flatMap(environmentStatusLines));
|
|
422
494
|
lines.push(...blockedSection(report, { limit }));
|
|
495
|
+
lines.push(...sharedSection(report.shared));
|
|
496
|
+
lines.push(...conditionsSection(report.totals?.blocked, { limit }));
|
|
423
497
|
lines.push(...dockerSection(report));
|
|
424
498
|
lines.push(...errorsSection(report.errors));
|
|
425
499
|
lines.push(...totalsSection(report));
|
|
@@ -499,6 +573,14 @@ function emergencyLines(audit) {
|
|
|
499
573
|
* @param {object} audit
|
|
500
574
|
* @param {{verbose?: boolean}} [options]
|
|
501
575
|
*/
|
|
576
|
+
/** Warns that a plan built on an incomplete scan misses what it never reached. */
|
|
577
|
+
export function incompleteScanLines(incomplete) {
|
|
578
|
+
return (incomplete ?? []).map(
|
|
579
|
+
(env) =>
|
|
580
|
+
`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).`
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
502
584
|
export function formatAudit(audit, options = {}) {
|
|
503
585
|
const limit = options.verbose ? Infinity : DEFAULT_LIMIT * 2;
|
|
504
586
|
const mode = audit.dryRun ? 'dry run, nothing was deleted' : 'deleting';
|
|
@@ -527,6 +609,14 @@ export function formatAudit(audit, options = {}) {
|
|
|
527
609
|
? `Would free ${formatBytes(audit.plannedBytes)} (run with --yes to delete)`
|
|
528
610
|
: `Freed ${formatBytes(audit.freedBytes)}`
|
|
529
611
|
);
|
|
612
|
+
lines.push(...incompleteScanLines(audit.incomplete));
|
|
613
|
+
const conditions = conditionsWarning(audit.blocked);
|
|
614
|
+
if (conditions) {
|
|
615
|
+
lines.push(
|
|
616
|
+
conditions,
|
|
617
|
+
...conditionsSection(audit.blocked, options).slice(2)
|
|
618
|
+
);
|
|
619
|
+
}
|
|
530
620
|
if (audit.file) {
|
|
531
621
|
lines.push(`Audit log: ${audit.file}`);
|
|
532
622
|
}
|
package/src/scan.js
CHANGED
|
@@ -377,13 +377,20 @@ async function scanEnvironmentScoped(
|
|
|
377
377
|
block(item, env.scanPolicy.removalReason(target));
|
|
378
378
|
}
|
|
379
379
|
}
|
|
380
|
-
for (const message of env
|
|
380
|
+
for (const message of await accessErrorMessages(env)) {
|
|
381
381
|
errors.push({ env: env.id, scanner: 'filesystem', message });
|
|
382
382
|
}
|
|
383
383
|
env.scanStatus = incomplete ? 'error' : 'complete';
|
|
384
384
|
return items;
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
+
/** Report messages for paths the scan could not inspect. */
|
|
388
|
+
function accessErrorMessages(env) {
|
|
389
|
+
return env.accessErrorMessages
|
|
390
|
+
? env.accessErrorMessages()
|
|
391
|
+
: [...(env.accessErrors?.values() ?? [])];
|
|
392
|
+
}
|
|
393
|
+
|
|
387
394
|
async function attributeStorage(env, items) {
|
|
388
395
|
if (env.writableLayer) {
|
|
389
396
|
const usages = await env.usageMany([
|
|
@@ -516,6 +523,24 @@ export function filterItems(items, options, daemons = [], pathApi) {
|
|
|
516
523
|
);
|
|
517
524
|
}
|
|
518
525
|
|
|
526
|
+
/**
|
|
527
|
+
* Items that share every block with an installed app (APFS clones): they
|
|
528
|
+
* free nothing, so they are not offered, but `du` counts them at full size.
|
|
529
|
+
*/
|
|
530
|
+
function sharedItems(items, options, pathApi) {
|
|
531
|
+
return filterItems(
|
|
532
|
+
items.filter((item) => item.sharedWith),
|
|
533
|
+
{ ...options, minSizeBytes: 0 },
|
|
534
|
+
[],
|
|
535
|
+
pathApi
|
|
536
|
+
).map((item) => ({
|
|
537
|
+
rule: item.rule,
|
|
538
|
+
path: item.path,
|
|
539
|
+
apparentBytes: item.apparentBytes,
|
|
540
|
+
sharedWith: item.sharedWith,
|
|
541
|
+
}));
|
|
542
|
+
}
|
|
543
|
+
|
|
519
544
|
function envDescriptor(env, depth, chain) {
|
|
520
545
|
return {
|
|
521
546
|
id: env.id,
|
|
@@ -665,6 +690,7 @@ async function scanScoped(input) {
|
|
|
665
690
|
docker?.daemons ?? [],
|
|
666
691
|
env.path
|
|
667
692
|
);
|
|
693
|
+
const shared = sharedItems(hostItems, options, env.path);
|
|
668
694
|
const environments = [envDescriptor(env, 0, [])];
|
|
669
695
|
for (const inner of docker?.environments ?? []) {
|
|
670
696
|
environments.push(inner);
|
|
@@ -677,6 +703,7 @@ async function scanScoped(input) {
|
|
|
677
703
|
options,
|
|
678
704
|
env,
|
|
679
705
|
items,
|
|
706
|
+
shared,
|
|
680
707
|
environments,
|
|
681
708
|
docker,
|
|
682
709
|
errors,
|
|
@@ -707,6 +734,7 @@ export function buildReport({
|
|
|
707
734
|
options,
|
|
708
735
|
env,
|
|
709
736
|
items,
|
|
737
|
+
shared = [],
|
|
710
738
|
environments,
|
|
711
739
|
docker,
|
|
712
740
|
errors,
|
|
@@ -722,6 +750,7 @@ export function buildReport({
|
|
|
722
750
|
options: reportOptions(options),
|
|
723
751
|
environments,
|
|
724
752
|
items,
|
|
753
|
+
shared,
|
|
725
754
|
totals: tierTotals(items),
|
|
726
755
|
docker: docker
|
|
727
756
|
? {
|
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
|
}
|
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
|
|
@@ -23,6 +26,7 @@ import { analyzeRustProfile, findRustProfiles } from './rust.js';
|
|
|
23
26
|
import { timed } from '../timings.js';
|
|
24
27
|
|
|
25
28
|
const ECOSYSTEM_NAMES = new Map(ECOSYSTEMS.map((e) => [e.id, e.name]));
|
|
29
|
+
const WORKTREE_DIRS = ['.worktrees', 'worktrees'];
|
|
26
30
|
|
|
27
31
|
function anyMatch(entries, globs) {
|
|
28
32
|
return (entries ?? []).some((entry) =>
|
|
@@ -37,6 +41,13 @@ function depthBelow(env, roots, target) {
|
|
|
37
41
|
return env.path.relative(root, target).split(/[\\/]/).filter(Boolean).length;
|
|
38
42
|
}
|
|
39
43
|
|
|
44
|
+
/** Depth left below a directory no rule claims; worktrees start afresh. */
|
|
45
|
+
function remainingDepth(env, dirs, candidate, { depth, maxDepth }) {
|
|
46
|
+
return WORKTREE_DIRS.includes(candidate.name)
|
|
47
|
+
? maxDepth
|
|
48
|
+
: depth - depthBelow(env, dirs, candidate.path);
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
function ruleAccepts(env, rule, candidate, selfListing, grandListing) {
|
|
41
52
|
if (rule.markers && !anyMatch(candidate.siblings, rule.markers)) {
|
|
42
53
|
return false;
|
|
@@ -139,7 +150,9 @@ async function indexedMatches(env, roots, globs, maxDepth, rules) {
|
|
|
139
150
|
export async function findProjectDirs(env, roots, options = {}) {
|
|
140
151
|
const rules = options.rules ?? PROJECT_RULES;
|
|
141
152
|
const maxDepth = options.maxDepth ?? 6;
|
|
142
|
-
const globs = [
|
|
153
|
+
const globs = [
|
|
154
|
+
...new Set([...rules.flatMap((rule) => rule.names), ...WORKTREE_DIRS]),
|
|
155
|
+
];
|
|
143
156
|
const matches = [];
|
|
144
157
|
const seen = new Set();
|
|
145
158
|
const accept = (match) => {
|
|
@@ -171,7 +184,10 @@ export async function findProjectDirs(env, roots, options = {}) {
|
|
|
171
184
|
}
|
|
172
185
|
continue;
|
|
173
186
|
}
|
|
174
|
-
const remaining =
|
|
187
|
+
const remaining = remainingDepth(env, dirs, match.candidate, {
|
|
188
|
+
depth,
|
|
189
|
+
maxDepth,
|
|
190
|
+
});
|
|
175
191
|
if (remaining > 0) {
|
|
176
192
|
next.set(remaining, [
|
|
177
193
|
...(next.get(remaining) ?? []),
|