@vzn/vx 0.0.645 → 0.0.648

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 (186) hide show
  1. package/README.md +39 -50
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -24,6 +24,7 @@ import {
24
24
  checkIgnoreSync,
25
25
  executablePath,
26
26
  gitEnv,
27
+ gitFailure,
27
28
  gitSpawnRefusal,
28
29
  isInstalledPath,
29
30
  notAWorkTree,
@@ -178,7 +179,12 @@ function repoFactsFromDisk(dir: string): RepoFacts | undefined {
178
179
  }
179
180
  }
180
181
 
181
- /** `[extensions] objectFormat` of a repository config, sha1 when unset; undefined when unsure. */
182
+ /**
183
+ * `[extensions] objectFormat` of a repository config, sha1 when unset;
184
+ * undefined when unsure, or when `core.worktree` puts the work tree
185
+ * somewhere other than the `.git` directory's parent, which the prefix
186
+ * read off the disk assumes.
187
+ */
182
188
  function configObjectFormat(config: string): 'sha1' | 'sha256' | undefined {
183
189
  let section = ''
184
190
  let format: 'sha1' | 'sha256' = 'sha1'
@@ -191,7 +197,10 @@ function configObjectFormat(config: string): 'sha1' | 'sha256' | undefined {
191
197
  if (section === 'include' || section === 'includeif') return undefined
192
198
  continue
193
199
  }
200
+ if (section === 'core' && /^worktree\b/i.test(line)) return undefined
194
201
  if (section !== 'extensions') continue
202
+ // `config.worktree` may set `core.worktree` too.
203
+ if (/^worktreeconfig\b/i.test(line)) return undefined
195
204
  const kv = /^([A-Za-z0-9-]+)\s*=\s*(.*)$/.exec(line)
196
205
  if (kv === null || kv[1]!.toLowerCase() !== 'objectformat') continue
197
206
  const value = kv[2]!.toLowerCase()
@@ -204,7 +213,8 @@ function configObjectFormat(config: string): 'sha1' | 'sha256' | undefined {
204
213
  interface PartitionDigest {
205
214
  snapshot: readonly string[]
206
215
  oids: OidSlot | undefined
207
- digest: string
216
+ /** Built on first read: a partition with untrusted files stats them then. */
217
+ digest: () => string | undefined
208
218
  }
209
219
 
210
220
  /**
@@ -243,6 +253,12 @@ interface IgnoredListing {
243
253
  }
244
254
 
245
255
  export class GitFilesCache extends Map<string, readonly string[]> {
256
+ /**
257
+ * vx's own directories inside the workspace (the run's cache directory),
258
+ * spelled under the workspace root as the run spells it: no input, even
259
+ * where a glob walks an ignored prefix on disk.
260
+ */
261
+ vxDirs: readonly string[] = []
246
262
  private changed = new Map<string, string[]>()
247
263
  /**
248
264
  * Per-project trusted index OIDs: absolute path → git blob OID, for
@@ -303,7 +319,7 @@ export class GitFilesCache extends Map<string, readonly string[]> {
303
319
  this.foldCase ??=
304
320
  this.vars === null
305
321
  ? process.platform === 'darwin'
306
- : this.vars.get('core.ignorecase')?.trim().toLowerCase() === 'true'
322
+ : gitBool(this.vars.get('core.ignorecase')) === true
307
323
  return this.foldCase ? abs.toLowerCase() : abs
308
324
  }
309
325
 
@@ -327,7 +343,7 @@ export class GitFilesCache extends Map<string, readonly string[]> {
327
343
 
328
344
  /**
329
345
  * Whether git tracks a file under `dir`: committed (or `git add`ed)
330
- * files are the user's, never cleaned (owner, 2026-10-14: "never
346
+ * files are the user's, never cleaned (2026-10-14: "never
331
347
  * delete committed file"). A directory no enumeration covered (a
332
348
  * nested repository, a cache no enumeration filled) is listed once.
333
349
  * git lists a submodule as its gitlink path alone, so a file under a
@@ -515,11 +531,20 @@ export class GitFilesCache extends Map<string, readonly string[]> {
515
531
  const d = this.digests.get(projectDir)
516
532
  if (d === undefined || d.snapshot !== snapshot || this.get(projectDir) !== snapshot) return
517
533
  if (this.oids.get(projectDir) !== d.oids || this.changed.has(projectDir)) return
518
- return d.digest
519
- }
520
-
521
- noteFilesDigest(projectDir: string, snapshot: readonly string[], digest: string): void {
522
- this.digests.set(projectDir, { snapshot, oids: this.oids.get(projectDir), digest })
534
+ return d.digest()
535
+ }
536
+
537
+ noteFilesDigest(
538
+ projectDir: string,
539
+ snapshot: readonly string[],
540
+ digest: () => string | undefined,
541
+ ): void {
542
+ let memo: { value: string | undefined } | undefined
543
+ this.digests.set(projectDir, {
544
+ snapshot,
545
+ oids: this.oids.get(projectDir),
546
+ digest: () => (memo ??= { value: digest() }).value,
547
+ })
523
548
  }
524
549
 
525
550
  /** Trusted index OIDs for a project (abs path → oid), if any survive. */
@@ -541,12 +566,34 @@ export class GitFilesCache extends Map<string, readonly string[]> {
541
566
  const snap = this.get(projectDir)
542
567
  if (snap === undefined) return undefined
543
568
  const pending = this.changed.get(projectDir)
544
- if (pending !== undefined && pending.some((p) => inputGlobs.some((g) => g.match(p)))) {
545
- return undefined
546
- }
569
+ if (pending !== undefined && this.pendingMatch(pending, inputGlobs)) return undefined
547
570
  return snap
548
571
  }
549
572
 
573
+ /**
574
+ * How far each glob has read a `changed` list, -1 once it matched: the
575
+ * list only grows until a reset replaces it, so each glob reads each path
576
+ * once. Every task asked again from the start: 9000 tasks writing into
577
+ * one project cost 10 s of reads (2026-10-25).
578
+ */
579
+ private readonly read = new WeakMap<readonly string[], Map<Bun.Glob, number>>()
580
+
581
+ private pendingMatch(pending: readonly string[], globs: readonly Bun.Glob[]): boolean {
582
+ let upTo = this.read.get(pending)
583
+ if (upTo === undefined) this.read.set(pending, (upTo = new Map()))
584
+ for (const g of globs) {
585
+ let i = upTo.get(g) ?? 0
586
+ if (i < 0) return true
587
+ for (; i < pending.length; i++)
588
+ if (g.match(pending[i]!)) {
589
+ upTo.set(g, -1)
590
+ return true
591
+ }
592
+ upTo.set(g, i)
593
+ }
594
+ return false
595
+ }
596
+
550
597
  override set(key: string, value: readonly string[]): this {
551
598
  this.changed.delete(key)
552
599
  this.oids.delete(key)
@@ -645,12 +692,12 @@ const LS_FILES_STAGE_RE = /^(?:([A-Za-z]) )?([0-7]{6}) ([0-9a-f]{40,64}) ([0-3])
645
692
  * tree; vx requires git. `-z` survives filenames with newlines /
646
693
  * spaces.
647
694
  */
648
- export function runGitLsFiles(cwd: string): GitLsResult {
695
+ export function runGitLsFiles(cwd: string, env = gitEnv()): GitLsResult {
649
696
  let proc
650
697
  try {
651
698
  proc = Bun.spawnSync({
652
699
  cmd: [executablePath('git'), 'ls-files', '-s', '--others', '--exclude-standard', '-z', '.'],
653
- env: gitEnv(),
700
+ env,
654
701
  cwd,
655
702
  stdout: 'pipe',
656
703
  stderr: 'pipe',
@@ -659,9 +706,9 @@ export function runGitLsFiles(cwd: string): GitLsResult {
659
706
  throw gitSpawnRefusal(cwd)
660
707
  }
661
708
  if (proc.exitCode !== 0) {
662
- // Exit 128 = not a git work tree; other non-zero = git failure.
663
- // Either way we can't enumerate inputs reliably.
664
- throw notAWorkTree(cwd, new TextDecoder().decode(proc.stderr).trim())
709
+ // Not a work tree, or git failing in one: either way the inputs
710
+ // cannot be enumerated, and the two have different fixes.
711
+ throw gitFailure(cwd, 'ls-files', proc.exitCode, new TextDecoder().decode(proc.stderr).trim())
665
712
  }
666
713
  const { text, undecodable } = decodeGitZ(proc.stdout)
667
714
  const parsed = parseLsFilesOutput(text.length === 0 ? [] : text.split('\0'), undecodable)
@@ -674,7 +721,9 @@ export function runGitLsFiles(cwd: string): GitLsResult {
674
721
  /**
675
722
  * The files git tracks under `dir`, absolute: one `ls-files` of the index,
676
723
  * no worktree walk. Outside a work tree (an embedder's temp dir) nothing
677
- * is tracked.
724
+ * is tracked. A git that fails otherwise refuses: read as "nothing
725
+ * tracked", every committed file under `dir` was the output clean's to
726
+ * delete (2026-10-28).
678
727
  */
679
728
  function listTracked(dir: string): string[] {
680
729
  let proc
@@ -687,9 +736,16 @@ function listTracked(dir: string): string[] {
687
736
  stderr: 'pipe',
688
737
  })
689
738
  } catch {
690
- return []
739
+ throw gitSpawnRefusal(dir)
740
+ }
741
+ if (proc.exitCode !== 0) {
742
+ const stderr = new TextDecoder().decode(proc.stderr).trim()
743
+ if (/not a git repository/i.test(stderr)) return []
744
+ throw new UserError(
745
+ `git ls-files failed (exit ${proc.exitCode}) in ${dir}: ${stderr || 'no output'}. Run it there to see why.`,
746
+ 'VX_E_GIT',
747
+ )
691
748
  }
692
- if (proc.exitCode !== 0) return []
693
749
  const { text } = decodeGitZ(proc.stdout)
694
750
  return text
695
751
  .split('\0')
@@ -732,13 +788,67 @@ function expandNestedRepos(
732
788
  for (const f of walkFiles(abs)) out.push(`${nested}/${f}`)
733
789
  continue
734
790
  }
735
- const inner = runGitLsFiles(abs)
791
+ let inner
792
+ try {
793
+ inner = runGitLsFiles(abs, nestedRepoEnv())
794
+ } catch {
795
+ // A `.git` git cannot open (a submodule's file naming a lost
796
+ // `.git/modules` entry) answers as no `.git` does: by a walk. It
797
+ // refused the whole run as "not inside a git work tree".
798
+ for (const f of walkFiles(abs)) out.push(`${nested}/${f}`)
799
+ continue
800
+ }
736
801
  for (const f of inner.files) out.push(`${nested}/${f}`)
737
802
  for (const f of inner.undecodable) undecodable.push(`${nested}/${f}`)
738
803
  }
739
804
  return { files: out ?? files, undecodable }
740
805
  }
741
806
 
807
+ /**
808
+ * Variables that name the repository git works on (git's `local_repo_env`),
809
+ * which a nested repository's git must not inherit. A pre-commit hook runs
810
+ * vx with `GIT_INDEX_FILE` or `GIT_DIR` naming the OUTER repository, and
811
+ * the nested listing read the outer index: a file the nested repository
812
+ * tracks under an ignore rule fell out of the inputs, a stale hit. git
813
+ * clears the same list for a submodule's commands; config variables stay.
814
+ */
815
+ const LOCAL_REPO_ENV = new Set([
816
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
817
+ 'GIT_COMMON_DIR',
818
+ 'GIT_DIR',
819
+ 'GIT_GRAFT_FILE',
820
+ 'GIT_IMPLICIT_WORK_TREE',
821
+ 'GIT_INDEX_FILE',
822
+ 'GIT_INTERNAL_SUPER_PREFIX',
823
+ 'GIT_NO_REPLACE_OBJECTS',
824
+ 'GIT_OBJECT_DIRECTORY',
825
+ 'GIT_PREFIX',
826
+ 'GIT_REPLACE_REF_BASE',
827
+ 'GIT_SHALLOW_FILE',
828
+ 'GIT_WORK_TREE',
829
+ ])
830
+
831
+ function nestedRepoEnv(): Record<string, string> {
832
+ const env = gitEnv()
833
+ for (const name of LOCAL_REPO_ENV) delete env[name]
834
+ return env
835
+ }
836
+
837
+ /**
838
+ * The environment a listing of `dir` runs in: `nestedRepoEnv` when a
839
+ * location variable is set and `dir` sits in a repository nested below
840
+ * `workspaceRoot` (a `.git` at it or between), whose git must not read the
841
+ * workspace repository's index; git's own environment otherwise.
842
+ */
843
+ export function listingEnv(dir: string, workspaceRoot: string): Record<string, string> {
844
+ if (![...LOCAL_REPO_ENV].some((name) => process.env[name] !== undefined)) return gitEnv()
845
+ for (let at = dir; at !== workspaceRoot && at.startsWith(workspaceRoot); at = path.dirname(at)) {
846
+ if (existsSync(path.join(at, '.git'))) return nestedRepoEnv()
847
+ if (path.dirname(at) === at) break
848
+ }
849
+ return gitEnv()
850
+ }
851
+
742
852
  /** Every file and symlink under `dir`, relative and `/`-separated; a `.git` is skipped, a link not followed. */
743
853
  function walkFiles(dir: string, rel = ''): string[] {
744
854
  let entries
@@ -1206,10 +1316,30 @@ function gitVarList(listing: string): Map<string, string> {
1206
1316
  for (const line of listing.split('\n')) {
1207
1317
  const eq = line.indexOf('=')
1208
1318
  if (eq > 0) vars.set(line.slice(0, eq), line.slice(eq + 1))
1319
+ // A bare key (`[core] fileMode` with no `=`) is listed without one, and
1320
+ // git reads it as true.
1321
+ else if (eq === -1 && /^[a-z]+\./i.test(line)) vars.set(line, 'true')
1209
1322
  }
1210
1323
  return vars
1211
1324
  }
1212
1325
 
1326
+ /**
1327
+ * A boolean config value as git reads it: `true`/`yes`/`on`, `false`/
1328
+ * `no`/`off` and the empty value, or an integer (`k`/`m`/`g` suffix
1329
+ * allowed) that is true when nonzero. Only `false`/`no`/`off`/`0` were
1330
+ * taken, so `trustctime =` or `fileMode = 00` kept index OIDs trusted
1331
+ * while git judged by the weaker stat. Undefined when unset or not a
1332
+ * boolean git takes.
1333
+ */
1334
+ function gitBool(v: string | undefined): boolean | undefined {
1335
+ if (v === undefined) return undefined
1336
+ const t = v.trim().toLowerCase()
1337
+ if (t === 'true' || t === 'yes' || t === 'on') return true
1338
+ if (t === 'false' || t === 'no' || t === 'off' || t === '') return false
1339
+ const n = /^([-+]?\d+)[kmg]?$/.exec(t)
1340
+ return n === null ? undefined : Number(n[1]) !== 0
1341
+ }
1342
+
1213
1343
  /**
1214
1344
  * `true` when git ignores the executable bit (`core.fileMode=false`, the
1215
1345
  * default on WSL's DrvFs and what `git init` writes on a filesystem without
@@ -1217,8 +1347,7 @@ function gitVarList(listing: string): Map<string, string> {
1217
1347
  * says nothing about the worktree.
1218
1348
  */
1219
1349
  function fileModeIgnored(gitVars: string): boolean {
1220
- const v = gitVarList(gitVars).get('core.filemode')?.trim().toLowerCase()
1221
- return v === 'false' || v === 'no' || v === 'off' || v === '0'
1350
+ return gitBool(gitVarList(gitVars).get('core.filemode')) === false
1222
1351
  }
1223
1352
 
1224
1353
  /**
@@ -1255,21 +1384,14 @@ function restampModes(trusted: Map<string, string>, workspaceRoot: string): void
1255
1384
  */
1256
1385
  export function gitStatWeakened(gitVars: string): boolean {
1257
1386
  const vars = gitVarList(gitVars)
1258
- const trustctime = vars.get('core.trustctime')?.trim().toLowerCase()
1259
1387
  const checkStat = vars.get('core.checkstat')?.trim().toLowerCase()
1260
- return (
1261
- trustctime === 'false' ||
1262
- trustctime === 'no' ||
1263
- trustctime === 'off' ||
1264
- trustctime === '0' ||
1265
- checkStat === 'minimal'
1266
- )
1388
+ return gitBool(vars.get('core.trustctime')) === false || checkStat === 'minimal'
1267
1389
  }
1268
1390
 
1269
1391
  /** `true` when git may rewrite bytes for EVERY auto-detected text file. */
1270
1392
  export function autocrlfConverts(gitVars: string): boolean {
1271
- const v = gitVarList(gitVars).get('core.autocrlf')?.trim().toLowerCase()
1272
- return v === 'true' || v === 'input'
1393
+ const v = gitVarList(gitVars).get('core.autocrlf')
1394
+ return v?.trim().toLowerCase() === 'input' || gitBool(v) === true
1273
1395
  }
1274
1396
 
1275
1397
  /**
@@ -1423,6 +1545,8 @@ export interface GitEnumeration {
1423
1545
  scopes: readonly string[]
1424
1546
  /** Workspace-relative path → index OID, for files whose worktree state matched the index. */
1425
1547
  trusted: Map<string, string>
1548
+ /** Tracked paths git does not look at on disk (assume-unchanged, skip-worktree), workspace-relative. */
1549
+ flagged: readonly string[]
1426
1550
  /** Whether the worktree had uncommitted changes; null when `git status` failed. */
1427
1551
  dirty: boolean | null
1428
1552
  /** `git var -l`'s listing (merged config, then git's variables); null when it failed. */
@@ -1505,6 +1629,19 @@ function hasRacyEntry(index: Uint8Array, mtimeSec: number, oidBytes: number): bo
1505
1629
  /** A private copy a run older than this may still have handed to `status`. */
1506
1630
  const STALE_INDEX_MS = 60_000
1507
1631
 
1632
+ /** Whether `status` should read a refreshed copy of the index (`refreshedIndex`). */
1633
+ function racyIndex(
1634
+ read: { bytes: Uint8Array; st: Stats },
1635
+ objectFormat: 'sha1' | 'sha256',
1636
+ ): boolean {
1637
+ if (GIT_LOCATION_ENV.some((name) => process.env[name] !== undefined)) return false
1638
+ return hasRacyEntry(
1639
+ read.bytes,
1640
+ Math.floor(read.st.mtimeMs / 1000),
1641
+ objectFormat === 'sha256' ? 32 : 20,
1642
+ )
1643
+ }
1644
+
1508
1645
  /**
1509
1646
  * The index `status` reads when the real one holds a racily clean entry: a
1510
1647
  * copy refreshed once and kept beside it, named by the real index's bytes.
@@ -1517,18 +1654,13 @@ const STALE_INDEX_MS = 60_000
1517
1654
  async function refreshedIndex(
1518
1655
  indexFile: string,
1519
1656
  read: { bytes: Uint8Array; hash: string; st: Stats },
1520
- objectFormat: 'sha1' | 'sha256',
1521
1657
  spawnGit: (
1522
1658
  args: string[],
1523
1659
  stdin?: string,
1524
1660
  env?: Record<string, string>,
1525
1661
  ) => Promise<GitRun | null>,
1526
1662
  ): Promise<string | undefined> {
1527
- if (GIT_LOCATION_ENV.some((name) => process.env[name] !== undefined)) return undefined
1528
1663
  const { bytes, hash, st } = read
1529
- if (!hasRacyEntry(bytes, Math.floor(st.mtimeMs / 1000), objectFormat === 'sha256' ? 32 : 20)) {
1530
- return undefined
1531
- }
1532
1664
  const dir = path.dirname(indexFile)
1533
1665
  const name = `vx-index-${hash}`
1534
1666
  const copy = path.join(dir, name)
@@ -1630,15 +1762,6 @@ export async function startGitEnumeration(
1630
1762
  }
1631
1763
  }
1632
1764
  const startedAtMs = Date.now()
1633
- // Four spawns at most (the rev-parse is memoized per process), one
1634
- // worktree walk. `ls-files -s -v` reads the INDEX only
1635
- // (~9 ms on a 1000-project tree) and answers two questions at once: every
1636
- // tracked path's OID and its cache-state flag. `status -uall` is the one
1637
- // command that walks the worktree, and it answers two as well: which
1638
- // tracked paths are dirty AND which files are untracked. Asking
1639
- // `ls-files --others` for the untracked set walked the same tree a second
1640
- // time (~50 ms of CPU, concurrent with status but contending with it).
1641
- const listing = spawnGit(['ls-files', '-s', '-v', '-z', '--', ...pathspecs])
1642
1765
  // The gate for whether a clean filter can rewrite bytes between the
1643
1766
  // index and the worktree: git's merged config (`core.autocrlf`) and,
1644
1767
  // from git 2.42, the attributes files it reads outside the tree
@@ -1647,7 +1770,7 @@ export async function startGitEnumeration(
1647
1770
  // 1.32 ms, min of 30), which could not name the default global file.
1648
1771
  // Not foldable into the rev-parse below: rev-parse prints no config.
1649
1772
  const varsRun = spawnGit(['var', '-l'])
1650
- // Asked while the two above run, from the memo the file hasher reads
1773
+ // Asked while `var` runs, from the memo the file hasher reads
1651
1774
  // too (`repoFacts`). `prefix` is the repo→workspace path (empty when the
1652
1775
  // workspace root IS the git root): `ls-files` prints cwd(workspace)-
1653
1776
  // relative paths but `status` prints repo-root-relative ones, so when the
@@ -1673,15 +1796,39 @@ export async function startGitEnumeration(
1673
1796
  facts === null || facts.indexFile === ''
1674
1797
  ? undefined
1675
1798
  : path.resolve(workspaceRoot, facts.indexFile)
1676
- const readIndex = async (): Promise<
1677
- { bytes: Uint8Array; hash: string; st: Stats; key: string; stamp: string } | undefined
1678
- > => {
1799
+ // `ls-files` and `status` each read the index on their own. A write
1800
+ // between them (an unstage, a checkout) paired one index's OIDs with the
1801
+ // other's verdict: a `git reset` of a staged edit made the file clean to
1802
+ // `status` while `ls-files` had listed the staged blob, and that blob
1803
+ // keyed the committed bytes on disk. The index is stamped before the
1804
+ // listing and after the status; an index that moved trusts no OID.
1805
+ const indexStamp = (): string | undefined => {
1806
+ if (indexFile === undefined) return undefined
1807
+ const st = lstatSync(indexFile, { throwIfNoEntry: false })
1808
+ return st === undefined ? undefined : `${st.ino}:${st.ctimeMs}:${st.size}`
1809
+ }
1810
+ const stampBefore = indexStamp()
1811
+ // Four spawns at most (the rev-parse is memoized per process), one
1812
+ // worktree walk. `ls-files -s -v` reads the INDEX only
1813
+ // (~9 ms on a 1000-project tree) and answers two questions at once: every
1814
+ // tracked path's OID and its cache-state flag. `status -uall` is the one
1815
+ // command that walks the worktree, and it answers two as well: which
1816
+ // tracked paths are dirty AND which files are untracked. Asking
1817
+ // `ls-files --others` for the untracked set walked the same tree a second
1818
+ // time (~50 ms of CPU, concurrent with status but contending with it).
1819
+ const listing = spawnGit(['ls-files', '-s', '-v', '-z', '--', ...pathspecs])
1820
+ // Read synchronously: `status` spawns once the read is in, and an async
1821
+ // read's answer waited for the main thread to yield, behind the workspace
1822
+ // config and discovery (~5 ms of a 1,200-task warm run's wait for git).
1823
+ const readIndex = ():
1824
+ | { bytes: Uint8Array; hash: string; st: Stats; key: string; stamp: string }
1825
+ | undefined => {
1679
1826
  if (indexFile === undefined) return undefined
1680
1827
  try {
1681
1828
  // Stat before the read: a write between them leaves an mtime older
1682
1829
  // than the bytes, which only makes more entries racy.
1683
1830
  const st = lstatSync(indexFile)
1684
- const bytes = await Bun.file(indexFile).bytes()
1831
+ const bytes = readFileSync(indexFile)
1685
1832
  const hash = xxh3hex(bytes)
1686
1833
  return {
1687
1834
  bytes,
@@ -1694,38 +1841,33 @@ export async function startGitEnumeration(
1694
1841
  return undefined
1695
1842
  }
1696
1843
  }
1697
- const indexKeyRead = readIndex()
1698
- const statusIndex = indexKeyRead.then((read) =>
1699
- read === undefined || indexFile === undefined
1700
- ? undefined
1701
- : refreshedIndex(indexFile, read, facts!.objectFormat, spawnGit),
1702
- )
1703
- const running = Promise.all([
1704
- listing,
1705
- // `--ignored=matching` names an ignored path without descending into an
1706
- // ignored directory: git applies an ignored `.gitattributes` as it does
1707
- // any other, and the filter gate below must see it (A-19).
1708
- // `--no-renames`: a deletion paired as an unmerged path's rename source
1709
- // prints only as `UU <path>`, and the deleted file stayed trusted (A-59).
1710
- statusIndex.then((index) => {
1711
- const env = index === undefined ? undefined : { GIT_INDEX_FILE: index }
1712
- return spawnGit(
1713
- [
1714
- 'status',
1715
- '--porcelain',
1716
- '-z',
1717
- '-uall',
1718
- '--ignored=matching',
1719
- '--no-renames',
1720
- '--',
1721
- ...pathspecs,
1722
- ],
1723
- undefined,
1724
- env,
1725
- )
1726
- }),
1727
- varsRun,
1728
- ])
1844
+ const read = readIndex()
1845
+ // `--ignored=matching` names an ignored path without descending into an
1846
+ // ignored directory: git applies an ignored `.gitattributes` as it does
1847
+ // any other, and the filter gate below must see it (A-19).
1848
+ // `--no-renames`: a deletion paired as an unmerged path's rename source
1849
+ // prints only as `UU <path>`, and the deleted file stayed trusted (A-59).
1850
+ const spawnStatus = (index: string | undefined) =>
1851
+ spawnGit(
1852
+ [
1853
+ 'status',
1854
+ '--porcelain',
1855
+ '-z',
1856
+ '-uall',
1857
+ '--ignored=matching',
1858
+ '--no-renames',
1859
+ '--',
1860
+ ...pathspecs,
1861
+ ],
1862
+ undefined,
1863
+ index === undefined ? undefined : { GIT_INDEX_FILE: index },
1864
+ )
1865
+ // An index with no racy entry is read as it is: `status` spawns now.
1866
+ const statusRun =
1867
+ read === undefined || indexFile === undefined || !racyIndex(read, facts!.objectFormat)
1868
+ ? spawnStatus(undefined)
1869
+ : refreshedIndex(indexFile, read, spawnGit).then(spawnStatus)
1870
+ const running = Promise.all([listing, statusRun, varsRun])
1729
1871
  const gitPrefix = facts?.prefix ?? ''
1730
1872
  const gitDir = facts?.commonDir ?? ''
1731
1873
  const ls = await listing
@@ -1740,12 +1882,12 @@ export async function startGitEnumeration(
1740
1882
  ? spawnGit(CHECK_ATTR, [...parsedLs.oids.keys()].join('\0'))
1741
1883
  : undefined
1742
1884
  const [, status, vars] = await running
1885
+ const indexMoved = indexStamp() !== stampBefore
1743
1886
  // The key stands for the index `ls-files` listed only when that index
1744
1887
  // was written before the enumeration began (git writes it by rename, so
1745
1888
  // the stamp moves with any write) and is still in place after it: a
1746
1889
  // `git add` between the listing and the read paired one index's verdict
1747
1890
  // with the other's entries. Otherwise this run asks for no verdict.
1748
- const read = await indexKeyRead
1749
1891
  let indexKey: string | undefined
1750
1892
  if (
1751
1893
  read !== undefined &&
@@ -1761,13 +1903,8 @@ export async function startGitEnumeration(
1761
1903
  if (ls === null) {
1762
1904
  throw gitSpawnRefusal(workspaceRoot)
1763
1905
  }
1764
- if (parsedLs === undefined) {
1765
- const stderr = ls.stderr.trim()
1766
- throw new UserError(
1767
- `vx requires git: ${workspaceRoot} is not inside a git work tree. ` +
1768
- `Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
1769
- )
1770
- }
1906
+ if (parsedLs === undefined)
1907
+ throw gitFailure(workspaceRoot, 'ls-files', ls.exitCode, ls.stderr.trim())
1771
1908
  const { files: tracked, oids, flagged, gitlinks, undecodable } = parsedLs
1772
1909
  // Normalize `status`'s repo-root-relative paths to workspace-relative (strip
1773
1910
  // the `--show-prefix`) so the dirty set is keyed identically to the trusted
@@ -1798,7 +1935,7 @@ export async function startGitEnumeration(
1798
1935
  // this same status spawn so `run()` needs no second `git status`.
1799
1936
  // null when the status spawn failed (non-repo / git error).
1800
1937
  const worktreeDirty = dirty === null ? null : dirty.size > 0
1801
- const trusted = dirty === null ? new Map<string, string>() : oids
1938
+ const trusted = dirty === null || indexMoved ? new Map<string, string>() : oids
1802
1939
  if (dirty !== null) {
1803
1940
  for (const rel of dirty) trusted.delete(rel)
1804
1941
  }
@@ -1846,6 +1983,7 @@ export async function startGitEnumeration(
1846
1983
  tracked: gitlinks.size === 0 ? tracked : tracked.concat([...gitlinks]),
1847
1984
  scopes: pathspecs,
1848
1985
  trusted,
1986
+ flagged: [...flagged],
1849
1987
  dirty: worktreeDirty,
1850
1988
  vars: vars !== null && vars.exitCode === 0 ? vars.stdout : null,
1851
1989
  changed: dirty === null ? null : [...dirty, ...untracked],
@@ -1861,6 +1999,10 @@ export async function startGitEnumeration(
1861
1999
  async blobs() {
1862
2000
  const run = await spawnGit(['ls-files', '-s', '-v', '-z', '--debug', '--', ...pathspecs])
1863
2001
  if (run === null || run.exitCode !== 0) return null
2002
+ // Sizes from an index written since the listing judge entries the
2003
+ // trusted OIDs did not come from: an add there rewrote a resized
2004
+ // entry to one that fits, and the listed OID stood (A-60).
2005
+ if (indexStamp() !== stampBefore) return null
1864
2006
  const debug = splitLsDebug(run)
1865
2007
  const indexed = new Map<string, { oid: string; size: number }>()
1866
2008
  parseLsFilesOutput(debug.records, debug.undecodable, { sizes: debug.sizes, indexed })
@@ -1908,6 +2050,30 @@ export function lazyGitEnumeration(workspaceRoot: string): LazyGitEnumeration {
1908
2050
  }
1909
2051
  }
1910
2052
 
2053
+ /**
2054
+ * Each loose file's stat identity, `hashFile`'s memo key: mode, size,
2055
+ * mtime, ctime and inode. Undefined for a symlink (what it points to
2056
+ * folds too, and its stat does not move with that), a file gone, or one
2057
+ * changed within the racy window, where the memo trusts no stat either.
2058
+ */
2059
+ function statRecords(
2060
+ rels: readonly string[],
2061
+ abs: (rel: string) => string,
2062
+ prefixLength: number,
2063
+ ): string | undefined {
2064
+ if (rels.length === 0) return ''
2065
+ const now = Date.now()
2066
+ let out = ''
2067
+ for (const rel of rels) {
2068
+ const st = lstatSync(abs(rel), { throwIfNoEntry: false })
2069
+ if (st === undefined || !st.isFile()) return undefined
2070
+ if (now - Math.floor(st.ctimeMs) < racyWindowMs(st.ctimeMs, FILE_HASH_RACY_MS)) return
2071
+ const mode = (st.mode & 0o100) !== 0 ? '100755' : '100644'
2072
+ out += `${rel.slice(prefixLength)}\0stat:${mode}:${st.size}:${Math.floor(st.mtimeMs)}:${Math.floor(st.ctimeMs)}:${Number(st.ino)}\n`
2073
+ }
2074
+ return out
2075
+ }
2076
+
1911
2077
  /** The partition half of `populateGitFilesCache`: store per-project slices of one enumeration. */
1912
2078
  export async function applyGitEnumeration(
1913
2079
  enumeration: GitEnumeration,
@@ -2006,15 +2172,20 @@ export async function applyGitEnumeration(
2006
2172
  }
2007
2173
  // One hash of the records joined: one per record was ~25 ms of 125,000.
2008
2174
  let records: string | undefined = ''
2175
+ // A file git vouches for no OID (untracked, or changed since the
2176
+ // index) is named by its stat instead, the identity `hashFile`'s memo
2177
+ // trusts: a just-added config or an edited file turned the fold memo
2178
+ // off for its whole project (+3-5 ms of solid's warm run, 2026-10-22).
2179
+ const loose: string[] = []
2009
2180
  for (let i = start; i < end; i++) {
2010
2181
  const rel = sorted[i]!
2011
2182
  const own = rel.slice(prefix.length)
2012
2183
  matches.push(own)
2013
2184
  if (records === undefined) continue
2014
2185
  const oid = trusted.get(rel)
2015
- if (oid === undefined || oid.startsWith('120000:') || undecodable?.has(rel) === true) {
2016
- records = undefined
2017
- } else records += `${own}\0${oid}\n`
2186
+ if (undecodable?.has(rel) === true || oid?.startsWith('120000:') === true) records = undefined
2187
+ else if (oid === undefined) loose.push(rel)
2188
+ else records += `${own}\0${oid}\n`
2018
2189
  }
2019
2190
  // An empty slice is a directory git did not see, not an empty project:
2020
2191
  // a project has at least its package.json, tracked or untracked. A
@@ -2033,7 +2204,13 @@ export async function applyGitEnumeration(
2033
2204
  // setLazyOids AFTER set — set() drops the project's OID slot.
2034
2205
  cache.set(projectDir, matches)
2035
2206
  cache.setLazyOids(projectDir, projOids)
2036
- if (records !== undefined) cache.noteFilesDigest(projectDir, matches, xxh3hex(records))
2207
+ if (records !== undefined) {
2208
+ const known = records
2209
+ cache.noteFilesDigest(projectDir, matches, () => {
2210
+ const statted = statRecords(loose, projAbs, prefix.length)
2211
+ return statted === undefined ? undefined : xxh3hex(`${known}\0${statted}`)
2212
+ })
2213
+ }
2037
2214
  }
2038
2215
  if (workspaceWide) {
2039
2216
  cache.set(workspaceRoot, all)
@@ -29,6 +29,7 @@ export {
29
29
  WORKSPACE_OUTPUT_PREFIX,
30
30
  type OutputDirRow,
31
31
  OUTPUT_DIRS_CAP,
32
+ OUTPUT_DIRS_OVER_CAP,
32
33
  FILE_HASH_RACY_MS,
33
34
  OUTPUT_DIRS_RACY_MS,
34
35
  racyWindowMs,
@@ -39,7 +40,7 @@ export {
39
40
  export { ABSENT_INPUT, absentOr, CACHE_VERSION, foldHead } from './key-fold.js'
40
41
  export type { FoldMemo } from './fold-memo.js'
41
42
  export { measureFsClockLag, setFsClockLag } from './fs-clock.js'
42
- export { filesBehind } from './linked-inputs.js'
43
+ export { behindLink, filesBehind } from './linked-inputs.js'
43
44
  export {
44
45
  addedInput,
45
46
  type InputListing,
@@ -54,7 +55,9 @@ export {
54
55
  type ResolveInputsArgs,
55
56
  resolveOutputTree,
56
57
  resolveOutputs,
58
+ outputWalkTakes,
57
59
  resolveWorkspaceOutputs,
60
+ resolveWorkspaceOutputTree,
58
61
  sweepRestoreTemps,
59
62
  sweepStaging,
60
63
  asTrees,
@@ -66,6 +69,7 @@ export {
66
69
  stampOutputs,
67
70
  stampWorkspaceOutputs,
68
71
  stopRuntimeProbes,
72
+ answerRuntime,
69
73
  type OutputStamp,
70
74
  } from './inputs.js'
71
75
  export {
@@ -84,10 +88,11 @@ export {
84
88
  export {
85
89
  type ArtifactInfo,
86
90
  LayeredCache,
91
+ type LentAccess,
87
92
  type RemoteCacheLayer,
88
93
  type RemoteOpenContext,
89
94
  } from './layered-cache.js'
90
- export { networkConcurrency } from './remote-lane.js'
95
+ export { networkConcurrency, sayRemoteFault } from './remote-lane.js'
91
96
  export type { ResolvedRemote } from './remote-lane.js'
92
97
  export {
93
98
  absentPluginCache,