@vzn/vx 0.0.646 → 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 (184) hide show
  1. package/README.md +29 -22
  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 +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  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 +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -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/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -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()
@@ -244,6 +253,12 @@ interface IgnoredListing {
244
253
  }
245
254
 
246
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[] = []
247
262
  private changed = new Map<string, string[]>()
248
263
  /**
249
264
  * Per-project trusted index OIDs: absolute path → git blob OID, for
@@ -304,7 +319,7 @@ export class GitFilesCache extends Map<string, readonly string[]> {
304
319
  this.foldCase ??=
305
320
  this.vars === null
306
321
  ? process.platform === 'darwin'
307
- : this.vars.get('core.ignorecase')?.trim().toLowerCase() === 'true'
322
+ : gitBool(this.vars.get('core.ignorecase')) === true
308
323
  return this.foldCase ? abs.toLowerCase() : abs
309
324
  }
310
325
 
@@ -328,7 +343,7 @@ export class GitFilesCache extends Map<string, readonly string[]> {
328
343
 
329
344
  /**
330
345
  * Whether git tracks a file under `dir`: committed (or `git add`ed)
331
- * files are the user's, never cleaned (owner, 2026-10-14: "never
346
+ * files are the user's, never cleaned (2026-10-14: "never
332
347
  * delete committed file"). A directory no enumeration covered (a
333
348
  * nested repository, a cache no enumeration filled) is listed once.
334
349
  * git lists a submodule as its gitlink path alone, so a file under a
@@ -551,12 +566,34 @@ export class GitFilesCache extends Map<string, readonly string[]> {
551
566
  const snap = this.get(projectDir)
552
567
  if (snap === undefined) return undefined
553
568
  const pending = this.changed.get(projectDir)
554
- if (pending !== undefined && pending.some((p) => inputGlobs.some((g) => g.match(p)))) {
555
- return undefined
556
- }
569
+ if (pending !== undefined && this.pendingMatch(pending, inputGlobs)) return undefined
557
570
  return snap
558
571
  }
559
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
+
560
597
  override set(key: string, value: readonly string[]): this {
561
598
  this.changed.delete(key)
562
599
  this.oids.delete(key)
@@ -655,12 +692,12 @@ const LS_FILES_STAGE_RE = /^(?:([A-Za-z]) )?([0-7]{6}) ([0-9a-f]{40,64}) ([0-3])
655
692
  * tree; vx requires git. `-z` survives filenames with newlines /
656
693
  * spaces.
657
694
  */
658
- export function runGitLsFiles(cwd: string): GitLsResult {
695
+ export function runGitLsFiles(cwd: string, env = gitEnv()): GitLsResult {
659
696
  let proc
660
697
  try {
661
698
  proc = Bun.spawnSync({
662
699
  cmd: [executablePath('git'), 'ls-files', '-s', '--others', '--exclude-standard', '-z', '.'],
663
- env: gitEnv(),
700
+ env,
664
701
  cwd,
665
702
  stdout: 'pipe',
666
703
  stderr: 'pipe',
@@ -669,9 +706,9 @@ export function runGitLsFiles(cwd: string): GitLsResult {
669
706
  throw gitSpawnRefusal(cwd)
670
707
  }
671
708
  if (proc.exitCode !== 0) {
672
- // Exit 128 = not a git work tree; other non-zero = git failure.
673
- // Either way we can't enumerate inputs reliably.
674
- 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())
675
712
  }
676
713
  const { text, undecodable } = decodeGitZ(proc.stdout)
677
714
  const parsed = parseLsFilesOutput(text.length === 0 ? [] : text.split('\0'), undecodable)
@@ -684,7 +721,9 @@ export function runGitLsFiles(cwd: string): GitLsResult {
684
721
  /**
685
722
  * The files git tracks under `dir`, absolute: one `ls-files` of the index,
686
723
  * no worktree walk. Outside a work tree (an embedder's temp dir) nothing
687
- * 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).
688
727
  */
689
728
  function listTracked(dir: string): string[] {
690
729
  let proc
@@ -697,9 +736,16 @@ function listTracked(dir: string): string[] {
697
736
  stderr: 'pipe',
698
737
  })
699
738
  } catch {
700
- 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
+ )
701
748
  }
702
- if (proc.exitCode !== 0) return []
703
749
  const { text } = decodeGitZ(proc.stdout)
704
750
  return text
705
751
  .split('\0')
@@ -742,13 +788,67 @@ function expandNestedRepos(
742
788
  for (const f of walkFiles(abs)) out.push(`${nested}/${f}`)
743
789
  continue
744
790
  }
745
- 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
+ }
746
801
  for (const f of inner.files) out.push(`${nested}/${f}`)
747
802
  for (const f of inner.undecodable) undecodable.push(`${nested}/${f}`)
748
803
  }
749
804
  return { files: out ?? files, undecodable }
750
805
  }
751
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
+
752
852
  /** Every file and symlink under `dir`, relative and `/`-separated; a `.git` is skipped, a link not followed. */
753
853
  function walkFiles(dir: string, rel = ''): string[] {
754
854
  let entries
@@ -1216,10 +1316,30 @@ function gitVarList(listing: string): Map<string, string> {
1216
1316
  for (const line of listing.split('\n')) {
1217
1317
  const eq = line.indexOf('=')
1218
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')
1219
1322
  }
1220
1323
  return vars
1221
1324
  }
1222
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
+
1223
1343
  /**
1224
1344
  * `true` when git ignores the executable bit (`core.fileMode=false`, the
1225
1345
  * default on WSL's DrvFs and what `git init` writes on a filesystem without
@@ -1227,8 +1347,7 @@ function gitVarList(listing: string): Map<string, string> {
1227
1347
  * says nothing about the worktree.
1228
1348
  */
1229
1349
  function fileModeIgnored(gitVars: string): boolean {
1230
- const v = gitVarList(gitVars).get('core.filemode')?.trim().toLowerCase()
1231
- return v === 'false' || v === 'no' || v === 'off' || v === '0'
1350
+ return gitBool(gitVarList(gitVars).get('core.filemode')) === false
1232
1351
  }
1233
1352
 
1234
1353
  /**
@@ -1265,21 +1384,14 @@ function restampModes(trusted: Map<string, string>, workspaceRoot: string): void
1265
1384
  */
1266
1385
  export function gitStatWeakened(gitVars: string): boolean {
1267
1386
  const vars = gitVarList(gitVars)
1268
- const trustctime = vars.get('core.trustctime')?.trim().toLowerCase()
1269
1387
  const checkStat = vars.get('core.checkstat')?.trim().toLowerCase()
1270
- return (
1271
- trustctime === 'false' ||
1272
- trustctime === 'no' ||
1273
- trustctime === 'off' ||
1274
- trustctime === '0' ||
1275
- checkStat === 'minimal'
1276
- )
1388
+ return gitBool(vars.get('core.trustctime')) === false || checkStat === 'minimal'
1277
1389
  }
1278
1390
 
1279
1391
  /** `true` when git may rewrite bytes for EVERY auto-detected text file. */
1280
1392
  export function autocrlfConverts(gitVars: string): boolean {
1281
- const v = gitVarList(gitVars).get('core.autocrlf')?.trim().toLowerCase()
1282
- return v === 'true' || v === 'input'
1393
+ const v = gitVarList(gitVars).get('core.autocrlf')
1394
+ return v?.trim().toLowerCase() === 'input' || gitBool(v) === true
1283
1395
  }
1284
1396
 
1285
1397
  /**
@@ -1433,6 +1545,8 @@ export interface GitEnumeration {
1433
1545
  scopes: readonly string[]
1434
1546
  /** Workspace-relative path → index OID, for files whose worktree state matched the index. */
1435
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[]
1436
1550
  /** Whether the worktree had uncommitted changes; null when `git status` failed. */
1437
1551
  dirty: boolean | null
1438
1552
  /** `git var -l`'s listing (merged config, then git's variables); null when it failed. */
@@ -1515,6 +1629,19 @@ function hasRacyEntry(index: Uint8Array, mtimeSec: number, oidBytes: number): bo
1515
1629
  /** A private copy a run older than this may still have handed to `status`. */
1516
1630
  const STALE_INDEX_MS = 60_000
1517
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
+
1518
1645
  /**
1519
1646
  * The index `status` reads when the real one holds a racily clean entry: a
1520
1647
  * copy refreshed once and kept beside it, named by the real index's bytes.
@@ -1527,18 +1654,13 @@ const STALE_INDEX_MS = 60_000
1527
1654
  async function refreshedIndex(
1528
1655
  indexFile: string,
1529
1656
  read: { bytes: Uint8Array; hash: string; st: Stats },
1530
- objectFormat: 'sha1' | 'sha256',
1531
1657
  spawnGit: (
1532
1658
  args: string[],
1533
1659
  stdin?: string,
1534
1660
  env?: Record<string, string>,
1535
1661
  ) => Promise<GitRun | null>,
1536
1662
  ): Promise<string | undefined> {
1537
- if (GIT_LOCATION_ENV.some((name) => process.env[name] !== undefined)) return undefined
1538
1663
  const { bytes, hash, st } = read
1539
- if (!hasRacyEntry(bytes, Math.floor(st.mtimeMs / 1000), objectFormat === 'sha256' ? 32 : 20)) {
1540
- return undefined
1541
- }
1542
1664
  const dir = path.dirname(indexFile)
1543
1665
  const name = `vx-index-${hash}`
1544
1666
  const copy = path.join(dir, name)
@@ -1640,15 +1762,6 @@ export async function startGitEnumeration(
1640
1762
  }
1641
1763
  }
1642
1764
  const startedAtMs = Date.now()
1643
- // Four spawns at most (the rev-parse is memoized per process), one
1644
- // worktree walk. `ls-files -s -v` reads the INDEX only
1645
- // (~9 ms on a 1000-project tree) and answers two questions at once: every
1646
- // tracked path's OID and its cache-state flag. `status -uall` is the one
1647
- // command that walks the worktree, and it answers two as well: which
1648
- // tracked paths are dirty AND which files are untracked. Asking
1649
- // `ls-files --others` for the untracked set walked the same tree a second
1650
- // time (~50 ms of CPU, concurrent with status but contending with it).
1651
- const listing = spawnGit(['ls-files', '-s', '-v', '-z', '--', ...pathspecs])
1652
1765
  // The gate for whether a clean filter can rewrite bytes between the
1653
1766
  // index and the worktree: git's merged config (`core.autocrlf`) and,
1654
1767
  // from git 2.42, the attributes files it reads outside the tree
@@ -1657,7 +1770,7 @@ export async function startGitEnumeration(
1657
1770
  // 1.32 ms, min of 30), which could not name the default global file.
1658
1771
  // Not foldable into the rev-parse below: rev-parse prints no config.
1659
1772
  const varsRun = spawnGit(['var', '-l'])
1660
- // 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
1661
1774
  // too (`repoFacts`). `prefix` is the repo→workspace path (empty when the
1662
1775
  // workspace root IS the git root): `ls-files` prints cwd(workspace)-
1663
1776
  // relative paths but `status` prints repo-root-relative ones, so when the
@@ -1683,15 +1796,39 @@ export async function startGitEnumeration(
1683
1796
  facts === null || facts.indexFile === ''
1684
1797
  ? undefined
1685
1798
  : path.resolve(workspaceRoot, facts.indexFile)
1686
- const readIndex = async (): Promise<
1687
- { bytes: Uint8Array; hash: string; st: Stats; key: string; stamp: string } | undefined
1688
- > => {
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 => {
1689
1826
  if (indexFile === undefined) return undefined
1690
1827
  try {
1691
1828
  // Stat before the read: a write between them leaves an mtime older
1692
1829
  // than the bytes, which only makes more entries racy.
1693
1830
  const st = lstatSync(indexFile)
1694
- const bytes = await Bun.file(indexFile).bytes()
1831
+ const bytes = readFileSync(indexFile)
1695
1832
  const hash = xxh3hex(bytes)
1696
1833
  return {
1697
1834
  bytes,
@@ -1704,38 +1841,33 @@ export async function startGitEnumeration(
1704
1841
  return undefined
1705
1842
  }
1706
1843
  }
1707
- const indexKeyRead = readIndex()
1708
- const statusIndex = indexKeyRead.then((read) =>
1709
- read === undefined || indexFile === undefined
1710
- ? undefined
1711
- : refreshedIndex(indexFile, read, facts!.objectFormat, spawnGit),
1712
- )
1713
- const running = Promise.all([
1714
- listing,
1715
- // `--ignored=matching` names an ignored path without descending into an
1716
- // ignored directory: git applies an ignored `.gitattributes` as it does
1717
- // any other, and the filter gate below must see it (A-19).
1718
- // `--no-renames`: a deletion paired as an unmerged path's rename source
1719
- // prints only as `UU <path>`, and the deleted file stayed trusted (A-59).
1720
- statusIndex.then((index) => {
1721
- const env = index === undefined ? undefined : { GIT_INDEX_FILE: index }
1722
- return spawnGit(
1723
- [
1724
- 'status',
1725
- '--porcelain',
1726
- '-z',
1727
- '-uall',
1728
- '--ignored=matching',
1729
- '--no-renames',
1730
- '--',
1731
- ...pathspecs,
1732
- ],
1733
- undefined,
1734
- env,
1735
- )
1736
- }),
1737
- varsRun,
1738
- ])
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])
1739
1871
  const gitPrefix = facts?.prefix ?? ''
1740
1872
  const gitDir = facts?.commonDir ?? ''
1741
1873
  const ls = await listing
@@ -1750,12 +1882,12 @@ export async function startGitEnumeration(
1750
1882
  ? spawnGit(CHECK_ATTR, [...parsedLs.oids.keys()].join('\0'))
1751
1883
  : undefined
1752
1884
  const [, status, vars] = await running
1885
+ const indexMoved = indexStamp() !== stampBefore
1753
1886
  // The key stands for the index `ls-files` listed only when that index
1754
1887
  // was written before the enumeration began (git writes it by rename, so
1755
1888
  // the stamp moves with any write) and is still in place after it: a
1756
1889
  // `git add` between the listing and the read paired one index's verdict
1757
1890
  // with the other's entries. Otherwise this run asks for no verdict.
1758
- const read = await indexKeyRead
1759
1891
  let indexKey: string | undefined
1760
1892
  if (
1761
1893
  read !== undefined &&
@@ -1771,13 +1903,8 @@ export async function startGitEnumeration(
1771
1903
  if (ls === null) {
1772
1904
  throw gitSpawnRefusal(workspaceRoot)
1773
1905
  }
1774
- if (parsedLs === undefined) {
1775
- const stderr = ls.stderr.trim()
1776
- throw new UserError(
1777
- `vx requires git: ${workspaceRoot} is not inside a git work tree. ` +
1778
- `Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
1779
- )
1780
- }
1906
+ if (parsedLs === undefined)
1907
+ throw gitFailure(workspaceRoot, 'ls-files', ls.exitCode, ls.stderr.trim())
1781
1908
  const { files: tracked, oids, flagged, gitlinks, undecodable } = parsedLs
1782
1909
  // Normalize `status`'s repo-root-relative paths to workspace-relative (strip
1783
1910
  // the `--show-prefix`) so the dirty set is keyed identically to the trusted
@@ -1808,7 +1935,7 @@ export async function startGitEnumeration(
1808
1935
  // this same status spawn so `run()` needs no second `git status`.
1809
1936
  // null when the status spawn failed (non-repo / git error).
1810
1937
  const worktreeDirty = dirty === null ? null : dirty.size > 0
1811
- const trusted = dirty === null ? new Map<string, string>() : oids
1938
+ const trusted = dirty === null || indexMoved ? new Map<string, string>() : oids
1812
1939
  if (dirty !== null) {
1813
1940
  for (const rel of dirty) trusted.delete(rel)
1814
1941
  }
@@ -1856,6 +1983,7 @@ export async function startGitEnumeration(
1856
1983
  tracked: gitlinks.size === 0 ? tracked : tracked.concat([...gitlinks]),
1857
1984
  scopes: pathspecs,
1858
1985
  trusted,
1986
+ flagged: [...flagged],
1859
1987
  dirty: worktreeDirty,
1860
1988
  vars: vars !== null && vars.exitCode === 0 ? vars.stdout : null,
1861
1989
  changed: dirty === null ? null : [...dirty, ...untracked],
@@ -1871,6 +1999,10 @@ export async function startGitEnumeration(
1871
1999
  async blobs() {
1872
2000
  const run = await spawnGit(['ls-files', '-s', '-v', '-z', '--debug', '--', ...pathspecs])
1873
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
1874
2006
  const debug = splitLsDebug(run)
1875
2007
  const indexed = new Map<string, { oid: string; size: number }>()
1876
2008
  parseLsFilesOutput(debug.records, debug.undecodable, { sizes: debug.sizes, indexed })
@@ -40,7 +40,7 @@ export {
40
40
  export { ABSENT_INPUT, absentOr, CACHE_VERSION, foldHead } from './key-fold.js'
41
41
  export type { FoldMemo } from './fold-memo.js'
42
42
  export { measureFsClockLag, setFsClockLag } from './fs-clock.js'
43
- export { filesBehind } from './linked-inputs.js'
43
+ export { behindLink, filesBehind } from './linked-inputs.js'
44
44
  export {
45
45
  addedInput,
46
46
  type InputListing,
@@ -57,6 +57,7 @@ export {
57
57
  resolveOutputs,
58
58
  outputWalkTakes,
59
59
  resolveWorkspaceOutputs,
60
+ resolveWorkspaceOutputTree,
60
61
  sweepRestoreTemps,
61
62
  sweepStaging,
62
63
  asTrees,
@@ -91,7 +92,7 @@ export {
91
92
  type RemoteCacheLayer,
92
93
  type RemoteOpenContext,
93
94
  } from './layered-cache.js'
94
- export { networkConcurrency } from './remote-lane.js'
95
+ export { networkConcurrency, sayRemoteFault } from './remote-lane.js'
95
96
  export type { ResolvedRemote } from './remote-lane.js'
96
97
  export {
97
98
  absentPluginCache,