@vzn/vx 0.0.16 → 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.
Files changed (111) hide show
  1. package/README.md +66 -89
  2. package/index.ts +10 -0
  3. package/launcher.mjs +10 -11
  4. package/package.json +19 -5
  5. package/plugins/local-cache/index.ts +2 -0
  6. package/plugins/local-executor/index.ts +2 -0
  7. package/plugins/schedule-history/index.ts +2 -0
  8. package/src/bin.ts +2 -2
  9. package/src/cache/archive.ts +557 -0
  10. package/src/cache/cache.ts +929 -250
  11. package/src/cache/cas-backend.ts +20 -2
  12. package/src/cache/chained-cache.ts +202 -0
  13. package/src/cache/digest.ts +0 -15
  14. package/src/cache/index.ts +13 -3
  15. package/src/cache/inputs.ts +445 -41
  16. package/src/cache/layered-cache.ts +88 -30
  17. package/src/cache/tar-stream.ts +358 -0
  18. package/src/cli/cache.ts +36 -10
  19. package/src/cli/help.ts +167 -128
  20. package/src/cli/index.ts +74 -43
  21. package/src/cli/info.ts +30 -0
  22. package/src/cli/last.ts +140 -0
  23. package/src/cli/migrate-emit.ts +21 -0
  24. package/src/cli/migrate-nx.ts +23 -0
  25. package/src/cli/migrate-scripts.ts +127 -0
  26. package/src/cli/migrate-turbo.ts +43 -6
  27. package/src/cli/migrate.ts +119 -33
  28. package/src/cli/plan-format.ts +58 -3
  29. package/src/cli/plugin-commands.ts +96 -0
  30. package/src/cli/prune.ts +291 -0
  31. package/src/cli/run.ts +272 -80
  32. package/src/cli/upgrade.ts +7 -6
  33. package/src/cli/watch.ts +259 -61
  34. package/src/cli/why.ts +193 -0
  35. package/src/config.ts +40 -23
  36. package/src/exec/env.ts +7 -1
  37. package/src/exec/executor.ts +239 -0
  38. package/src/exec/index.ts +19 -1
  39. package/src/exec/runner.ts +116 -29
  40. package/src/exec/sandbox-runtime.ts +194 -24
  41. package/src/graph/index.ts +3 -1
  42. package/src/graph/scheduler.ts +92 -18
  43. package/src/graph/task-graph.ts +220 -6
  44. package/src/index.ts +82 -84
  45. package/src/orchestrator/deferred-outputs.ts +144 -0
  46. package/src/orchestrator/download-policy.ts +171 -0
  47. package/src/orchestrator/events.ts +30 -0
  48. package/src/orchestrator/execute-task.ts +328 -94
  49. package/src/orchestrator/failure-mode.ts +61 -0
  50. package/src/orchestrator/framed-output.ts +87 -35
  51. package/src/orchestrator/history.ts +78 -31
  52. package/src/orchestrator/index.ts +43 -90
  53. package/src/orchestrator/local-shortcircuit.ts +22 -0
  54. package/src/orchestrator/logger.ts +196 -14
  55. package/src/orchestrator/metrics.ts +57 -1376
  56. package/src/orchestrator/missing-plugin.ts +9 -0
  57. package/src/orchestrator/options.ts +22 -4
  58. package/src/orchestrator/plan.ts +144 -1
  59. package/src/orchestrator/plugin-host.ts +199 -119
  60. package/src/orchestrator/plugin.ts +144 -57
  61. package/src/orchestrator/prepare.ts +220 -69
  62. package/src/orchestrator/remote-prefetch.ts +12 -8
  63. package/src/orchestrator/resources.ts +20 -34
  64. package/src/orchestrator/run-artifacts.ts +41 -16
  65. package/src/orchestrator/run-context.ts +167 -29
  66. package/src/orchestrator/run-report.ts +73 -46
  67. package/src/orchestrator/run.ts +590 -122
  68. package/src/orchestrator/stable-keys.ts +74 -33
  69. package/src/orchestrator/status-line.ts +47 -8
  70. package/src/orchestrator/summary.ts +22 -1
  71. package/src/orchestrator/tally.ts +78 -35
  72. package/src/orchestrator/task-hash.ts +105 -11
  73. package/src/orchestrator/task-log-buffer.ts +291 -0
  74. package/src/orchestrator/telemetry-host.ts +24 -6
  75. package/src/orchestrator/telemetry.ts +181 -21
  76. package/src/orchestrator/upstream.ts +72 -31
  77. package/src/orchestrator/verify.ts +12 -2
  78. package/src/plugins/local-cache/index.ts +11 -0
  79. package/src/plugins/local-executor/index.ts +45 -0
  80. package/src/plugins/schedule-history/index.ts +109 -0
  81. package/src/util/edit-distance.ts +21 -0
  82. package/src/util/errors.ts +15 -1
  83. package/src/util/index.ts +7 -2
  84. package/src/util/num.ts +37 -0
  85. package/src/util/paths.ts +43 -0
  86. package/src/util/settle.ts +48 -0
  87. package/src/util/size.ts +5 -1
  88. package/src/util/tail.ts +66 -0
  89. package/src/util/timing.ts +63 -0
  90. package/src/workspace/affected.ts +142 -15
  91. package/src/workspace/config-cache.ts +304 -0
  92. package/src/workspace/config-eval.ts +210 -0
  93. package/src/workspace/config-imports.ts +161 -0
  94. package/src/workspace/filter.ts +22 -4
  95. package/src/workspace/fingerprint.ts +16 -1
  96. package/src/workspace/index.ts +15 -3
  97. package/src/workspace/lockfile.ts +13 -2
  98. package/src/workspace/nested-dirs.ts +21 -7
  99. package/src/workspace/package-graph.ts +23 -20
  100. package/src/workspace/project-loader.ts +503 -59
  101. package/src/workspace/workspace.ts +213 -72
  102. package/src/cache/tar.ts +0 -343
  103. package/src/cli/backend.ts +0 -23
  104. package/src/cli/mcp-rpc.ts +0 -292
  105. package/src/cli/mcp.ts +0 -92
  106. package/src/orchestrator/devframe-surface.ts +0 -74
  107. package/src/orchestrator/predict.ts +0 -106
  108. package/src/orchestrator/protocol.ts +0 -156
  109. package/src/orchestrator/run-state.ts +0 -145
  110. package/src/orchestrator/wire-render.ts +0 -92
  111. package/src/orchestrator/wire.ts +0 -212
@@ -19,7 +19,8 @@
19
19
  // supported.
20
20
 
21
21
  import path from 'node:path'
22
- import { rm } from 'node:fs/promises'
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
- (p) => new Bun.Glob(p),
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
- (p) => new Bun.Glob(p),
267
- )
268
- return [...(await scanUnion(args.outputs, excludeGlobs, args.projectDir))].sort()
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
- return [...(await scanUnion(args.outputs, [], args.workspaceRoot))].sort()
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
- (p) => new Bun.Glob(p),
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((p) => new Bun.Glob(p))
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
- const LS_FILES_STAGE_RE = /^([0-7]{6}) ([0-9a-f]{40,64}) ([0-3])\t/
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
- if (out.length === 0) return { files, oids }
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[1]!
643
- const stage = m[3]!
779
+ const mode = m[2]!
780
+ const stage = m[4]!
644
781
  if ((mode === '100644' || mode === '100755') && stage === '0') {
645
- oids.set(filePath, m[2]!)
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
- return { files, oids }
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
- function parseStatusOutput(out: string): Set<string> {
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
- // The two spawns are independent — run them concurrently so the
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
- // Pathspec scoping: when the run only needs a handful of projects
727
- // (scoped config loading), let git scan just those dirs — 75 ms →
728
- // 11 ms on an 11k-file repo. Above 64 dirs (or when a project IS
729
- // the root) the whole-tree scan wins on arg/exec overhead anyway.
730
- const rels = projectDirs.map((d) => path.relative(workspaceRoot, d).split(path.sep).join('/'))
731
- const scoped =
732
- !workspaceWide &&
733
- rels.length > 0 &&
734
- rels.length <= 64 &&
735
- rels.every((r) => r !== '' && r !== '.')
736
- const pathspecs = scoped ? rels : ['.']
737
- const [ls, status] = await Promise.all([
738
- spawnGit(['ls-files', '-s', '--others', '--exclude-standard', '-z', '--', ...pathspecs]),
739
- spawnGit(['status', '--porcelain', '-z', '--', ...pathspecs]),
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: all, oids } = parseLsFilesOutput(ls.stdout)
754
- const dirty = status !== null && status.exitCode === 0 ? parseStatusOutput(status.stdout) : null
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
- cache.setWorktreeDirty(dirty === null ? null : dirty.size > 0)
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 = new Bun.Glob(pattern)
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('/')