@vzn/vx 0.0.21 → 0.0.23

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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +622 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
@@ -6,8 +6,65 @@
6
6
  // task declared. Nothing here reads a config or applies a boundary.
7
7
 
8
8
  import path from 'node:path'
9
- import { existsSync } from 'node:fs'
10
- import { UserError } from '../util/index.js'
9
+ import { existsSync, realpathSync } from 'node:fs'
10
+ import { UserError, executablePath, gitSpawnRefusal } from '../util/index.js'
11
+
12
+ /** Three facts of the repository a directory is in, from one `git rev-parse`. */
13
+ export interface RepoFacts {
14
+ /** `--show-prefix`: the directory's path below the worktree root, `''` at the root. */
15
+ prefix: string
16
+ /** `--git-common-dir` as git prints it, relative to the directory asked. */
17
+ commonDir: string
18
+ /** `--show-object-format`: the hash the index's blob OIDs are in. */
19
+ objectFormat: 'sha1' | 'sha256'
20
+ }
21
+
22
+ const repoFactsMemo = new Map<string, RepoFacts>()
23
+
24
+ /**
25
+ * The enumeration needs the prefix and the common dir, the file hasher the
26
+ * object format: one spawn answers all three, once per directory for the
27
+ * life of the process — none of them moves while vx runs. A failure (no
28
+ * git, not a repository) is not remembered; the callers degrade on null.
29
+ * Synchronous so a reader that cannot wait (`hashBytes`, in the config
30
+ * load) and the enumeration share one answer whichever asks first.
31
+ */
32
+ export function repoFacts(dir: string): RepoFacts | null {
33
+ const hit = repoFactsMemo.get(dir)
34
+ if (hit !== undefined) return hit
35
+ let proc
36
+ try {
37
+ proc = Bun.spawnSync({
38
+ cmd: [
39
+ executablePath('git'),
40
+ 'rev-parse',
41
+ '--show-prefix',
42
+ '--git-common-dir',
43
+ '--show-object-format',
44
+ ],
45
+ cwd: dir,
46
+ stdout: 'pipe',
47
+ stderr: 'ignore',
48
+ })
49
+ } catch {
50
+ return null
51
+ }
52
+ if (proc.exitCode !== 0) return null
53
+ // One line per flag, in order. A git that does not know
54
+ // `--show-object-format` echoes it back, as it does any unknown flag,
55
+ // which reads as sha1 — what the flag's own spawn answered there too.
56
+ const [prefix = '', commonDir = '', format = ''] = new TextDecoder()
57
+ .decode(proc.stdout)
58
+ .split('\n')
59
+ .map((l) => l.trim())
60
+ const facts: RepoFacts = {
61
+ prefix,
62
+ commonDir,
63
+ objectFormat: format === 'sha256' ? 'sha256' : 'sha1',
64
+ }
65
+ repoFactsMemo.set(dir, facts)
66
+ return facts
67
+ }
11
68
 
12
69
  export class GitFilesCache extends Map<string, readonly string[]> {
13
70
  private changed = new Map<string, string[]>()
@@ -40,6 +97,27 @@ export class GitFilesCache extends Map<string, readonly string[]> {
40
97
  * populate runs, or when the status spawn failed (non-repo).
41
98
  */
42
99
  private dirty: boolean | null = null
100
+ /**
101
+ * Absolute paths, as git's lossy decoding spells them, of enumerated
102
+ * files whose names are not UTF-8 (`decodeGitZ`). Such a name cannot
103
+ * be opened from a string, so `resolveFiles` refuses one it would fold.
104
+ */
105
+ private undecodable = new Set<string>()
106
+ /**
107
+ * When the enumeration that vouched for `oids` started (ms since the
108
+ * epoch): an index OID says what a file held when `git status` looked,
109
+ * so a file changed since may hold something else (item 743). Unset on
110
+ * a cache no enumeration filled; its OIDs are then re-checked by content.
111
+ */
112
+ enumeratedAtMs: number | undefined
113
+
114
+ get undecodableNames(): ReadonlySet<string> {
115
+ return this.undecodable
116
+ }
117
+
118
+ markUndecodable(absPaths: readonly string[]): void {
119
+ for (const p of absPaths) this.undecodable.add(p)
120
+ }
43
121
 
44
122
  setWorkspaceRoot(root: string): void {
45
123
  this.wsRoot = root
@@ -106,6 +184,11 @@ export class GitFilesCache extends Map<string, readonly string[]> {
106
184
  }
107
185
 
108
186
  private recordChanged(partitionDir: string, relPaths: readonly string[]): void {
187
+ // A partition with no snapshot has nothing for a mark to invalidate
188
+ // (`snapshotFor` answers undefined either way), so this guard bounds
189
+ // memory for a project the enumeration left without a partition; it
190
+ // is not a rule, and deleting it survives the whole core suite (item
191
+ // 648).
109
192
  if (!this.has(partitionDir)) return
110
193
  const cur = this.changed.get(partitionDir)
111
194
  if (cur) cur.push(...relPaths)
@@ -149,6 +232,12 @@ export class GitFilesCache extends Map<string, readonly string[]> {
149
232
  this.oids.delete(key)
150
233
  return super.delete(key)
151
234
  }
235
+
236
+ override clear(): void {
237
+ this.changed.clear()
238
+ this.oids.clear()
239
+ super.clear()
240
+ }
152
241
  }
153
242
 
154
243
  /** No glob metacharacter — the entry names one exact path. */
@@ -168,8 +257,48 @@ interface GitLsResult {
168
257
  oids: Map<string, string>
169
258
  /** Paths flagged skip-worktree / assume-unchanged (only when `-v` was passed). */
170
259
  flagged: Set<string>
260
+ /** cwd-relative paths whose names are not UTF-8, spelled lossily (`decodeGitZ`). */
261
+ undecodable: string[]
171
262
  }
172
263
 
264
+ /**
265
+ * git's `-z` output as text, plus the NUL-separated records that were not
266
+ * UTF-8. git prints a path's bytes as they are, and a lossy decode turns
267
+ * `x\xffy` into `x\ufffdy`, which names no file: the name reached the
268
+ * input set, failed the disk probe, and dropped out of the key silently,
269
+ * so every edit to it was a hit (turborepo#9345). Nothing in vx can open a
270
+ * path a string cannot spell, so such a record is kept (lossily, so a
271
+ * glob still matches it) and named, for `resolveFiles` to refuse.
272
+ *
273
+ * The common output holds no U+FFFD at all. Measured on a 1.3 MB,
274
+ * 15,000-record listing (min of 2,000, interleaved): the lossy decode plus
275
+ * the U+FFFD check cost 0.17 ms against 0.10 for the `Response.text()` it
276
+ * replaced, and the check alone 0.003; a fatal decode of the whole output
277
+ * ran 0.73 ms at the median against 0.26. Only output holding a U+FFFD,
278
+ * from an invalid byte or from a name that really holds one, is split at
279
+ * its NULs and each record decoded fatally, which tells the two apart.
280
+ */
281
+ function decodeGitZ(bytes: Uint8Array): { text: string; undecodable: Set<string> } {
282
+ const undecodable = new Set<string>()
283
+ const text = LOSSY_UTF8.decode(bytes)
284
+ if (!text.includes('\ufffd')) return { text, undecodable }
285
+ let start = 0
286
+ for (let i = 0; i <= bytes.length; i++) {
287
+ if (i < bytes.length && bytes[i] !== 0) continue
288
+ const record = bytes.subarray(start, i)
289
+ try {
290
+ FATAL_UTF8.decode(record)
291
+ } catch {
292
+ undecodable.add(LOSSY_UTF8.decode(record))
293
+ }
294
+ start = i + 1
295
+ }
296
+ return { text, undecodable }
297
+ }
298
+
299
+ const LOSSY_UTF8 = new TextDecoder()
300
+ const FATAL_UTF8 = new TextDecoder('utf-8', { fatal: true })
301
+
173
302
  const LS_FILES_STAGE_RE = /^(?:([A-Za-z]) )?([0-7]{6}) ([0-9a-f]{40,64}) ([0-3])\t/
174
303
 
175
304
  /**
@@ -187,15 +316,13 @@ export function runGitLsFiles(cwd: string): GitLsResult {
187
316
  let proc
188
317
  try {
189
318
  proc = Bun.spawnSync({
190
- cmd: ['git', 'ls-files', '-s', '--others', '--exclude-standard', '-z', '.'],
319
+ cmd: [executablePath('git'), 'ls-files', '-s', '--others', '--exclude-standard', '-z', '.'],
191
320
  cwd,
192
321
  stdout: 'pipe',
193
322
  stderr: 'pipe',
194
323
  })
195
324
  } catch {
196
- throw new UserError(
197
- `vx requires git: failed to spawn 'git' (working dir: ${cwd}). Install git and re-run.`,
198
- )
325
+ throw gitSpawnRefusal(cwd)
199
326
  }
200
327
  if (proc.exitCode !== 0) {
201
328
  // Exit 128 = not a git work tree; other non-zero = git failure.
@@ -206,7 +333,21 @@ export function runGitLsFiles(cwd: string): GitLsResult {
206
333
  `Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
207
334
  )
208
335
  }
209
- return parseLsFilesOutput(new TextDecoder().decode(proc.stdout))
336
+ const { text, undecodable } = decodeGitZ(proc.stdout)
337
+ return parseLsFilesOutput(text, undecodable)
338
+ }
339
+
340
+ /**
341
+ * A file's identity as the key folds it: its blob OID, prefixed by its git
342
+ * mode unless that is a plain file's (100644). A blob OID holds no mode, so
343
+ * a `chmod +x` or a symlink swapped for a file of its target's bytes kept
344
+ * the key while `git status` and `--affected` saw the change, and the task
345
+ * replayed the old output (item 887). The plain mode stays unprefixed, so
346
+ * only executables and symlinks moved key. `hashFile` spells it the same
347
+ * way from a stat.
348
+ */
349
+ export function fileIdentity(mode: string, oid: string): string {
350
+ return mode === '100644' ? oid : `${mode}:${oid}`
210
351
  }
211
352
 
212
353
  // Each `ls-files -s -v` record is `[<flag> ]<mode> <oid> <stage>\t<path>` —
@@ -214,25 +355,24 @@ export function runGitLsFiles(cwd: string): GitLsResult {
214
355
  // `h`, …) in front. `--others` paths print bare; with `-z`, core.quotePath
215
356
  // quoting is off, so a bare path containing a literal tab still cannot
216
357
  // match the fixed-form prefix. Both answers come from ONE spawn.
217
- function parseLsFilesOutput(out: string): GitLsResult {
358
+ function parseLsFilesOutput(out: string, undecodableRecords: ReadonlySet<string>): GitLsResult {
218
359
  const files: string[] = []
219
360
  const oids = new Map<string, string>()
220
361
  const flagged = new Set<string>()
221
- if (out.length === 0) return { files, oids, flagged }
362
+ const undecodable: string[] = []
363
+ if (out.length === 0) return { files, oids, flagged, undecodable }
222
364
  // NUL-separated; trailing NUL produces an empty segment we skip.
223
365
  for (const record of out.split('\0')) {
224
366
  if (record.length === 0) continue
225
367
  const m = LS_FILES_STAGE_RE.exec(record)
226
- if (m === null) {
227
- files.push(record) // --others entry: bare path
228
- continue
229
- }
230
- const filePath = record.slice(m[0].length)
368
+ const filePath = m === null ? record : record.slice(m[0].length) // --others: bare path
231
369
  files.push(filePath)
370
+ if (undecodableRecords.size > 0 && undecodableRecords.has(record)) undecodable.push(filePath)
371
+ if (m === null) continue
232
372
  const mode = m[2]!
233
373
  const stage = m[4]!
234
374
  if ((mode === '100644' || mode === '100755' || mode === '120000') && stage === '0') {
235
- oids.set(filePath, m[3]!)
375
+ oids.set(filePath, fileIdentity(mode, m[3]!))
236
376
  }
237
377
  // A LOWERCASE letter means skip-worktree or assume-unchanged (`S` is the
238
378
  // older spelling of skip-worktree): git has been told to stop looking at
@@ -240,7 +380,7 @@ function parseLsFilesOutput(out: string): GitLsResult {
240
380
  const flag = m[1]
241
381
  if (flag !== undefined && (flag === 'S' || (flag >= 'a' && flag <= 'z'))) flagged.add(filePath)
242
382
  }
243
- return { files, oids, flagged }
383
+ return { files, oids, flagged, undecodable }
244
384
  }
245
385
 
246
386
  /** One completed `git` invocation. */
@@ -248,10 +388,12 @@ interface GitRun {
248
388
  exitCode: number
249
389
  stdout: string
250
390
  stderr: string
391
+ /** `stdout`'s records that were not UTF-8 (`decodeGitZ`). */
392
+ undecodable: ReadonlySet<string>
251
393
  }
252
394
 
253
395
  /**
254
- * Parse `git check-attr -z text eol ident` output — a flat stream of
396
+ * Parse `git check-attr -z text eol ident filter working-tree-encoding` output — a flat stream of
255
397
  * `<path>\0<attr>\0<value>\0` triples — into the set of paths where a clean
256
398
  * filter can rewrite bytes. `unspecified` means no rule matched and
257
399
  * `unset` (`-text`) explicitly disables conversion; both leave the index blob
@@ -280,12 +422,14 @@ export function parseCheckAttrOutput(out: string): Set<string> {
280
422
  * 1. If `core.autocrlf` is true/input, conversion applies to every
281
423
  * auto-detected text file with no attribute needed — trust nothing.
282
424
  * 2. Else, if no attributes source exists anywhere (no in-tree
283
- * `.gitattributes`, no `$GIT_DIR/info/attributes`, no
284
- * `core.attributesFile`), no rule can name a filter — return untouched,
285
- * zero extra work. This is the default `git init` repo.
425
+ * `.gitattributes`, no `$GIT_DIR/info/attributes`, none of the files
426
+ * outside the tree git reads — `attributeFilesOutsideTree`), no rule
427
+ * can name a filter — return untouched, zero extra work. This is the
428
+ * default `git init` repo.
286
429
  * 3. Otherwise ask `git check-attr` (measured 21 ms; it resolves attributes
287
430
  * from the index WITHOUT reading worktree content) and drop only the
288
- * paths that actually carry `text`/`eol`/`ident`.
431
+ * paths that actually carry `text`/`eol`/`ident`/`filter`/
432
+ * `working-tree-encoding`.
289
433
  *
290
434
  * Never throws: a probe that fails leaves the map as-is, which is exactly the
291
435
  * behaviour before this gate existed.
@@ -293,75 +437,213 @@ export function parseCheckAttrOutput(out: string): Set<string> {
293
437
  async function dropFilteredOids(
294
438
  trusted: Map<string, string>,
295
439
  args: {
440
+ /** Every path the enumeration listed: tracked (dirty ones too) and untracked. */
441
+ listed: readonly string[]
296
442
  workspaceRoot: string
297
443
  gitDir: string
298
- coreConfig: string
444
+ gitPrefix: string
445
+ pathspecs: readonly string[]
446
+ gitVars: string
299
447
  spawnGit: (a: string[], stdin?: string) => Promise<GitRun | null>
300
448
  },
301
449
  ): Promise<void> {
302
450
  if (trusted.size === 0) return
303
- if (autocrlfConverts(args.coreConfig)) {
451
+ if (autocrlfConverts(args.gitVars)) {
304
452
  trusted.clear()
305
453
  return
306
454
  }
307
455
  // Cheap checks first, and nothing is materialized until one of them fires —
308
456
  // on a 15k-file repo with no attributes this whole function is a size check,
309
- // a substring scan of an empty string, one stat, and a key walk that exits
310
- // on the first `.gitattributes` it does not find.
457
+ // a line scan of `git var -l`, three stats, and a key walk that exits on the
458
+ // first `.gitattributes` it does not find.
311
459
  let attributesPossible =
312
- args.coreConfig.toLowerCase().includes('core.attributesfile') ||
460
+ attributeFilesOutsideTree(args.gitVars, process.env).some((f) =>
461
+ existsSync(path.resolve(args.workspaceRoot, f)),
462
+ ) ||
313
463
  (args.gitDir !== '' &&
314
464
  existsSync(path.resolve(args.workspaceRoot, args.gitDir, 'info', 'attributes')))
465
+ // Every listed path, not only the trusted ones: git applies a
466
+ // `.gitattributes` that is untracked or modified as well, and scanned in
467
+ // the trusted set alone such a file was never seen, so a CRLF file's LF
468
+ // index blob keyed it through an edit (item 977).
315
469
  if (!attributesPossible) {
316
- for (const rel of trusted.keys()) {
470
+ for (const rel of args.listed) {
317
471
  if (rel === '.gitattributes' || rel.endsWith('/.gitattributes')) {
318
472
  attributesPossible = true
319
473
  break
320
474
  }
321
475
  }
322
476
  }
477
+ // The scan above can only see what the enumeration LISTED, and a scoped
478
+ // run lists the project dirs alone (`gitPathspecs`). A `.gitattributes`
479
+ // ABOVE them — the workspace root's own, which is where a monorepo puts
480
+ // it — is therefore invisible, and the gate read "no attributes
481
+ // anywhere" and kept every filtered OID. Measured on the real CLI: the
482
+ // same workspace and the same CRLF→LF edit is a miss under `--all`
483
+ // (pathspec `.`, so the root file is listed) and a STALE HIT under
484
+ // `--filter app`. Git resolves attributes from every directory between
485
+ // the repo root and the file, so walk those directories — bounded by
486
+ // depth and project count, never by file count, which is what the
487
+ // gate's cost rule cares about.
488
+ if (!attributesPossible) attributesPossible = attributesAbove(args)
323
489
  if (!attributesPossible) return
324
490
 
491
+ // `filter` and `working-tree-encoding` rewrite bytes too: a clean driver
492
+ // (nbstripout, a `sed`) can map two worktree files to one blob, so the
493
+ // blob's OID keyed an edit git calls clean, and the run replayed the old
494
+ // output (item 978). Git LFS is a `filter` as well: its files are hashed
495
+ // from disk now, the one honest identity of what a task reads.
325
496
  const res = await args.spawnGit(
326
- ['check-attr', '--stdin', '-z', 'text', 'eol', 'ident'],
497
+ ['check-attr', '--stdin', '-z', 'text', 'eol', 'ident', 'filter', 'working-tree-encoding'],
327
498
  [...trusted.keys()].join('\0'),
328
499
  )
329
500
  if (res === null || res.exitCode !== 0) return
330
501
  for (const rel of parseCheckAttrOutput(res.stdout)) trusted.delete(rel)
331
502
  }
332
503
 
333
- /** `true` when git may rewrite bytes for EVERY auto-detected text file. */
334
- export function autocrlfConverts(coreConfig: string): boolean {
335
- for (const line of coreConfig.split('\n')) {
336
- const [name, value] = [line.slice(0, line.indexOf(' ')), line.slice(line.indexOf(' ') + 1)]
337
- if (name.toLowerCase() === 'core.autocrlf') {
338
- const v = value.trim().toLowerCase()
339
- return v === 'true' || v === 'input'
504
+ /**
505
+ * The repository root, derived from the workspace root and git's own
506
+ * `--show-prefix` (the repo→workspace path). Exact and free: the prefix
507
+ * came from the same spawn. Deriving it from the git DIRECTORY instead
508
+ * is wrong in a linked worktree, where `--git-dir` names
509
+ * `<main>/.git/worktrees/<name>` — not an ancestor of the worktree's
510
+ * files at all, so a walk stopping there never stops and runs to `/`.
511
+ */
512
+ export function repoRootOf(workspaceRoot: string, gitPrefix: string): string {
513
+ const depth = gitPrefix.split('/').filter((p) => p !== '' && p !== '.').length
514
+ return depth === 0 ? workspaceRoot : path.resolve(workspaceRoot, ...Array(depth).fill('..'))
515
+ }
516
+
517
+ /**
518
+ * Is there a `.gitattributes` at or above the scanned dirs, up to the repo
519
+ * root? Only the directories are stat'd — one per level per pathspec — so
520
+ * this stays a handful of syscalls on any repo size.
521
+ */
522
+ function attributesAbove(args: {
523
+ workspaceRoot: string
524
+ gitPrefix: string
525
+ pathspecs: readonly string[]
526
+ }): boolean {
527
+ const top = repoRootOf(args.workspaceRoot, args.gitPrefix)
528
+ const seen = new Set<string>()
529
+ for (const spec of args.pathspecs) {
530
+ let dir = path.resolve(args.workspaceRoot, spec)
531
+ for (;;) {
532
+ if (!seen.has(dir)) {
533
+ seen.add(dir)
534
+ if (existsSync(path.join(dir, '.gitattributes'))) return true
535
+ }
536
+ const parent = path.dirname(dir)
537
+ // Stop at the repo root, and never walk past the filesystem root on a
538
+ // workspace whose git dir could not be read.
539
+ if (dir === top || parent === dir) break
540
+ dir = parent
340
541
  }
341
542
  }
342
543
  return false
343
544
  }
344
545
 
345
- function parseStatusOutput(out: string): { dirty: Set<string>; untracked: string[] } {
546
+ /**
547
+ * `git var -l` as `name → value`, the last line for a name winning, as the
548
+ * last value of a config key does in git. Config keys come first and
549
+ * lower-cased; git's own variables (`GIT_ATTR_GLOBAL`, …) follow.
550
+ */
551
+ function gitVarList(listing: string): Map<string, string> {
552
+ const vars = new Map<string, string>()
553
+ for (const line of listing.split('\n')) {
554
+ const eq = line.indexOf('=')
555
+ if (eq > 0) vars.set(line.slice(0, eq), line.slice(eq + 1))
556
+ }
557
+ return vars
558
+ }
559
+
560
+ /** `true` when git may rewrite bytes for EVERY auto-detected text file. */
561
+ export function autocrlfConverts(gitVars: string): boolean {
562
+ const v = gitVarList(gitVars).get('core.autocrlf')?.trim().toLowerCase()
563
+ return v === 'true' || v === 'input'
564
+ }
565
+
566
+ /**
567
+ * The attributes files outside the work tree git reads, from the same
568
+ * `git var -l` the gate already spawns. Git 2.42 and later name both:
569
+ * `GIT_ATTR_GLOBAL` is `core.attributesFile` or, unset, the default
570
+ * `$XDG_CONFIG_HOME/git/attributes` (`~/.config/git/attributes` without
571
+ * it), and `GIT_ATTR_SYSTEM` is the build's `$(sysconfdir)/gitattributes`,
572
+ * absent under `GIT_ATTR_NOSYSTEM`. The gate read only the config key, so
573
+ * `* text` in the default file left a CRLF file's LF blob trusted, a
574
+ * stale hit (the upstream survey's row X).
575
+ *
576
+ * An older git names neither; its global lookup is the documented one,
577
+ * mirrored here, and its system file sits where its build put it, which
578
+ * only git knows: `/etc/gitattributes` for a `/usr` build, else
579
+ * `<prefix>/etc/gitattributes` beside the binary's `bin/`. A path may be
580
+ * relative (a relative `core.attributesFile`), to the workspace root.
581
+ */
582
+ export function attributeFilesOutsideTree(
583
+ gitVars: string,
584
+ env: Readonly<Record<string, string | undefined>>,
585
+ ): string[] {
586
+ const vars = gitVarList(gitVars)
587
+ const global = vars.get('GIT_ATTR_GLOBAL')
588
+ const system = vars.get('GIT_ATTR_SYSTEM')
589
+ if (global !== undefined || system !== undefined || vars.has('GIT_CONFIG_GLOBAL')) {
590
+ return [global, system].filter((f): f is string => f !== undefined && f !== '')
591
+ }
592
+ const out: string[] = []
593
+ const configured = vars.get('core.attributesfile')
594
+ const home = env.HOME ?? ''
595
+ if (configured !== undefined && configured !== '') {
596
+ out.push(configured.startsWith('~/') ? path.join(home, configured.slice(2)) : configured)
597
+ } else if ((env.XDG_CONFIG_HOME ?? '') !== '') {
598
+ out.push(path.join(env.XDG_CONFIG_HOME!, 'git', 'attributes'))
599
+ } else if (home !== '') {
600
+ out.push(path.join(home, '.config', 'git', 'attributes'))
601
+ }
602
+ const noSystem = (env.GIT_ATTR_NOSYSTEM ?? '').toLowerCase()
603
+ if (!['1', 'true', 'yes', 'on'].includes(noSystem)) {
604
+ out.push('/etc/gitattributes')
605
+ try {
606
+ const prefix = path.dirname(path.dirname(realpathSync(executablePath('git'))))
607
+ out.push(path.join(prefix, 'etc', 'gitattributes'))
608
+ } catch {
609
+ // No git to resolve: the enumeration has already refused the run.
610
+ }
611
+ }
612
+ return out
613
+ }
614
+
615
+ function parseStatusOutput(
616
+ out: string,
617
+ undecodableRecords: ReadonlySet<string>,
618
+ ): { dirty: Set<string>; untracked: string[]; undecodable: Set<string> } {
346
619
  const tokens = out.split('\0')
347
620
  const dirty = new Set<string>()
348
621
  const untracked: string[] = []
622
+ const undecodable = new Set<string>()
349
623
  for (let i = 0; i < tokens.length; i++) {
350
624
  const token = tokens[i]!
351
625
  if (token.length < 4) continue
626
+ if (undecodableRecords.size > 0 && undecodableRecords.has(token))
627
+ undecodable.add(token.slice(3))
352
628
  if (token[0] === '?') {
353
629
  untracked.push(token.slice(3))
354
630
  continue
355
631
  }
356
632
  dirty.add(token.slice(3))
357
- // X = R or C → the next token is the rename/copy source path.
633
+ // An R or C in either column → the next token is the rename/copy
634
+ // source path. Y carries it for a rename git sees in the worktree (the
635
+ // copy added with `git add -N`, the original removed): read on X alone,
636
+ // the source was parsed as a record of its own, stayed trusted, and a
637
+ // deleted file was keyed from the index (item 976).
358
638
  const x = token[0]
359
- if ((x === 'R' || x === 'C') && i + 1 < tokens.length && tokens[i + 1]!.length > 0) {
639
+ const y = token[1]
640
+ const renamed = x === 'R' || x === 'C' || y === 'R' || y === 'C'
641
+ if (renamed && i + 1 < tokens.length && tokens[i + 1]!.length > 0) {
360
642
  i++
361
643
  dirty.add(tokens[i]!)
362
644
  }
363
645
  }
364
- return { dirty, untracked }
646
+ return { dirty, untracked, undecodable }
365
647
  }
366
648
 
367
649
  /**
@@ -427,6 +709,10 @@ export interface GitEnumeration {
427
709
  trusted: Map<string, string>
428
710
  /** Whether the worktree had uncommitted changes; null when `git status` failed. */
429
711
  dirty: boolean | null
712
+ /** Workspace-relative paths whose names are not UTF-8 (`decodeGitZ`). */
713
+ undecodable: readonly string[]
714
+ /** `Date.now()` before the spawns: what `trusted` says is true as of no earlier. */
715
+ startedAtMs: number
430
716
  }
431
717
 
432
718
  /**
@@ -467,49 +753,66 @@ export async function startGitEnumeration(
467
753
  const spawnGit = async (args: string[], stdin?: string): Promise<GitRun | null> => {
468
754
  try {
469
755
  const proc = Bun.spawn({
470
- cmd: ['git', ...args],
756
+ // `status` refreshes the index when it can take `index.lock`, so
757
+ // the user's own `git add` or `commit` failed on the lock while a
758
+ // vx run held it (12 in 1,165 across 80 runs, item 880). Read-only, as
759
+ // editors and prompts run it; a clean tree costs the same.
760
+ cmd: [executablePath('git'), '--no-optional-locks', ...args],
471
761
  cwd: workspaceRoot,
472
762
  stdin: stdin === undefined ? 'ignore' : new TextEncoder().encode(stdin),
473
763
  stdout: 'pipe',
474
764
  stderr: 'pipe',
475
765
  })
476
766
  const [stdout, stderr, exitCode] = await Promise.all([
477
- new Response(proc.stdout).text(),
767
+ new Response(proc.stdout).bytes(),
478
768
  new Response(proc.stderr).text(),
479
769
  proc.exited,
480
770
  ])
481
- return { exitCode, stdout, stderr }
771
+ const { text, undecodable } = decodeGitZ(stdout)
772
+ return { exitCode, stdout: text, stderr, undecodable }
482
773
  } catch {
483
774
  return null
484
775
  }
485
776
  }
486
- // Four spawns, one worktree walk. `ls-files -s -v` reads the INDEX only
777
+ const startedAtMs = Date.now()
778
+ // Four spawns at most (the rev-parse is memoized per process), one
779
+ // worktree walk. `ls-files -s -v` reads the INDEX only
487
780
  // (~9 ms on a 1000-project tree) and answers two questions at once: every
488
781
  // tracked path's OID and its cache-state flag. `status -uall` is the one
489
782
  // command that walks the worktree, and it answers two as well: which
490
783
  // tracked paths are dirty AND which files are untracked. Asking
491
784
  // `ls-files --others` for the untracked set walked the same tree a second
492
785
  // time (~50 ms of CPU, concurrent with status but contending with it).
493
- const [ls, status, prefixRes, coreCfg] = await Promise.all([
786
+ const running = Promise.all([
494
787
  spawnGit(['ls-files', '-s', '-v', '-z', '--', ...pathspecs]),
495
788
  spawnGit(['status', '--porcelain', '-z', '-uall', '--', ...pathspecs]),
496
- // Two answers from one spawn. `--show-prefix` is the repo→workspace path
497
- // (empty when the workspace root IS the git root): `ls-files` prints
498
- // cwd(workspace)-relative paths but `status` prints repo-root-relative
499
- // ones, so when the workspace root is a SUBDIR of the git repo the two
500
- // disagree; this lets us key both the same way below. `--git-dir` locates
501
- // `info/attributes` for the filter gate (it is not always `.git/` — a
502
- // linked worktree's `.git` is a FILE pointing elsewhere).
503
- spawnGit(['rev-parse', '--show-prefix', '--git-dir']),
504
- // Reads three config keys, no tree scan — the gate for whether a clean
505
- // filter can rewrite bytes between the index and the worktree. Exits 1
506
- // when none are set, which is the common case and means "no gate".
507
- spawnGit(['config', '--get-regexp', '^core\\.(autocrlf|eol|attributesfile)$']),
789
+ // The gate for whether a clean filter can rewrite bytes between the
790
+ // index and the worktree: git's merged config (`core.autocrlf`) and,
791
+ // from git 2.42, the attributes files it reads outside the tree
792
+ // (`attributeFilesOutsideTree`). No tree scan. It replaced a
793
+ // `config --get-regexp` of three keys at the same cost (1.29 against
794
+ // 1.32 ms, min of 30), which could not name the default global file.
795
+ // Not foldable into the rev-parse below: rev-parse prints no config.
796
+ spawnGit(['var', '-l']),
508
797
  ])
798
+ // Asked while the three above run, from the memo the file hasher reads
799
+ // too (`repoFacts`). `prefix` is the repo→workspace path (empty when the
800
+ // workspace root IS the git root): `ls-files` prints cwd(workspace)-
801
+ // relative paths but `status` prints repo-root-relative ones, so when the
802
+ // workspace root is a SUBDIR of the git repo the two disagree; this lets
803
+ // us key both the same way below. `commonDir` locates `info/attributes`
804
+ // for the filter gate: it is not always `.git/` (a linked worktree's
805
+ // `.git` is a FILE pointing elsewhere), and it is the COMMON dir rather
806
+ // than the per-worktree one because that is where git reads the file
807
+ // from — probed, 2026-09-20: from inside a worktree `git check-attr text`
808
+ // goes `unspecified` → `auto` when the rule is written to the common
809
+ // dir's `info/attributes`, while the per-worktree gitdir has no such file
810
+ // at all. `--git-dir` named the per-worktree directory, so the gate
811
+ // looked where the rule can never be.
812
+ const facts = repoFacts(workspaceRoot)
813
+ const [ls, status, vars] = await running
509
814
  if (ls === null) {
510
- throw new UserError(
511
- `vx requires git: failed to spawn 'git' (working dir: ${workspaceRoot}). Install git and re-run.`,
512
- )
815
+ throw gitSpawnRefusal(workspaceRoot)
513
816
  }
514
817
  if (ls.exitCode !== 0) {
515
818
  const stderr = ls.stderr.trim()
@@ -518,7 +821,12 @@ export async function startGitEnumeration(
518
821
  `Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
519
822
  )
520
823
  }
521
- const { files: tracked, oids, flagged } = parseLsFilesOutput(ls.stdout)
824
+ const {
825
+ files: tracked,
826
+ oids,
827
+ flagged,
828
+ undecodable,
829
+ } = parseLsFilesOutput(ls.stdout, ls.undecodable)
522
830
  // Normalize `status`'s repo-root-relative paths to workspace-relative (strip
523
831
  // the `--show-prefix`) so the dirty set is keyed identically to the trusted
524
832
  // OID map. Without this, when the workspace root is a git subdir, a modified
@@ -526,13 +834,12 @@ export async function startGitEnumeration(
526
834
  // a STALE cache hit serving old outputs. Empty prefix (workspace == git root,
527
835
  // the common case) is a zero-cost no-op. Paths above the workspace can't be
528
836
  // inputs, so they drop out of the set.
529
- // One spawn, two lines: `--show-prefix` then `--git-dir`.
530
- const revLines =
531
- prefixRes !== null && prefixRes.exitCode === 0 ? prefixRes.stdout.split('\n') : []
532
- const gitPrefix = (revLines[0] ?? '').trim()
533
- const gitDir = (revLines[1] ?? '').trim()
837
+ const gitPrefix = facts?.prefix ?? ''
838
+ const gitDir = facts?.commonDir ?? ''
534
839
  const parsedStatus =
535
- status !== null && status.exitCode === 0 ? parseStatusOutput(status.stdout) : null
840
+ status !== null && status.exitCode === 0
841
+ ? parseStatusOutput(status.stdout, status.undecodable)
842
+ : null
536
843
  const dirty = parsedStatus === null ? null : stripPrefixFromSet(parsedStatus.dirty, gitPrefix)
537
844
  // Untracked files are inputs too (a new file is the commonest edit there
538
845
  // is). They come from the status walk, repo-root-relative like the dirty
@@ -557,7 +864,7 @@ export async function startGitEnumeration(
557
864
  // fall out of the input set while unmaterialized.
558
865
  for (const rel of flagged) trusted.delete(rel)
559
866
  // An index OID is only the file's content hash when git stores the worktree
560
- // bytes VERBATIM. Under a clean filter (`text`/`eol`/`ident`) the blob is a
867
+ // bytes VERBATIM. Under a clean filter (`text`/`eol`/`ident`/`filter`/`working-tree-encoding`) the blob is a
561
868
  // DIFFERENT sequence of bytes — the LF-normalized form — while the task
562
869
  // reads the CRLF worktree file. `git status` compares AFTER filtering, so
563
870
  // such a file reports clean and keeps its OID: the CRLF and LF states then
@@ -568,12 +875,18 @@ export async function startGitEnumeration(
568
875
  // all along. The only cost is the read, so the gate below is about paying it
569
876
  // ONLY where a filter can actually apply.
570
877
  await dropFilteredOids(trusted, {
878
+ listed: all,
571
879
  workspaceRoot,
572
880
  gitDir,
573
- coreConfig: coreCfg !== null && coreCfg.exitCode === 0 ? coreCfg.stdout : '',
881
+ gitPrefix,
882
+ pathspecs,
883
+ gitVars: vars !== null && vars.exitCode === 0 ? vars.stdout : '',
574
884
  spawnGit,
575
885
  })
576
- return { all, trusted, dirty: worktreeDirty }
886
+ if (parsedStatus !== null && parsedStatus.undecodable.size > 0) {
887
+ undecodable.push(...stripPrefixFromSet(parsedStatus.undecodable, gitPrefix))
888
+ }
889
+ return { all, trusted, dirty: worktreeDirty, undecodable, startedAtMs }
577
890
  }
578
891
 
579
892
  /** The partition half of `populateGitFilesCache`: store per-project slices of one enumeration. */
@@ -586,6 +899,8 @@ export function applyGitEnumeration(
586
899
  ): void {
587
900
  const { all, trusted } = enumeration
588
901
  cache.setWorktreeDirty(enumeration.dirty)
902
+ cache.enumeratedAtMs = enumeration.startedAtMs
903
+ cache.markUndecodable(enumeration.undecodable.map((rel) => path.join(workspaceRoot, rel)))
589
904
  // Sort once, then each project's files are a contiguous range found
590
905
  // by binary search on its `dir/` prefix — O((F+P) log F) instead of
591
906
  // the O(P·F) per-project startsWith scan (54 ms at 1090 projects ×
@@ -623,6 +938,20 @@ export function applyGitEnumeration(
623
938
  const oid = trusted.get(rel)
624
939
  if (oid !== undefined) projOids.set(path.join(workspaceRoot, rel), oid)
625
940
  }
941
+ // An empty slice is a directory git did not see, not an empty project:
942
+ // a project has at least its package.json, tracked or untracked. What
943
+ // hides a directory from this listing is a nested repository — a
944
+ // submodule, an embedded repository — which the workspace's git holds as
945
+ // ONE entry (a gitlink, `dir/` when untracked) and, under a pathspec
946
+ // naming the project, as nothing at all; or a directory ignored
947
+ // outright. Storing the empty slice made the key never move: `cache.inputs
948
+ // matched no files`, then a stale hit under a green run once the source
949
+ // changed (2026-09-16). No partition instead: `resolveFiles` spawns
950
+ // `git ls-files` in the project's own directory, which a nested
951
+ // repository answers, and the files hash by content (no index OID
952
+ // trusted from here) — one spawn per such project per run, nothing for
953
+ // a workspace without one.
954
+ if (matches.length === 0) continue
626
955
  // setOids AFTER set — set() drops the project's OID slot.
627
956
  cache.set(projectDir, matches)
628
957
  cache.setOids(projectDir, projOids)