@vzn/vx 0.0.15 → 0.0.18
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/README.md +91 -130
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +19 -5
- package/plugins/local-cache/index.ts +2 -0
- package/plugins/local-executor/index.ts +2 -0
- package/plugins/schedule-history/index.ts +2 -0
- package/src/bin.ts +2 -2
- package/src/cache/archive.ts +557 -0
- package/src/cache/cache.ts +929 -250
- package/src/cache/cas-backend.ts +20 -2
- package/src/cache/chained-cache.ts +202 -0
- package/src/cache/digest.ts +0 -15
- package/src/cache/index.ts +13 -3
- package/src/cache/inputs.ts +445 -41
- package/src/cache/layered-cache.ts +129 -30
- package/src/cache/tar-stream.ts +358 -0
- package/src/cli/cache.ts +36 -10
- package/src/cli/help.ts +167 -128
- package/src/cli/index.ts +74 -43
- package/src/cli/info.ts +30 -0
- package/src/cli/last.ts +140 -0
- package/src/cli/migrate-emit.ts +21 -0
- package/src/cli/migrate-nx.ts +23 -0
- package/src/cli/migrate-scripts.ts +127 -0
- package/src/cli/migrate-turbo.ts +43 -6
- package/src/cli/migrate.ts +119 -33
- package/src/cli/plan-format.ts +58 -3
- package/src/cli/plugin-commands.ts +96 -0
- package/src/cli/prune.ts +291 -0
- package/src/cli/run.ts +272 -80
- package/src/cli/upgrade.ts +7 -6
- package/src/cli/watch.ts +259 -61
- package/src/cli/why.ts +193 -0
- package/src/config.ts +40 -23
- package/src/exec/env.ts +7 -1
- package/src/exec/executor.ts +239 -0
- package/src/exec/index.ts +19 -1
- package/src/exec/runner.ts +116 -29
- package/src/exec/sandbox-runtime.ts +194 -24
- package/src/graph/index.ts +3 -1
- package/src/graph/scheduler.ts +92 -18
- package/src/graph/task-graph.ts +220 -6
- package/src/index.ts +82 -84
- package/src/orchestrator/deferred-outputs.ts +144 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +30 -0
- package/src/orchestrator/execute-task.ts +328 -94
- package/src/orchestrator/failure-mode.ts +61 -0
- package/src/orchestrator/framed-output.ts +87 -35
- package/src/orchestrator/history.ts +78 -31
- package/src/orchestrator/index.ts +43 -90
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/logger.ts +196 -14
- package/src/orchestrator/metrics.ts +57 -1376
- package/src/orchestrator/missing-plugin.ts +9 -0
- package/src/orchestrator/options.ts +22 -4
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +199 -119
- package/src/orchestrator/plugin.ts +144 -57
- package/src/orchestrator/prepare.ts +220 -69
- package/src/orchestrator/remote-prefetch.ts +27 -8
- package/src/orchestrator/resources.ts +20 -34
- package/src/orchestrator/run-artifacts.ts +41 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-report.ts +73 -46
- package/src/orchestrator/run.ts +590 -122
- package/src/orchestrator/stable-keys.ts +74 -33
- package/src/orchestrator/status-line.ts +47 -8
- package/src/orchestrator/summary.ts +22 -1
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +105 -11
- package/src/orchestrator/task-log-buffer.ts +291 -0
- package/src/orchestrator/telemetry-host.ts +24 -6
- package/src/orchestrator/telemetry.ts +181 -21
- package/src/orchestrator/upstream.ts +72 -31
- package/src/orchestrator/verify.ts +12 -2
- package/src/plugins/local-cache/index.ts +11 -0
- package/src/plugins/local-executor/index.ts +45 -0
- package/src/plugins/schedule-history/index.ts +109 -0
- package/src/util/edit-distance.ts +21 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +7 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +43 -0
- package/src/util/settle.ts +48 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/workspace/affected.ts +142 -15
- package/src/workspace/config-cache.ts +304 -0
- package/src/workspace/config-eval.ts +210 -0
- package/src/workspace/config-imports.ts +161 -0
- package/src/workspace/filter.ts +22 -4
- package/src/workspace/fingerprint.ts +16 -1
- package/src/workspace/index.ts +15 -3
- package/src/workspace/lockfile.ts +13 -2
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +23 -20
- package/src/workspace/project-loader.ts +503 -59
- package/src/workspace/workspace.ts +213 -72
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
package/src/cache/inputs.ts
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
// supported.
|
|
20
20
|
|
|
21
21
|
import path from 'node:path'
|
|
22
|
-
import {
|
|
22
|
+
import { existsSync } from 'node:fs'
|
|
23
|
+
import { realpath, rm } from 'node:fs/promises'
|
|
23
24
|
import type { CacheInputs } from '../config.js'
|
|
24
25
|
import { UserError } from '../util/index.js'
|
|
25
26
|
|
|
@@ -154,10 +155,8 @@ async function resolveWorkspaceFiles(args: {
|
|
|
154
155
|
}
|
|
155
156
|
if (positive.length === 0) return []
|
|
156
157
|
|
|
157
|
-
const excludeGlobs = [...ALWAYS_IGNORE, ...args.ownWorkspaceOutputs, ...negative].map(
|
|
158
|
-
|
|
159
|
-
)
|
|
160
|
-
const positiveGlobs = positive.map((p) => new Bun.Glob(p))
|
|
158
|
+
const excludeGlobs = [...ALWAYS_IGNORE, ...args.ownWorkspaceOutputs, ...negative].map(globFor)
|
|
159
|
+
const positiveGlobs = positive.map(globFor)
|
|
161
160
|
// Workspace-wide partition, keyed by the workspace root. Populated
|
|
162
161
|
// up-front by `populateGitFilesCache(..., workspaceWide: true)` when
|
|
163
162
|
// any loaded task declares workspaceFiles; a missing/invalidated
|
|
@@ -167,12 +166,21 @@ async function resolveWorkspaceFiles(args: {
|
|
|
167
166
|
gitFiles = runGitLsFiles(args.workspaceRoot).files
|
|
168
167
|
args.gitFilesCache?.set(args.workspaceRoot, gitFiles)
|
|
169
168
|
}
|
|
169
|
+
// Second call site of the literal-input guard. `resolveWorkspaceFiles`
|
|
170
|
+
// carries its own copy of the filter-over-git-set design, so the same
|
|
171
|
+
// silently-folds-nothing hazard exists here — and a fix applied only to the
|
|
172
|
+
// project half would pass that half's tests while leaving this one live.
|
|
173
|
+
const unmatchedLiterals = new Set(positive.filter(isLiteralPath))
|
|
170
174
|
const candidates: string[] = []
|
|
171
175
|
for (const rel of gitFiles) {
|
|
176
|
+
if (unmatchedLiterals.size > 0) unmatchedLiterals.delete(rel)
|
|
172
177
|
if (!positiveGlobs.some((g) => g.match(rel))) continue
|
|
173
178
|
if (excludeGlobs.some((g) => g.match(rel))) continue
|
|
174
179
|
candidates.push(path.resolve(args.workspaceRoot, rel))
|
|
175
180
|
}
|
|
181
|
+
if (unmatchedLiterals.size > 0) {
|
|
182
|
+
await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.workspaceRoot, 'workspaceFiles')
|
|
183
|
+
}
|
|
176
184
|
// Same OID-trust shortcut as project files: a clean-per-status
|
|
177
185
|
// tracked file necessarily exists on disk.
|
|
178
186
|
const oids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
|
|
@@ -262,10 +270,68 @@ export async function resolveOutputs(args: {
|
|
|
262
270
|
nestedProjectDirs: string[]
|
|
263
271
|
}): Promise<string[]> {
|
|
264
272
|
if (args.outputs.length === 0) return []
|
|
265
|
-
const excludeGlobs = boundaryIgnorePatterns(args.projectDir, args.nestedProjectDirs).map(
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
273
|
+
const excludeGlobs = boundaryIgnorePatterns(args.projectDir, args.nestedProjectDirs).map(globFor)
|
|
274
|
+
const scanned = [...(await scanUnion(args.outputs, excludeGlobs, args.projectDir))]
|
|
275
|
+
// Containment, enforced HERE and not only at the loader. `cleanOutputs`
|
|
276
|
+
// DELETES whatever this returns, and `Bun.Glob.scan` happily walks `..` out
|
|
277
|
+
// of its cwd — so the loader's `..`/absolute rejection alone was a single
|
|
278
|
+
// point of failure. The resolver that feeds the delete refuses to name a path
|
|
279
|
+
// outside the project, so any future caller reaching it by another route (a
|
|
280
|
+
// programmatic embedder, a config source that skips the loader) is contained
|
|
281
|
+
// by construction.
|
|
282
|
+
return (await containedIn(args.projectDir, scanned)).sort()
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Keep only the paths that are REALLY inside `root` — lexically, and after
|
|
287
|
+
* resolving symlinks.
|
|
288
|
+
*
|
|
289
|
+
* Lexical alone was sufficient only while `Bun.Glob.scan` refused to descend
|
|
290
|
+
* into symlinked directories, which this repo pinned as a deliberate tripwire
|
|
291
|
+
* on a DEPENDENCY's behaviour. **Bun 1.4.0 tripped it**: with `dist ->
|
|
292
|
+
* ../victim` the scan now yields `dist/precious.txt`, a path that is lexically
|
|
293
|
+
* inside the project while the file it names is not — and the caller rm()s
|
|
294
|
+
* whatever this returns. Measured on 1.4.0 before this guard: a plain
|
|
295
|
+
* `outputs.files: ['dist/**']` deleted a file outside the project.
|
|
296
|
+
*
|
|
297
|
+
* DIRECTORIES are what matter, not files: `rm` on a symlinked FILE unlinks the
|
|
298
|
+
* link and never its target, so a link sitting inside a real output directory
|
|
299
|
+
* is harmless. Resolving per directory also keeps this cheap — one syscall per
|
|
300
|
+
* distinct output directory rather than per output file, concurrently, and a
|
|
301
|
+
* `dist/**` of ten thousand files in one directory costs exactly one.
|
|
302
|
+
*
|
|
303
|
+
* A path whose directory will not resolve (a broken link, or a race with the
|
|
304
|
+
* task that produced it) is REFUSED. When the caller deletes, unresolvable
|
|
305
|
+
* means leave it alone.
|
|
306
|
+
*/
|
|
307
|
+
async function containedIn(root: string, paths: readonly string[]): Promise<string[]> {
|
|
308
|
+
// One `path.dirname` per path, kept alongside — computing it again in the
|
|
309
|
+
// final filter measured as the DOMINANT added cost on a wide output tree
|
|
310
|
+
// (string work, not syscalls: 10k files in 20 dirs cost more than 5k files
|
|
311
|
+
// in 200, which is the wrong shape for a per-directory probe).
|
|
312
|
+
const lexical: string[] = []
|
|
313
|
+
const lexDirs: string[] = []
|
|
314
|
+
for (const p of paths) {
|
|
315
|
+
if (!isInside(root, p)) continue
|
|
316
|
+
lexical.push(p)
|
|
317
|
+
lexDirs.push(path.dirname(p))
|
|
318
|
+
}
|
|
319
|
+
if (lexical.length === 0) return []
|
|
320
|
+
const realRoot = await realpath(root).catch(() => root)
|
|
321
|
+
const uniqueDirs = [...new Set(lexDirs)]
|
|
322
|
+
const resolved = await Promise.all(uniqueDirs.map((d) => realpath(d).catch(() => null)))
|
|
323
|
+
const contained = new Set<string>()
|
|
324
|
+
for (const [i, dir] of uniqueDirs.entries()) {
|
|
325
|
+
const real = resolved[i]
|
|
326
|
+
if (real !== null && real !== undefined && isInside(realRoot, real)) contained.add(dir)
|
|
327
|
+
}
|
|
328
|
+
return lexical.filter((_p, i) => contained.has(lexDirs[i]!))
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Is `abs` the directory `dir` itself or something beneath it? */
|
|
332
|
+
function isInside(dir: string, abs: string): boolean {
|
|
333
|
+
if (abs === dir) return true
|
|
334
|
+
return abs.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep)
|
|
269
335
|
}
|
|
270
336
|
|
|
271
337
|
/**
|
|
@@ -307,7 +373,12 @@ export async function resolveWorkspaceOutputs(args: {
|
|
|
307
373
|
outputs: string[]
|
|
308
374
|
}): Promise<string[]> {
|
|
309
375
|
if (args.outputs.length === 0) return []
|
|
310
|
-
|
|
376
|
+
const scanned = [...(await scanUnion(args.outputs, [], args.workspaceRoot))]
|
|
377
|
+
// Same containment as the project twin, anchored one level out. These globs
|
|
378
|
+
// deliberately ignore PROJECT boundaries — that is the escape hatch — but
|
|
379
|
+
// escaping the WORKSPACE was never part of it, and `cleanWorkspaceOutputs`
|
|
380
|
+
// deletes what this returns.
|
|
381
|
+
return (await containedIn(args.workspaceRoot, scanned)).sort()
|
|
311
382
|
}
|
|
312
383
|
|
|
313
384
|
/**
|
|
@@ -496,6 +567,51 @@ interface ResolveFilesArgs {
|
|
|
496
567
|
gitFilesCache?: GitFilesCache
|
|
497
568
|
}
|
|
498
569
|
|
|
570
|
+
/** No glob metacharacter — the entry names one exact path. */
|
|
571
|
+
function isLiteralPath(glob: string): boolean {
|
|
572
|
+
return !/[*?[\]{}]/.test(glob)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Refuse a literal `cache.inputs.files` entry that EXISTS ON DISK but is
|
|
577
|
+
* invisible to git — gitignored, or otherwise absent from
|
|
578
|
+
* `git ls-files --cached --others --exclude-standard`.
|
|
579
|
+
*
|
|
580
|
+
* Such an entry contributes nothing to the cache key, so the task stops
|
|
581
|
+
* tracking a file its own config names as an input. That is a stale hit, and a
|
|
582
|
+
* quiet one: the run replays cached stdout, so it even LOOKS like it executed.
|
|
583
|
+
*
|
|
584
|
+
* Turbo honours the declaration instead (an explicit `inputs` entry overrides
|
|
585
|
+
* gitignore). vx cannot afford to, and the reason is not cost: honouring it
|
|
586
|
+
* would make the cache key depend on a change that `git diff` and
|
|
587
|
+
* `git ls-files --others` CANNOT SEE, so editing that input would re-key the
|
|
588
|
+
* task while `--affected` selected nothing. `docs/cli.md` states the invariant
|
|
589
|
+
* those two surfaces owe each other as a principle — "input hashing sees it, so
|
|
590
|
+
* `--affected` must too" — and it was closed for lockfiles only recently.
|
|
591
|
+
* Honouring a gitignored input would reopen it from the other side.
|
|
592
|
+
*
|
|
593
|
+
* An entry that does NOT exist on disk stays silent: that is an ordinary stale
|
|
594
|
+
* declaration, and refusing it would break every config that lists an
|
|
595
|
+
* optional file.
|
|
596
|
+
*/
|
|
597
|
+
async function assertNoInvisibleLiteralInputs(
|
|
598
|
+
literals: ReadonlySet<string>,
|
|
599
|
+
base: string,
|
|
600
|
+
field: 'files' | 'workspaceFiles' = 'files',
|
|
601
|
+
): Promise<void> {
|
|
602
|
+
for (const rel of literals) {
|
|
603
|
+
if (!(await Bun.file(path.resolve(base, rel)).exists())) continue
|
|
604
|
+
throw new UserError(
|
|
605
|
+
`cache.inputs.${field}: "${rel}" exists in ${base} but git does not report it, ` +
|
|
606
|
+
`so it contributes NOTHING to the cache key — input globs filter the files git ` +
|
|
607
|
+
`lists, and a filter cannot add one back. The task would keep reporting ` +
|
|
608
|
+
`up-to-date after that file changed. If it is generated, depend on the task ` +
|
|
609
|
+
`that produces it via cache.inputs.tasks; if it should be tracked, remove it ` +
|
|
610
|
+
`from .gitignore.`,
|
|
611
|
+
)
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
499
615
|
async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
500
616
|
const positive: string[] = []
|
|
501
617
|
const negative: string[] = []
|
|
@@ -513,7 +629,7 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
513
629
|
|
|
514
630
|
const boundaryIgnores = boundaryIgnorePatterns(args.projectDir, args.nestedProjectDirs)
|
|
515
631
|
const excludeGlobs = [...ALWAYS_IGNORE, ...boundaryIgnores, ...args.ownOutputs, ...negative].map(
|
|
516
|
-
|
|
632
|
+
globFor,
|
|
517
633
|
)
|
|
518
634
|
|
|
519
635
|
// Defer to git for the file set (Turbo / Nx parity). Nested .gitignore
|
|
@@ -524,7 +640,7 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
524
640
|
// per task (build + test + lint + …). Spawning git N times for the
|
|
525
641
|
// same project per run is wasteful; we cache the result for the
|
|
526
642
|
// duration of one orchestrator run.
|
|
527
|
-
const positiveGlobs = positive.map(
|
|
643
|
+
const positiveGlobs = positive.map(globFor)
|
|
528
644
|
let gitFiles = args.gitFilesCache?.snapshotFor(args.projectDir, positiveGlobs)
|
|
529
645
|
if (gitFiles === undefined) {
|
|
530
646
|
// Mid-run re-enumeration. The OIDs this spawn could yield are NOT
|
|
@@ -536,9 +652,22 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
536
652
|
// set() also clears the project's pending-changed bookkeeping.
|
|
537
653
|
args.gitFilesCache?.set(args.projectDir, gitFiles)
|
|
538
654
|
}
|
|
655
|
+
// A LITERAL entry — one with no glob metacharacter — names exactly one file,
|
|
656
|
+
// so "this matched nothing" is unambiguous. For a glob it is not: matching
|
|
657
|
+
// nothing is perfectly legitimate (`src/**/*.gen.ts` in a package with no
|
|
658
|
+
// generated code), which is why only literals are tracked here.
|
|
659
|
+
//
|
|
660
|
+
// The reason it has to be tracked at all: the user's globs FILTER the set git
|
|
661
|
+
// reports, and a filter can only remove. So naming a gitignored file by hand
|
|
662
|
+
// folds NOTHING — silently. The task then reports `up-to-date` while replaying
|
|
663
|
+
// an artifact built from an older version of a file the config explicitly
|
|
664
|
+
// claims as an input. See the refusal below for why this is not simply
|
|
665
|
+
// honoured instead.
|
|
666
|
+
const unmatchedLiterals = new Set(positive.filter(isLiteralPath))
|
|
539
667
|
// First pass: glob-filter to candidate absolute paths (no I/O).
|
|
540
668
|
const candidates: string[] = []
|
|
541
669
|
for (const rel of gitFiles) {
|
|
670
|
+
if (unmatchedLiterals.size > 0) unmatchedLiterals.delete(rel)
|
|
542
671
|
let matched = false
|
|
543
672
|
for (const g of positiveGlobs) {
|
|
544
673
|
if (g.match(rel)) {
|
|
@@ -550,6 +679,9 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
550
679
|
if (excludeGlobs.some((g) => g.match(rel))) continue
|
|
551
680
|
candidates.push(path.resolve(args.projectDir, rel))
|
|
552
681
|
}
|
|
682
|
+
if (unmatchedLiterals.size > 0) {
|
|
683
|
+
await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.projectDir, 'files')
|
|
684
|
+
}
|
|
553
685
|
// Second pass: parallel existence check — but ONLY for paths
|
|
554
686
|
// without a trusted index OID. A clean-per-status tracked file
|
|
555
687
|
// necessarily exists on disk, so skipping its probe keeps the warm
|
|
@@ -580,13 +712,17 @@ interface GitLsResult {
|
|
|
580
712
|
* dirtiness — callers intersect with `git status` before trusting.
|
|
581
713
|
*/
|
|
582
714
|
oids: Map<string, string>
|
|
715
|
+
/** Paths flagged skip-worktree / assume-unchanged (only when `-v` was passed). */
|
|
716
|
+
flagged: Set<string>
|
|
583
717
|
}
|
|
584
718
|
|
|
585
719
|
// `<mode> <oid> <stage>\t<path>` — the staged-entry form of
|
|
586
720
|
// `ls-files -s`. `--others` paths print bare; with `-z`,
|
|
587
721
|
// core.quotePath quoting is off, so a bare path containing a literal
|
|
588
722
|
// tab still can't match this fixed-form prefix.
|
|
589
|
-
|
|
723
|
+
// `ls-files -s -v`: an optional cache-state flag (`H`, `S`, `h`, …) then the
|
|
724
|
+
// stage record. Both answers come from ONE spawn.
|
|
725
|
+
const LS_FILES_STAGE_RE = /^(?:([A-Za-z]) )?([0-7]{6}) ([0-9a-f]{40,64}) ([0-3])\t/
|
|
590
726
|
|
|
591
727
|
/**
|
|
592
728
|
* Run `git ls-files -s --others --exclude-standard -z .` in `cwd`.
|
|
@@ -628,7 +764,8 @@ function runGitLsFiles(cwd: string): GitLsResult {
|
|
|
628
764
|
function parseLsFilesOutput(out: string): GitLsResult {
|
|
629
765
|
const files: string[] = []
|
|
630
766
|
const oids = new Map<string, string>()
|
|
631
|
-
|
|
767
|
+
const flagged = new Set<string>()
|
|
768
|
+
if (out.length === 0) return { files, oids, flagged }
|
|
632
769
|
// NUL-separated; trailing NUL produces an empty segment we skip.
|
|
633
770
|
for (const record of out.split('\0')) {
|
|
634
771
|
if (record.length === 0) continue
|
|
@@ -639,21 +776,147 @@ function parseLsFilesOutput(out: string): GitLsResult {
|
|
|
639
776
|
}
|
|
640
777
|
const filePath = record.slice(m[0].length)
|
|
641
778
|
files.push(filePath)
|
|
642
|
-
const mode = m[
|
|
643
|
-
const stage = m[
|
|
779
|
+
const mode = m[2]!
|
|
780
|
+
const stage = m[4]!
|
|
644
781
|
if ((mode === '100644' || mode === '100755') && stage === '0') {
|
|
645
|
-
oids.set(filePath, m[
|
|
782
|
+
oids.set(filePath, m[3]!)
|
|
783
|
+
}
|
|
784
|
+
// A LOWERCASE letter means skip-worktree or assume-unchanged (`S` is the
|
|
785
|
+
// older spelling of skip-worktree): git has been told to stop looking at
|
|
786
|
+
// the worktree file, so its index OID says nothing about the disk.
|
|
787
|
+
const flag = m[1]
|
|
788
|
+
if (flag !== undefined && (flag === 'S' || (flag >= 'a' && flag <= 'z'))) flagged.add(filePath)
|
|
789
|
+
}
|
|
790
|
+
return { files, oids, flagged }
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** One completed `git` invocation. */
|
|
794
|
+
interface GitRun {
|
|
795
|
+
exitCode: number
|
|
796
|
+
stdout: string
|
|
797
|
+
stderr: string
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Parse `git check-attr -z text eol ident` output — a flat stream of
|
|
802
|
+
* `<path>\0<attr>\0<value>\0` triples — into the set of paths where a clean
|
|
803
|
+
* filter can rewrite bytes. `unspecified` means no rule matched and
|
|
804
|
+
* `unset` (`-text`) explicitly disables conversion; both leave the index blob
|
|
805
|
+
* byte-identical to the worktree file, so those OIDs stay trusted.
|
|
806
|
+
*/
|
|
807
|
+
export function parseCheckAttrOutput(out: string): Set<string> {
|
|
808
|
+
const affected = new Set<string>()
|
|
809
|
+
const fields = out.split('\0')
|
|
810
|
+
for (let i = 0; i + 2 < fields.length; i += 3) {
|
|
811
|
+
const value = fields[i + 2]!
|
|
812
|
+
if (value !== 'unspecified' && value !== 'unset') affected.add(fields[i]!)
|
|
813
|
+
}
|
|
814
|
+
return affected
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Remove from `trusted` every path whose index blob may differ from its
|
|
819
|
+
* worktree bytes because a clean filter applies.
|
|
820
|
+
*
|
|
821
|
+
* Gated in two steps so the common case pays NOTHING. The precise answer —
|
|
822
|
+
* `git ls-files --eol`, comparing `i/` to `w/` — was measured at 240 ms on a
|
|
823
|
+
* 15k-file tree because it must READ every worktree file: 13x the entire
|
|
824
|
+
* enumeration, on a run whose warm total is ~130 ms, and in the common Linux
|
|
825
|
+
* case it finds nothing. So instead:
|
|
826
|
+
*
|
|
827
|
+
* 1. If `core.autocrlf` is true/input, conversion applies to every
|
|
828
|
+
* auto-detected text file with no attribute needed — trust nothing.
|
|
829
|
+
* 2. Else, if no attributes source exists anywhere (no in-tree
|
|
830
|
+
* `.gitattributes`, no `$GIT_DIR/info/attributes`, no
|
|
831
|
+
* `core.attributesFile`), no rule can name a filter — return untouched,
|
|
832
|
+
* zero extra work. This is the default `git init` repo.
|
|
833
|
+
* 3. Otherwise ask `git check-attr` (measured 21 ms; it resolves attributes
|
|
834
|
+
* from the index WITHOUT reading worktree content) and drop only the
|
|
835
|
+
* paths that actually carry `text`/`eol`/`ident`.
|
|
836
|
+
*
|
|
837
|
+
* Never throws: a probe that fails leaves the map as-is, which is exactly the
|
|
838
|
+
* behaviour before this gate existed.
|
|
839
|
+
*/
|
|
840
|
+
async function dropFilteredOids(
|
|
841
|
+
trusted: Map<string, string>,
|
|
842
|
+
args: {
|
|
843
|
+
workspaceRoot: string
|
|
844
|
+
gitDir: string
|
|
845
|
+
coreConfig: string
|
|
846
|
+
spawnGit: (a: string[], stdin?: string) => Promise<GitRun | null>
|
|
847
|
+
},
|
|
848
|
+
): Promise<void> {
|
|
849
|
+
if (trusted.size === 0) return
|
|
850
|
+
if (autocrlfConverts(args.coreConfig)) {
|
|
851
|
+
trusted.clear()
|
|
852
|
+
return
|
|
853
|
+
}
|
|
854
|
+
// Cheap checks first, and nothing is materialized until one of them fires —
|
|
855
|
+
// on a 15k-file repo with no attributes this whole function is a size check,
|
|
856
|
+
// a substring scan of an empty string, one stat, and a key walk that exits
|
|
857
|
+
// on the first `.gitattributes` it does not find.
|
|
858
|
+
let attributesPossible =
|
|
859
|
+
args.coreConfig.toLowerCase().includes('core.attributesfile') ||
|
|
860
|
+
(args.gitDir !== '' &&
|
|
861
|
+
existsSync(path.resolve(args.workspaceRoot, args.gitDir, 'info', 'attributes')))
|
|
862
|
+
if (!attributesPossible) {
|
|
863
|
+
for (const rel of trusted.keys()) {
|
|
864
|
+
if (rel === '.gitattributes' || rel.endsWith('/.gitattributes')) {
|
|
865
|
+
attributesPossible = true
|
|
866
|
+
break
|
|
867
|
+
}
|
|
646
868
|
}
|
|
647
869
|
}
|
|
648
|
-
|
|
870
|
+
if (!attributesPossible) return
|
|
871
|
+
|
|
872
|
+
const res = await args.spawnGit(
|
|
873
|
+
['check-attr', '--stdin', '-z', 'text', 'eol', 'ident'],
|
|
874
|
+
[...trusted.keys()].join('\0'),
|
|
875
|
+
)
|
|
876
|
+
if (res === null || res.exitCode !== 0) return
|
|
877
|
+
for (const rel of parseCheckAttrOutput(res.stdout)) trusted.delete(rel)
|
|
649
878
|
}
|
|
650
879
|
|
|
651
|
-
|
|
880
|
+
/** `true` when git may rewrite bytes for EVERY auto-detected text file. */
|
|
881
|
+
export function autocrlfConverts(coreConfig: string): boolean {
|
|
882
|
+
for (const line of coreConfig.split('\n')) {
|
|
883
|
+
const [name, value] = [line.slice(0, line.indexOf(' ')), line.slice(line.indexOf(' ') + 1)]
|
|
884
|
+
if (name.toLowerCase() === 'core.autocrlf') {
|
|
885
|
+
const v = value.trim().toLowerCase()
|
|
886
|
+
return v === 'true' || v === 'input'
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return false
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Paths `git ls-files -v -z` marks as not-watched: `S` is skip-worktree, and
|
|
894
|
+
* ANY lowercase letter is assume-unchanged layered on that entry's state.
|
|
895
|
+
* Either way git has been told to stop comparing the worktree file, so its
|
|
896
|
+
* index OID says nothing about what is — or isn't — on disk. Each
|
|
897
|
+
* NUL-terminated record is `<letter><space><path>`.
|
|
898
|
+
*/
|
|
899
|
+
export function parseFlaggedOutput(out: string): Set<string> {
|
|
900
|
+
const flagged = new Set<string>()
|
|
901
|
+
for (const record of out.split('\0')) {
|
|
902
|
+
if (record.length < 3 || record[1] !== ' ') continue
|
|
903
|
+
const letter = record[0]!
|
|
904
|
+
if (letter === 'S' || (letter >= 'a' && letter <= 'z')) flagged.add(record.slice(2))
|
|
905
|
+
}
|
|
906
|
+
return flagged
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function parseStatusOutput(out: string): { dirty: Set<string>; untracked: string[] } {
|
|
652
910
|
const tokens = out.split('\0')
|
|
653
911
|
const dirty = new Set<string>()
|
|
912
|
+
const untracked: string[] = []
|
|
654
913
|
for (let i = 0; i < tokens.length; i++) {
|
|
655
914
|
const token = tokens[i]!
|
|
656
915
|
if (token.length < 4) continue
|
|
916
|
+
if (token[0] === '?') {
|
|
917
|
+
untracked.push(token.slice(3))
|
|
918
|
+
continue
|
|
919
|
+
}
|
|
657
920
|
dirty.add(token.slice(3))
|
|
658
921
|
// X = R or C → the next token is the rename/copy source path.
|
|
659
922
|
const x = token[0]
|
|
@@ -662,7 +925,20 @@ function parseStatusOutput(out: string): Set<string> {
|
|
|
662
925
|
dirty.add(tokens[i]!)
|
|
663
926
|
}
|
|
664
927
|
}
|
|
665
|
-
return dirty
|
|
928
|
+
return { dirty, untracked }
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Strip a repo→workspace `--show-prefix` (e.g. `code/`) from each path, keeping
|
|
933
|
+
* only paths inside the workspace — so a repo-root-relative set (from `git
|
|
934
|
+
* status`/`diff`) is re-keyed workspace-relative to match `git ls-files`. Empty
|
|
935
|
+
* prefix → returns the set unchanged (workspace root IS the git root).
|
|
936
|
+
*/
|
|
937
|
+
function stripPrefixFromSet(paths: Set<string>, prefix: string): Set<string> {
|
|
938
|
+
if (prefix === '') return paths
|
|
939
|
+
const out = new Set<string>()
|
|
940
|
+
for (const p of paths) if (p.startsWith(prefix)) out.add(p.slice(prefix.length))
|
|
941
|
+
return out
|
|
666
942
|
}
|
|
667
943
|
|
|
668
944
|
/**
|
|
@@ -700,16 +976,64 @@ export async function populateGitFilesCache(
|
|
|
700
976
|
cache: GitFilesCache,
|
|
701
977
|
workspaceWide = false,
|
|
702
978
|
): Promise<void> {
|
|
703
|
-
|
|
979
|
+
const enumeration = await startGitEnumeration(
|
|
980
|
+
workspaceRoot,
|
|
981
|
+
gitPathspecs(workspaceRoot, projectDirs, workspaceWide),
|
|
982
|
+
)
|
|
983
|
+
applyGitEnumeration(enumeration, workspaceRoot, projectDirs, cache, workspaceWide)
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/** What one workspace-wide enumeration learned, before it is partitioned per project. */
|
|
987
|
+
export interface GitEnumeration {
|
|
988
|
+
/** Every enumerated path, workspace-relative, tracked and untracked. */
|
|
989
|
+
all: string[]
|
|
990
|
+
/** Workspace-relative path → index OID, for files whose worktree state matched the index. */
|
|
991
|
+
trusted: Map<string, string>
|
|
992
|
+
/** Whether the worktree had uncommitted changes; null when `git status` failed. */
|
|
993
|
+
dirty: boolean | null
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Pathspec scoping: when the run only needs a handful of projects
|
|
998
|
+
* (scoped config loading), let git scan just those dirs — 75 ms →
|
|
999
|
+
* 11 ms on an 11k-file repo. Above 64 dirs (or when a project IS
|
|
1000
|
+
* the root) the whole-tree scan wins on arg/exec overhead anyway.
|
|
1001
|
+
*/
|
|
1002
|
+
export function gitPathspecs(
|
|
1003
|
+
workspaceRoot: string,
|
|
1004
|
+
projectDirs: readonly string[],
|
|
1005
|
+
workspaceWide: boolean,
|
|
1006
|
+
): string[] {
|
|
1007
|
+
const rels = projectDirs.map((d) => path.relative(workspaceRoot, d).split(path.sep).join('/'))
|
|
1008
|
+
const scoped =
|
|
1009
|
+
!workspaceWide &&
|
|
1010
|
+
rels.length > 0 &&
|
|
1011
|
+
rels.length <= 64 &&
|
|
1012
|
+
rels.every((r) => r !== '' && r !== '.')
|
|
1013
|
+
return scoped ? rels : ['.']
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* The spawn half of `populateGitFilesCache`, separated so an UNSCOPED run
|
|
1018
|
+
* can start it before the configs are evaluated — the enumeration needs
|
|
1019
|
+
* only the pathspecs, and `['.']` is right for every unscoped run — and
|
|
1020
|
+
* overlap ~60 ms of git with ~80 ms of config evaluation on a
|
|
1021
|
+
* 1000-project tree instead of paying them back to back.
|
|
1022
|
+
*/
|
|
1023
|
+
export async function startGitEnumeration(
|
|
1024
|
+
workspaceRoot: string,
|
|
1025
|
+
pathspecs: readonly string[],
|
|
1026
|
+
): Promise<GitEnumeration> {
|
|
1027
|
+
// The spawns are independent — run them concurrently so the
|
|
704
1028
|
// bulk-populate costs max(ls-files, status) wall time, not the sum
|
|
705
1029
|
// (status alone is ~74 ms on a 1000-project tree; serial spawning
|
|
706
1030
|
// was a measurable warm-path regression vs the pre-OID code).
|
|
707
|
-
const spawnGit = async (args: string[]) => {
|
|
1031
|
+
const spawnGit = async (args: string[], stdin?: string): Promise<GitRun | null> => {
|
|
708
1032
|
try {
|
|
709
1033
|
const proc = Bun.spawn({
|
|
710
1034
|
cmd: ['git', ...args],
|
|
711
1035
|
cwd: workspaceRoot,
|
|
712
|
-
stdin: 'ignore',
|
|
1036
|
+
stdin: stdin === undefined ? 'ignore' : new TextEncoder().encode(stdin),
|
|
713
1037
|
stdout: 'pipe',
|
|
714
1038
|
stderr: 'pipe',
|
|
715
1039
|
})
|
|
@@ -723,20 +1047,28 @@ export async function populateGitFilesCache(
|
|
|
723
1047
|
return null
|
|
724
1048
|
}
|
|
725
1049
|
}
|
|
726
|
-
//
|
|
727
|
-
// (
|
|
728
|
-
//
|
|
729
|
-
//
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
1050
|
+
// Four spawns, one worktree walk. `ls-files -s -v` reads the INDEX only
|
|
1051
|
+
// (~9 ms on a 1000-project tree) and answers two questions at once: every
|
|
1052
|
+
// tracked path's OID and its cache-state flag. `status -uall` is the one
|
|
1053
|
+
// command that walks the worktree, and it answers two as well: which
|
|
1054
|
+
// tracked paths are dirty AND which files are untracked. Asking
|
|
1055
|
+
// `ls-files --others` for the untracked set walked the same tree a second
|
|
1056
|
+
// time (~50 ms of CPU, concurrent with status but contending with it).
|
|
1057
|
+
const [ls, status, prefixRes, coreCfg] = await Promise.all([
|
|
1058
|
+
spawnGit(['ls-files', '-s', '-v', '-z', '--', ...pathspecs]),
|
|
1059
|
+
spawnGit(['status', '--porcelain', '-z', '-uall', '--', ...pathspecs]),
|
|
1060
|
+
// Two answers from one spawn. `--show-prefix` is the repo→workspace path
|
|
1061
|
+
// (empty when the workspace root IS the git root): `ls-files` prints
|
|
1062
|
+
// cwd(workspace)-relative paths but `status` prints repo-root-relative
|
|
1063
|
+
// ones, so when the workspace root is a SUBDIR of the git repo the two
|
|
1064
|
+
// disagree; this lets us key both the same way below. `--git-dir` locates
|
|
1065
|
+
// `info/attributes` for the filter gate (it is not always `.git/` — a
|
|
1066
|
+
// linked worktree's `.git` is a FILE pointing elsewhere).
|
|
1067
|
+
spawnGit(['rev-parse', '--show-prefix', '--git-dir']),
|
|
1068
|
+
// Reads three config keys, no tree scan — the gate for whether a clean
|
|
1069
|
+
// filter can rewrite bytes between the index and the worktree. Exits 1
|
|
1070
|
+
// when none are set, which is the common case and means "no gate".
|
|
1071
|
+
spawnGit(['config', '--get-regexp', '^core\\.(autocrlf|eol|attributesfile)$']),
|
|
740
1072
|
])
|
|
741
1073
|
if (ls === null) {
|
|
742
1074
|
throw new UserError(
|
|
@@ -750,16 +1082,74 @@ export async function populateGitFilesCache(
|
|
|
750
1082
|
`Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
|
|
751
1083
|
)
|
|
752
1084
|
}
|
|
753
|
-
const { files:
|
|
754
|
-
|
|
1085
|
+
const { files: tracked, oids, flagged } = parseLsFilesOutput(ls.stdout)
|
|
1086
|
+
// Normalize `status`'s repo-root-relative paths to workspace-relative (strip
|
|
1087
|
+
// the `--show-prefix`) so the dirty set is keyed identically to the trusted
|
|
1088
|
+
// OID map. Without this, when the workspace root is a git subdir, a modified
|
|
1089
|
+
// tracked file is never pruned from `trusted` and keeps its committed OID —
|
|
1090
|
+
// a STALE cache hit serving old outputs. Empty prefix (workspace == git root,
|
|
1091
|
+
// the common case) is a zero-cost no-op. Paths above the workspace can't be
|
|
1092
|
+
// inputs, so they drop out of the set.
|
|
1093
|
+
// One spawn, two lines: `--show-prefix` then `--git-dir`.
|
|
1094
|
+
const revLines =
|
|
1095
|
+
prefixRes !== null && prefixRes.exitCode === 0 ? prefixRes.stdout.split('\n') : []
|
|
1096
|
+
const gitPrefix = (revLines[0] ?? '').trim()
|
|
1097
|
+
const gitDir = (revLines[1] ?? '').trim()
|
|
1098
|
+
const parsedStatus =
|
|
1099
|
+
status !== null && status.exitCode === 0 ? parseStatusOutput(status.stdout) : null
|
|
1100
|
+
const dirty = parsedStatus === null ? null : stripPrefixFromSet(parsedStatus.dirty, gitPrefix)
|
|
1101
|
+
// Untracked files are inputs too (a new file is the commonest edit there
|
|
1102
|
+
// is). They come from the status walk, repo-root-relative like the dirty
|
|
1103
|
+
// set. Without a status answer the enumeration is the index alone.
|
|
1104
|
+
const untracked =
|
|
1105
|
+
parsedStatus === null ? [] : [...stripPrefixFromSet(new Set(parsedStatus.untracked), gitPrefix)]
|
|
1106
|
+
const all = untracked.length === 0 ? tracked : tracked.concat(untracked)
|
|
755
1107
|
// Aggregate dirtiness for the Tier-3 invocation record — derived from
|
|
756
1108
|
// this same status spawn so `run()` needs no second `git status`.
|
|
757
1109
|
// null when the status spawn failed (non-repo / git error).
|
|
758
|
-
|
|
1110
|
+
const worktreeDirty = dirty === null ? null : dirty.size > 0
|
|
759
1111
|
const trusted = dirty === null ? new Map<string, string>() : oids
|
|
760
1112
|
if (dirty !== null) {
|
|
761
1113
|
for (const rel of dirty) trusted.delete(rel)
|
|
762
1114
|
}
|
|
1115
|
+
// A skip-worktree / assume-unchanged path sits at stage 0 and `git status`
|
|
1116
|
+
// reports nothing for it, so it would otherwise keep a trusted OID — and
|
|
1117
|
+
// resolveFiles SKIPS its existence probe for OID-carrying paths. A sparse
|
|
1118
|
+
// checkout would then count a file that is not on disk as an input, so
|
|
1119
|
+
// materializing it later changes no key and the old artifact is replayed.
|
|
1120
|
+
// Dropping the OID sends these back through the probe, where they correctly
|
|
1121
|
+
// fall out of the input set while unmaterialized.
|
|
1122
|
+
for (const rel of flagged) trusted.delete(rel)
|
|
1123
|
+
// An index OID is only the file's content hash when git stores the worktree
|
|
1124
|
+
// bytes VERBATIM. Under a clean filter (`text`/`eol`/`ident`) the blob is a
|
|
1125
|
+
// DIFFERENT sequence of bytes — the LF-normalized form — while the task
|
|
1126
|
+
// reads the CRLF worktree file. `git status` compares AFTER filtering, so
|
|
1127
|
+
// such a file reports clean and keeps its OID: the CRLF and LF states then
|
|
1128
|
+
// fold the SAME key and a real content change is invisible.
|
|
1129
|
+
//
|
|
1130
|
+
// Dropping an OID is not over-invalidation. It routes the path to
|
|
1131
|
+
// `hashFile`, which hashes the worktree bytes — the source that was correct
|
|
1132
|
+
// all along. The only cost is the read, so the gate below is about paying it
|
|
1133
|
+
// ONLY where a filter can actually apply.
|
|
1134
|
+
await dropFilteredOids(trusted, {
|
|
1135
|
+
workspaceRoot,
|
|
1136
|
+
gitDir,
|
|
1137
|
+
coreConfig: coreCfg !== null && coreCfg.exitCode === 0 ? coreCfg.stdout : '',
|
|
1138
|
+
spawnGit,
|
|
1139
|
+
})
|
|
1140
|
+
return { all, trusted, dirty: worktreeDirty }
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/** The partition half of `populateGitFilesCache`: store per-project slices of one enumeration. */
|
|
1144
|
+
export function applyGitEnumeration(
|
|
1145
|
+
enumeration: GitEnumeration,
|
|
1146
|
+
workspaceRoot: string,
|
|
1147
|
+
projectDirs: readonly string[],
|
|
1148
|
+
cache: GitFilesCache,
|
|
1149
|
+
workspaceWide = false,
|
|
1150
|
+
): void {
|
|
1151
|
+
const { all, trusted } = enumeration
|
|
1152
|
+
cache.setWorktreeDirty(enumeration.dirty)
|
|
763
1153
|
// Sort once, then each project's files are a contiguous range found
|
|
764
1154
|
// by binary search on its `dir/` prefix — O((F+P) log F) instead of
|
|
765
1155
|
// the O(P·F) per-project startsWith scan (54 ms at 1090 projects ×
|
|
@@ -822,7 +1212,11 @@ async function scanUnion(
|
|
|
822
1212
|
): Promise<Set<string>> {
|
|
823
1213
|
const matches = new Set<string>()
|
|
824
1214
|
for (const pattern of positive) {
|
|
825
|
-
const glob =
|
|
1215
|
+
const glob = globFor(pattern)
|
|
1216
|
+
// Async scan, deliberately: the sync walk is faster in isolation (37 µs
|
|
1217
|
+
// vs 56 µs on a one-file `dist/**`) but SERIALISES on the main thread,
|
|
1218
|
+
// and this runs under the scheduler's concurrency — measured 2026-09-02,
|
|
1219
|
+
// switching it to scanSync made a 1000-hit warm run 40 ms SLOWER.
|
|
826
1220
|
for await (const rel of glob.scan({ cwd, onlyFiles: true, dot: true })) {
|
|
827
1221
|
if (excludeGlobs.some((g) => g.match(rel))) continue
|
|
828
1222
|
matches.add(path.resolve(cwd, rel))
|
|
@@ -831,6 +1225,16 @@ async function scanUnion(
|
|
|
831
1225
|
return matches
|
|
832
1226
|
}
|
|
833
1227
|
|
|
1228
|
+
// Compiled once per pattern string for the life of the process: a `Bun.Glob`
|
|
1229
|
+
// is immutable, and the same handful of declared globs are compiled again
|
|
1230
|
+
// for every task otherwise (three sites, up to thousands of times per run).
|
|
1231
|
+
const globMemo = new Map<string, Bun.Glob>()
|
|
1232
|
+
function globFor(pattern: string): Bun.Glob {
|
|
1233
|
+
let g = globMemo.get(pattern)
|
|
1234
|
+
if (g === undefined) globMemo.set(pattern, (g = new Bun.Glob(pattern)))
|
|
1235
|
+
return g
|
|
1236
|
+
}
|
|
1237
|
+
|
|
834
1238
|
function boundaryIgnorePatterns(projectDir: string, nestedDirs: string[]): string[] {
|
|
835
1239
|
return nestedDirs.map((d) => {
|
|
836
1240
|
const rel = path.relative(projectDir, d).split(path.sep).join('/')
|