@vzn/vx 0.0.651 → 0.0.653

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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
@@ -0,0 +1,102 @@
1
+ // The index's WAL checkpoints, on a thread of their own while a run saves.
2
+ // SQLite checkpoints on the committing connection once the WAL passes 1,000
3
+ // pages: on a cold run that was the main thread, the scheduler's, copying
4
+ // pages and waiting on two fsyncs a checkpoint, stalls of up to 750 ms in
5
+ // a cold 8,002-task run (2026-11-06). Here a second connection checkpoints
6
+ // PASSIVE on a timer: it never waits on a lock and never blocks the writer,
7
+ // which appends as before; the run's connection checkpoints nothing until
8
+ // its close, which finds the WAL nearly copied.
9
+
10
+ import { SQLITE_FCNTL_PERSIST_WAL } from './schema.js'
11
+
12
+ /** How often the thread checkpoints: about 1,000 pages of a cold run's writes. */
13
+ const EVERY_MS = 100
14
+
15
+ // The thread as source, not a file: a compiled vx carries only its entry
16
+ // points' graphs (spawn-off.ts does the same).
17
+ const WORKER = `
18
+ const { Database } = require('bun:sqlite')
19
+ let db
20
+ let timer
21
+ const schemas = []
22
+ const tick = () => {
23
+ for (const s of schemas) {
24
+ try {
25
+ db.exec('PRAGMA ' + s + '.wal_checkpoint(PASSIVE)')
26
+ } catch {
27
+ // Busy or gone: the next tick, or the run's close, copies it.
28
+ }
29
+ }
30
+ }
31
+ self.onmessage = (e) => {
32
+ const m = e.data
33
+ if (m.open !== undefined) {
34
+ db = new Database(m.open.index)
35
+ // As Cache opens it: a close here must not delete the WAL (O-10).
36
+ db.fileControl(${SQLITE_FCNTL_PERSIST_WAL}, 1)
37
+ schemas.push('main')
38
+ if (m.open.store !== undefined) {
39
+ // Finalized: a statement still alive left the close a zombie
40
+ // connection, and a write right after the run was SQLITE_BUSY
41
+ // (shared-store.test.ts).
42
+ const attach = db.prepare('ATTACH DATABASE ? AS store')
43
+ attach.run(m.open.store)
44
+ attach.finalize()
45
+ db.fileControl('store', ${SQLITE_FCNTL_PERSIST_WAL}, 1)
46
+ schemas.push('store')
47
+ }
48
+ timer = setInterval(tick, m.everyMs)
49
+ return
50
+ }
51
+ clearInterval(timer)
52
+ try {
53
+ db?.close()
54
+ } catch {
55
+ // The run's own close copies what is left.
56
+ }
57
+ postMessage({ ended: true })
58
+ }
59
+ `
60
+
61
+ export class WalCheckpointer {
62
+ private readonly worker: Worker
63
+ private failed = false
64
+ private ended: (() => void) | undefined
65
+
66
+ /** Starts checkpointing `files` every `everyMs`; `onError` hears a thread that could not. */
67
+ constructor(
68
+ files: { index: string; store?: string },
69
+ onError: () => void,
70
+ everyMs: number = EVERY_MS,
71
+ ) {
72
+ const w = new Worker(URL.createObjectURL(new Blob([WORKER], { type: 'text/javascript' })))
73
+ // A run that throws before `stop` must still exit.
74
+ w.unref()
75
+ w.onmessage = () => this.ended?.()
76
+ w.onerror = (e) => {
77
+ e.preventDefault()
78
+ this.failed = true
79
+ this.ended?.()
80
+ onError()
81
+ }
82
+ w.postMessage({ open: files, everyMs })
83
+ this.worker = w
84
+ }
85
+
86
+ /** Stops the timer and closes the thread's connection, so the run's close is the last. */
87
+ async stop(): Promise<void> {
88
+ if (!this.failed) {
89
+ this.worker.ref()
90
+ await new Promise<void>((resolve) => {
91
+ this.ended = resolve
92
+ this.worker.postMessage({ end: true })
93
+ })
94
+ }
95
+ this.worker.terminate()
96
+ }
97
+
98
+ /** A close that could not wait for `stop`: the thread ends where it is. */
99
+ terminate(): void {
100
+ this.worker.terminate()
101
+ }
102
+ }
@@ -138,7 +138,7 @@ export class FileHashStore {
138
138
  * form while this hashes the worktree bytes, so `inputs.ts` drops
139
139
  * the index OID for those paths and routes them here.
140
140
  */
141
- async hashFile(filePath: string): Promise<string> {
141
+ async hashFile(filePath: string, seen?: (ctimeMs: number) => void): Promise<string> {
142
142
  // lstatSync intentional: a single stat is ~1.6µs (Bun 1.3); the
143
143
  // async-stat equivalent adds ~75µs of Promise machinery per call.
144
144
  // Promise.all over the batched callers (key derivation) gives no
@@ -154,6 +154,7 @@ export class FileHashStore {
154
154
  // a more useful error.
155
155
  return await this.hashFileFromDisk(filePath)
156
156
  }
157
+ seen?.(st.ctimeMs)
157
158
  // A symlink's identity is git's: the blob of its TARGET STRING, its
158
159
  // mode-120000 index OID, so a retarget moves it. What it points to
159
160
  // folds after it (linked-inputs.ts). No memo: readlink is one syscall,
@@ -22,6 +22,7 @@ export interface FoldMemo {
22
22
 
23
23
  export class IndexFoldMemo implements FoldMemo {
24
24
  private rows: Map<string, FoldMemoRow> | undefined
25
+ private readonly runRows = new Map<string, FoldMemoRow | null>()
25
26
  private readonly dirty = new Map<string, FoldMemoRow>()
26
27
 
27
28
  constructor(
@@ -32,18 +33,37 @@ export class IndexFoldMemo implements FoldMemo {
32
33
 
33
34
  /** The key remembered for `taskId` under `probe`, if that is the probe it was folded under. */
34
35
  get(taskId: string, probe: string): string | undefined {
35
- // One read of every row: a warm run asks for nearly all of them.
36
+ const row = this.dirty.get(taskId) ?? this.read(taskId)
37
+ return row?.probe === probe ? row.key : undefined
38
+ }
39
+
40
+ private read(taskId: string): FoldMemoRow | null | undefined {
41
+ // A run's own rows (`\0run\0…`, the graph's) are read one each: a warm
42
+ // run whose keys are remembered whole asks for no task's row, and
43
+ // reading all of them held every task's row to the end of the run.
44
+ if (taskId.startsWith('\0')) {
45
+ let row = this.runRows.get(taskId)
46
+ if (row === undefined) {
47
+ row = this.db
48
+ .query('SELECT probe, key FROM fold_memo WHERE task_id = ?')
49
+ .get(taskId) as FoldMemoRow | null
50
+ this.runRows.set(taskId, row)
51
+ }
52
+ return row
53
+ }
54
+ // One read of every task's row: a run that folds asks for nearly all of them.
36
55
  this.rows ??= new Map(
37
56
  (
38
- this.db.query('SELECT task_id, probe, key FROM fold_memo').all() as Array<{
57
+ this.db
58
+ .query('SELECT task_id, probe, key FROM fold_memo WHERE task_id >= ?')
59
+ .all('\u0001') as Array<{
39
60
  task_id: string
40
61
  probe: string
41
62
  key: string
42
63
  }>
43
64
  ).map((r) => [r.task_id, { probe: r.probe, key: r.key }]),
44
65
  )
45
- const row = this.dirty.get(taskId) ?? this.rows.get(taskId)
46
- return row?.probe === probe ? row.key : undefined
66
+ return this.rows.get(taskId)
47
67
  }
48
68
 
49
69
  set(taskId: string, probe: string, key: string): void {
@@ -40,6 +40,32 @@ export function stampGrainMs(stampMs: number): number {
40
40
  return stampMs % 1000 === 0 ? 1000 : 0
41
41
  }
42
42
 
43
+ /**
44
+ * "Now" as the file system will stamp the next write, not as `Date.now()`
45
+ * reads it: a stamp read off a file written and removed in `dir` (the
46
+ * run's cache dir), and every later write's mtime and ctime is at or after
47
+ * it. Linux before 6.13 stamps from its coarse clock, a tick behind the
48
+ * fine clock `Date.now()` reads — on the Linux bench box 2026-09-11, 2 of
49
+ * 3,000 tight writes carried an mtime 5.8 ms EARLIER than a `Date.now()`
50
+ * taken before the write, and more under CPU load. That lag is under the
51
+ * one `measureFsClockLag` applies, so a window that asks "written after
52
+ * this moment?" takes the moment from here: `vx watch`'s arm and cycle
53
+ * start (an edit right after the ready line was dropped as "before the
54
+ * arm"), and the re-check's command start. A dir that cannot take the
55
+ * file falls back to `Date.now()` less the measured lag.
56
+ */
57
+ export function fsClockNow(dir: string): number {
58
+ const stamp = path.join(dir, `.vx-clock-${process.pid}`)
59
+ try {
60
+ writeFileSync(stamp, '')
61
+ const at = statSync(stamp).mtimeMs
62
+ unlinkSync(stamp)
63
+ return at
64
+ } catch {
65
+ return Date.now() - lagMs
66
+ }
67
+ }
68
+
43
69
  /**
44
70
  * Measure the lag from a file written and removed in `dir` (the run's cache
45
71
  * dir: on the workspace's file system by default, and ignored by git and
@@ -8,7 +8,9 @@
8
8
  import os from 'node:os'
9
9
  import path from 'node:path'
10
10
  import {
11
+ accessSync,
11
12
  closeSync,
13
+ constants as fsConstants,
12
14
  existsSync,
13
15
  fstatSync,
14
16
  lstatSync,
@@ -27,9 +29,11 @@ import {
27
29
  import {
28
30
  checkIgnoreSync,
29
31
  executablePath,
32
+ gitCaseArgs,
30
33
  gitEnv,
31
34
  gitFailure,
32
35
  gitSpawnRefusal,
36
+ ignoreCaseLies,
33
37
  isInstalledPath,
34
38
  notAWorkTree,
35
39
  relPosix,
@@ -41,7 +45,18 @@ import { FILE_HASH_RACY_MS, racyWindowMs } from './layer.js'
41
45
  /** A name no ignore rule names, standing for a file a task may create. */
42
46
  const FRESH_NAME = '__vx_probe__'
43
47
 
44
- /** Three facts of the repository a directory is in: read off the disk, or one `git rev-parse`. */
48
+ /**
49
+ * Which of `dirs` (relative to `root`) git ignores a new file directly in,
50
+ * in one `git check-ignore`: `GitFilesCache.newFilesIgnored`'s probe, which
51
+ * watch asks of the entries a task names (`namedIgnoredReach`).
52
+ */
53
+ export function newFilesIgnoredIn(root: string, dirs: readonly string[]): Set<string> {
54
+ const probes = dirs.map((d) => `${d}/${FRESH_NAME}`)
55
+ const hit = checkIgnoreSync(root, probes)
56
+ return new Set(dirs.filter((_, i) => hit?.has(probes[i]!) === true))
57
+ }
58
+
59
+ /** The facts of the repository a directory is in: read off the disk, or one `git rev-parse`. */
45
60
  export interface RepoFacts {
46
61
  /** `--show-prefix`: the directory's path below the worktree root, `''` at the root. */
47
62
  prefix: string
@@ -51,6 +66,8 @@ export interface RepoFacts {
51
66
  objectFormat: 'sha1' | 'sha256'
52
67
  /** `--git-path index` (it honours `GIT_INDEX_FILE`), relative to the directory asked. */
53
68
  indexFile: string
69
+ /** `--absolute-git-dir`: the per-worktree git dir, which the sandbox walls off when it lies outside. */
70
+ gitDir: string
54
71
  }
55
72
 
56
73
  const repoFactsMemo = new Map<string, RepoFacts>()
@@ -82,6 +99,7 @@ export function repoFacts(dir: string): RepoFacts | null {
82
99
  '--show-object-format',
83
100
  '--git-path',
84
101
  'index',
102
+ '--absolute-git-dir',
85
103
  ],
86
104
  env: gitEnv(),
87
105
  cwd: dir,
@@ -95,9 +113,12 @@ export function repoFacts(dir: string): RepoFacts | null {
95
113
  // One line per flag, in order. A git that does not know
96
114
  // `--show-object-format` echoes it back, as it does any unknown flag,
97
115
  // which reads as sha1 — what the flag's own spawn answered there too.
98
- const [prefix = '', commonDir = '', format = '', indexFile = ''] = new TextDecoder('utf-8', {
99
- ignoreBOM: true,
100
- })
116
+ const [prefix = '', commonDir = '', format = '', indexFile = '', gitDir = ''] = new TextDecoder(
117
+ 'utf-8',
118
+ {
119
+ ignoreBOM: true,
120
+ },
121
+ )
101
122
  .decode(proc.stdout)
102
123
  .split('\n')
103
124
  .map((l) => l.trim())
@@ -106,6 +127,7 @@ export function repoFacts(dir: string): RepoFacts | null {
106
127
  commonDir,
107
128
  objectFormat: format === 'sha256' ? 'sha256' : 'sha1',
108
129
  indexFile,
130
+ gitDir,
109
131
  }
110
132
  repoFactsMemo.set(dir, facts)
111
133
  return facts
@@ -172,6 +194,7 @@ function repoFactsFromDisk(dir: string): RepoFacts | undefined {
172
194
  commonDir: relPosix(real, dotGit),
173
195
  objectFormat,
174
196
  indexFile: path.relative(real, path.join(dotGit, 'index')).split(path.sep).join('/'),
197
+ gitDir: dotGit,
175
198
  }
176
199
  }
177
200
  const parent = path.dirname(at)
@@ -221,6 +244,24 @@ interface PartitionDigest {
221
244
  digest: () => string | undefined
222
245
  }
223
246
 
247
+ /**
248
+ * `compute`'s answer, computed on the first ask, after which `compute` is
249
+ * let go: a digest's records are a rope of three strings per file, ~3.7 MB
250
+ * of heap at 1,601 projects, held to the end of the run otherwise. Built
251
+ * here, not in the caller, so the caller's scope (which holds `compute`)
252
+ * is not the getter's.
253
+ */
254
+ function once<T>(compute: (() => T) | undefined): () => T | undefined {
255
+ let value: T | undefined
256
+ return () => {
257
+ if (compute !== undefined) {
258
+ value = compute()
259
+ compute = undefined
260
+ }
261
+ return value
262
+ }
263
+ }
264
+
224
265
  /**
225
266
  * A partition's trusted OIDs, built on first read: a warm run whose keys
226
267
  * come back from the fold memo reads none, and building 125,000 abs-path
@@ -252,6 +293,8 @@ class OidSlot {
252
293
 
253
294
  interface IgnoredListing {
254
295
  root: string
296
+ /** `root/` when `root` is absolute and normalized: a normalized path under it is relative as its rest. */
297
+ base: string | undefined
255
298
  paths: readonly string[]
256
299
  asked: Map<string, boolean>
257
300
  }
@@ -314,7 +357,8 @@ export class GitFilesCache extends Map<string, readonly string[]> {
314
357
  /**
315
358
  * Under `core.ignorecase` (macOS's default volume) git's `Foo.txt` is the
316
359
  * disk's `foo.txt` after a rename git never saw, so paths are compared
317
- * case-folded. Decided at the first note: the run's `git var -l`, else
360
+ * case-folded. Decided at the first note: the run's `git var -l` (false
361
+ * there when the disk does not fold case, `ignoreCaseLies`), else
318
362
  * the platform.
319
363
  */
320
364
  private foldCase: boolean | undefined
@@ -385,7 +429,13 @@ export class GitFilesCache extends Map<string, readonly string[]> {
385
429
  private ignored: IgnoredListing | null = null
386
430
 
387
431
  setIgnored(root: string, paths: readonly string[]): void {
388
- this.ignored = { root, paths, asked: new Map() }
432
+ const base =
433
+ path.sep === '/' && path.isAbsolute(root) && path.normalize(root) === root
434
+ ? root.endsWith('/')
435
+ ? root
436
+ : `${root}/`
437
+ : undefined
438
+ this.ignored = { root, base, paths, asked: new Map() }
389
439
  }
390
440
 
391
441
  /**
@@ -400,7 +450,17 @@ export class GitFilesCache extends Map<string, readonly string[]> {
400
450
  newFilesIgnored(dir: string): boolean {
401
451
  const ig = this.ignored
402
452
  if (ig === null || ig.paths.length === 0) return false
403
- const rel = relPosix(ig.root, dir)
453
+ // Asked once per project a run keys: `path.relative` was ~2 ms of
454
+ // 1,601 (2026-11-07). Below a normalized root, a path with no `.`
455
+ // segment and no doubled or trailing slash is its rest.
456
+ const rel =
457
+ ig.base !== undefined &&
458
+ dir.startsWith(ig.base) &&
459
+ !dir.includes('/.') &&
460
+ !dir.includes('//') &&
461
+ !dir.endsWith('/')
462
+ ? dir.slice(ig.base.length)
463
+ : relPosix(ig.root, dir)
404
464
  if (rel === '' || rel.startsWith('../')) return false
405
465
  let known = ig.asked.get(rel)
406
466
  if (known !== undefined) return known
@@ -413,10 +473,7 @@ export class GitFilesCache extends Map<string, readonly string[]> {
413
473
  else hi = mid
414
474
  }
415
475
  known = false
416
- if (ig.paths[lo]?.startsWith(under) === true) {
417
- const probe = `${under}${FRESH_NAME}`
418
- known = checkIgnoreSync(ig.root, [probe])?.has(probe) === true
419
- }
476
+ if (ig.paths[lo]?.startsWith(under) === true) known = newFilesIgnoredIn(ig.root, [rel]).size > 0
420
477
  ig.asked.set(rel, known)
421
478
  return known
422
479
  }
@@ -543,11 +600,10 @@ export class GitFilesCache extends Map<string, readonly string[]> {
543
600
  snapshot: readonly string[],
544
601
  digest: () => string | undefined,
545
602
  ): void {
546
- let memo: { value: string | undefined } | undefined
547
603
  this.digests.set(projectDir, {
548
604
  snapshot,
549
605
  oids: this.oids.get(projectDir),
550
- digest: () => (memo ??= { value: digest() }).value,
606
+ digest: once(digest),
551
607
  })
552
608
  }
553
609
 
@@ -565,6 +621,20 @@ export class GitFilesCache extends Map<string, readonly string[]> {
565
621
  this.oids.set(projectDir, new OidSlot(build))
566
622
  }
567
623
 
624
+ /**
625
+ * Each file of `snapshot` by its absolute path, filled as callers spell
626
+ * them: one string per file for the run, however many memo keys resolve
627
+ * it and whether the OID map names it too. Each spelled its own: a cold
628
+ * 9,603-task run held ~96,000 path strings for ~32,000 files.
629
+ */
630
+ absPaths(snapshot: readonly string[]): Array<string | undefined> {
631
+ let paths = this.abs.get(snapshot)
632
+ if (paths === undefined) this.abs.set(snapshot, (paths = []))
633
+ return paths
634
+ }
635
+
636
+ private readonly abs = new WeakMap<readonly string[], Array<string | undefined>>()
637
+
568
638
  /** Snapshot if still valid for these input globs; undefined → re-spawn. */
569
639
  snapshotFor(projectDir: string, inputGlobs: readonly Bun.Glob[]): readonly string[] | undefined {
570
640
  const snap = this.get(projectDir)
@@ -685,12 +755,66 @@ const FATAL_UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
685
755
 
686
756
  const LS_FILES_STAGE_RE = /^(?:([A-Za-z]) )?([0-7]{6}) ([0-9a-f]{40,64}) ([0-3])\t/
687
757
 
758
+ const UNOPENED = "warning: could not open directory '"
759
+
760
+ /**
761
+ * Refuses a walk git did not finish. `could not open directory` is a
762
+ * warning and the exit is 0, so the files under it were in no key: past
763
+ * PATH_MAX (4096 bytes on Linux) an edit there was a stale hit. A directory
764
+ * this user may not read is left out as before: no task of theirs reads it
765
+ * either, and a root-owned volume beside the projects must not stop every
766
+ * run. git cuts its message at 4096 bytes, so a long path comes with no
767
+ * reason. `cwd`: where git ran; it names the directory from its work tree's top.
768
+ */
769
+ function refuseUnopenedDirs(cwd: string, stderr: string): void {
770
+ if (!stderr.includes(UNOPENED)) return
771
+ const prefix = repoFacts(cwd)?.prefix ?? ''
772
+ const top = path.resolve(
773
+ cwd,
774
+ ...prefix
775
+ .split('/')
776
+ .filter((s) => s !== '')
777
+ .map(() => '..'),
778
+ )
779
+ for (const line of stderr.split('\n')) {
780
+ if (!line.startsWith(UNOPENED)) continue
781
+ const rest = line.slice(UNOPENED.length)
782
+ const end = rest.indexOf("': ")
783
+ const dir = end === -1 ? rest : rest.slice(0, end)
784
+ const at = path.resolve(top, dir)
785
+ // Gone since git read its parent (a run lock's staging directory in a
786
+ // TMPDIR inside the workspace): no file is under it to miss. Only a
787
+ // line git did not cut: a cut one may name a prefix of a real path.
788
+ if (end !== -1 && vanished(at)) continue
789
+ try {
790
+ accessSync(at, fsConstants.R_OK | fsConstants.X_OK)
791
+ } catch (err) {
792
+ if ((err as NodeJS.ErrnoException).code === 'EACCES') continue
793
+ }
794
+ const shown = dir.length > 100 ? `${dir.slice(0, 40)}…${dir.slice(-40)}` : dir
795
+ throw new UserError(
796
+ `git could not open the directory ${shown}, so no key would see the files under it. Shorten the path, or ignore it in .gitignore.`,
797
+ 'VX_E_GIT',
798
+ )
799
+ }
800
+ }
801
+
802
+ function vanished(p: string): boolean {
803
+ try {
804
+ lstatSync(p)
805
+ return false
806
+ } catch (err) {
807
+ const code = (err as NodeJS.ErrnoException).code
808
+ return code === 'ENOENT' || code === 'ENOTDIR'
809
+ }
810
+ }
811
+
688
812
  /**
689
813
  * Run `git ls-files -s --others --exclude-standard -z .` in `cwd`.
690
814
  * One spawn yields BOTH the file list (identical visibility to the
691
815
  * pre-v20 `--cached --others` form, verified empirically: same set
692
- * including staged-but-deleted files and per-stage conflict
693
- * duplicates) AND each tracked file's index OID — the heart of the
816
+ * including staged-but-deleted files; an unmerged path, listed there
817
+ * once per stage, is listed once) AND each tracked file's index OID — the heart of the
694
818
  * Turbo-parity "hashes come from git's index" fast path. Throws a
695
819
  * `UserError` when git is unavailable or `cwd` isn't a git work
696
820
  * tree; vx requires git. `-z` survives filenames with newlines /
@@ -700,7 +824,16 @@ export function runGitLsFiles(cwd: string, env = gitEnv()): GitLsResult {
700
824
  let proc
701
825
  try {
702
826
  proc = Bun.spawnSync({
703
- cmd: [executablePath('git'), 'ls-files', '-s', '--others', '--exclude-standard', '-z', '.'],
827
+ cmd: [
828
+ executablePath('git'),
829
+ ...gitCaseArgs(cwd),
830
+ 'ls-files',
831
+ '-s',
832
+ '--others',
833
+ '--exclude-standard',
834
+ '-z',
835
+ '.',
836
+ ],
704
837
  env,
705
838
  cwd,
706
839
  stdout: 'pipe',
@@ -714,6 +847,7 @@ export function runGitLsFiles(cwd: string, env = gitEnv()): GitLsResult {
714
847
  // cannot be enumerated, and the two have different fixes.
715
848
  throw gitFailure(cwd, 'ls-files', proc.exitCode, new TextDecoder().decode(proc.stderr).trim())
716
849
  }
850
+ refuseUnopenedDirs(cwd, new TextDecoder().decode(proc.stderr))
717
851
  const { text, undecodable } = decodeGitZ(proc.stdout)
718
852
  const parsed = parseLsFilesOutput(text.length === 0 ? [] : text.split('\0'), undecodable)
719
853
  const nested = expandNestedRepos(cwd, parsed.files, parsed.gitlinks)
@@ -915,6 +1049,14 @@ function parseLsFilesOutput(
915
1049
  const m = LS_FILES_STAGE_RE.exec(record)
916
1050
  const filePath = m === null ? record : record.slice(m[0].length) // --others: bare path
917
1051
  if (m === null && isInstalledPath(filePath)) continue
1052
+ // An unmerged path prints once per stage (1, 2, 3), in a row. Listed
1053
+ // each time, the key folded the file once per stage, so a conflict
1054
+ // resolved on disk keyed apart from the same bytes once added. It
1055
+ // carries no OID either way: its worktree bytes are what folds.
1056
+ if (m !== null && m[4] !== '0' && files.length > 0 && files[files.length - 1] === filePath) {
1057
+ if (m[2] === '160000') gitlinks.add(filePath)
1058
+ continue
1059
+ }
918
1060
  files.push(filePath)
919
1061
  if (undecodableRecords.size > 0 && undecodableRecords.has(record)) undecodable.push(filePath)
920
1062
  if (m === null) continue
@@ -1790,6 +1932,9 @@ export async function startGitEnumeration(
1790
1932
  pathspecs: readonly string[],
1791
1933
  ): Promise<GitEnumeration> {
1792
1934
  attributeReads.clear()
1935
+ // A dir is a path, never pathspec magic: `:top` listed `top`, `*g` matched
1936
+ // `:top/src/log`, and a project's edit left its key (a stale hit).
1937
+ const literal = pathspecs.map((p) => (p === '.' ? p : `:(literal)${p}`))
1793
1938
  // The spawns are independent — run them concurrently so the
1794
1939
  // bulk-populate costs max(ls-files, status) wall time, not the sum
1795
1940
  // (status alone is ~74 ms on a 1000-project tree; serial spawning
@@ -1911,7 +2056,7 @@ export async function startGitEnumeration(
1911
2056
  // tracked paths are dirty AND which files are untracked. Asking
1912
2057
  // `ls-files --others` for the untracked set walked the same tree a second
1913
2058
  // time (~50 ms of CPU, concurrent with status but contending with it).
1914
- const listing = spawnGit(['ls-files', '-s', '-v', '-z', '--', ...pathspecs])
2059
+ const listing = spawnGit(['ls-files', '-s', '-v', '-z', '--', ...literal])
1915
2060
  // Read synchronously: `status` spawns once the read is in, and an async
1916
2061
  // read's answer waited for the main thread to yield, behind the workspace
1917
2062
  // config and discovery (~5 ms of a 1,200-task warm run's wait for git).
@@ -1944,9 +2089,10 @@ export async function startGitEnumeration(
1944
2089
  // any other, and the filter gate below must see it (A-19).
1945
2090
  // `--no-renames`: a deletion paired as an unmerged path's rename source
1946
2091
  // prints only as `UU <path>`, and the deleted file stayed trusted (A-59).
1947
- const spawnStatus = (index: string | undefined) =>
2092
+ const spawnStatus = (index: string | undefined, args: readonly string[] = []) =>
1948
2093
  spawnGit(
1949
2094
  [
2095
+ ...args,
1950
2096
  'status',
1951
2097
  '--porcelain',
1952
2098
  '-z',
@@ -1954,7 +2100,7 @@ export async function startGitEnumeration(
1954
2100
  '--ignored=matching',
1955
2101
  '--no-renames',
1956
2102
  '--',
1957
- ...pathspecs,
2103
+ ...literal,
1958
2104
  ],
1959
2105
  undefined,
1960
2106
  index === undefined ? undefined : { GIT_INDEX_FILE: index },
@@ -1984,7 +2130,23 @@ export async function startGitEnumeration(
1984
2130
  attributesInIndex({ tracked: parsedLs.files, workspaceRoot, gitDir, gitPrefix, pathspecs })
1985
2131
  ? spawnGit(CHECK_ATTR, [...parsedLs.oids.keys()].join('\0'))
1986
2132
  : undefined
1987
- const [, status, vars] = await running
2133
+ const [, firstStatus, gitVars] = await running
2134
+ // `core.ignorecase` is known only once `var` answers, so a status run
2135
+ // under a setting the disk does not hold is run again without it: a
2136
+ // cost only that misconfigured repository pays.
2137
+ const caseBlind =
2138
+ facts !== null &&
2139
+ gitVars !== null &&
2140
+ gitVars.exitCode === 0 &&
2141
+ gitVars.stdout.includes('core.ignorecase') &&
2142
+ gitBool(gitVarList(gitVars.stdout).get('core.ignorecase')) === true &&
2143
+ ignoreCaseLies(workspaceRoot, path.resolve(workspaceRoot, gitDir, 'HEAD'))
2144
+ const status = caseBlind ? await spawnStatus(statusCopy, gitCaseArgs(workspaceRoot)) : firstStatus
2145
+ // The listing as git reads it with the override, which `trackedKey` reads too.
2146
+ const vars =
2147
+ caseBlind && gitVars !== null
2148
+ ? { ...gitVars, stdout: `${gitVars.stdout}core.ignorecase=false\n` }
2149
+ : gitVars
1988
2150
  const indexMoved = indexStamp() !== stampBefore
1989
2151
  // The key stands for the index `ls-files` listed only when that index
1990
2152
  // was written before the enumeration began (git writes it by rename, so
@@ -2019,6 +2181,7 @@ export async function startGitEnumeration(
2019
2181
  }
2020
2182
  if (parsedLs === undefined)
2021
2183
  throw gitFailure(workspaceRoot, 'ls-files', ls.exitCode, ls.stderr.trim())
2184
+ if (status !== null && status.exitCode === 0) refuseUnopenedDirs(workspaceRoot, status.stderr)
2022
2185
  const { files: tracked, oids, flagged, gitlinks, undecodable } = parsedLs
2023
2186
  // Normalize `status`'s repo-root-relative paths to workspace-relative (strip
2024
2187
  // the `--show-prefix`) so the dirty set is keyed identically to the trusted
@@ -2119,7 +2282,7 @@ export async function startGitEnumeration(
2119
2282
  return null
2120
2283
  }
2121
2284
  const run = await spawnGit(
2122
- ['ls-files', '-s', '-v', '-z', '--debug', '--', ...pathspecs],
2285
+ ['ls-files', '-s', '-v', '-z', '--debug', '--', ...literal],
2123
2286
  undefined,
2124
2287
  statusCopy === undefined ? undefined : { GIT_INDEX_FILE: statusCopy },
2125
2288
  )
@@ -2300,10 +2463,13 @@ export async function applyGitEnumeration(
2300
2463
  relPrefix === spelled ? abs : (rel: string) => path.join(projectDir, rel.slice(prefix.length))
2301
2464
  const projOids = (): Map<string, string> => {
2302
2465
  const oids = new Map<string, string>()
2466
+ // `resolveFiles` spells `matches[j]` the same, so both take one string.
2467
+ const paths = base !== undefined && projAbs === abs ? cache.absPaths(matches) : undefined
2303
2468
  for (let i = start; i < end; i++) {
2304
2469
  const rel = sorted[i]!
2305
2470
  const oid = trusted.get(rel)
2306
- if (oid !== undefined) oids.set(projAbs(rel), oid)
2471
+ if (oid === undefined) continue
2472
+ oids.set(paths === undefined ? projAbs(rel) : (paths[i - start] ??= abs(rel)), oid)
2307
2473
  }
2308
2474
  return oids
2309
2475
  }