@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -0,0 +1,966 @@
1
+ // Git-backed input enumeration: one `git ls-files -s` + one `git status
2
+ // --porcelain -z` per run (plus `check-attr` where a filter could convert
3
+ // bytes), parsed into the per-project `GitFilesCache` that `inputs.ts`'s
4
+ // glob resolution trusts for blob OIDs. Split from `inputs.ts` on
5
+ // 2026-09-10: this file talks to git; `inputs.ts` decides which files a
6
+ // task declared. Nothing here reads a config or applies a boundary.
7
+
8
+ import path from 'node:path'
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
+ }
68
+
69
+ export class GitFilesCache extends Map<string, readonly string[]> {
70
+ private changed = new Map<string, string[]>()
71
+ /**
72
+ * Per-project trusted index OIDs: absolute path → git blob OID, for
73
+ * tracked regular files whose working-tree state matched the index
74
+ * at populate time (per one `git status --porcelain` snapshot).
75
+ * These feed `Cache.key` via `CacheKeyInput.fileHashes` so a clean
76
+ * tree derives input hashes with zero reads / stats / SQLite.
77
+ *
78
+ * Dropped wholesale on `set` / `delete`: a mid-run re-enumeration
79
+ * proves the project's tree changed, and index OIDs can't be
80
+ * re-trusted without a fresh status — the per-file fallback
81
+ * (`Cache.hashFile`) computes the identical blob OID from disk, so
82
+ * dropping is a pure perf concession, never a correctness one.
83
+ */
84
+ private oids = new Map<string, Map<string, string>>()
85
+ /**
86
+ * Set when `populateGitFilesCache` stored a WORKSPACE-WIDE partition
87
+ * (any loaded task declares `inputs.workspaceFiles`). Null when the
88
+ * feature is unused — every workspace-partition hook below is then a
89
+ * no-op, so unused-feature behavior stays byte-identical.
90
+ */
91
+ private wsRoot: string | null = null
92
+ /**
93
+ * Whether the worktree had any uncommitted changes at populate time,
94
+ * derived from the SAME `git status --porcelain` spawn that prunes
95
+ * dirty paths from the trusted-OID set. Lets the Tier-3 invocation
96
+ * record report `dirty` without a SECOND status spawn. `null` until
97
+ * populate runs, or when the status spawn failed (non-repo).
98
+ */
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
+ }
121
+
122
+ setWorkspaceRoot(root: string): void {
123
+ this.wsRoot = root
124
+ }
125
+
126
+ /** Aggregate worktree dirtiness from the populate-time status spawn. */
127
+ get worktreeDirty(): boolean | null {
128
+ return this.dirty
129
+ }
130
+
131
+ setWorktreeDirty(dirty: boolean | null): void {
132
+ this.dirty = dirty
133
+ }
134
+
135
+ markOutputsChanged(projectDir: string, relPaths: readonly string[]): void {
136
+ this.recordChanged(projectDir, relPaths)
137
+ // The workspace-wide partition sees the same files under
138
+ // root-relative names; forward so a downstream workspaceFiles
139
+ // task can't reuse a snapshot its globs could now contradict.
140
+ if (this.wsRoot !== null && this.wsRoot !== projectDir) {
141
+ this.recordChanged(
142
+ this.wsRoot,
143
+ relPaths.map((rel) =>
144
+ path.relative(this.wsRoot!, path.resolve(projectDir, rel)).split(path.sep).join('/'),
145
+ ),
146
+ )
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Record root-anchored changed paths (cleaned/restored workspace
152
+ * outputs) against EVERY partition that can see them: the workspace
153
+ * partition under their root-relative names, and any project
154
+ * partition whose dir contains them (workspace outputs may land
155
+ * inside other projects' dirs — the no-boundary escape hatch).
156
+ */
157
+ markWorkspaceOutputsChanged(workspaceRoot: string, relPaths: readonly string[]): void {
158
+ if (relPaths.length === 0) return
159
+ for (const key of this.keys()) {
160
+ if (key === workspaceRoot) {
161
+ this.recordChanged(key, relPaths)
162
+ continue
163
+ }
164
+ const under: string[] = []
165
+ for (const rel of relPaths) {
166
+ const abs = path.resolve(workspaceRoot, rel)
167
+ if (abs.startsWith(key + path.sep)) {
168
+ under.push(path.relative(key, abs).split(path.sep).join('/'))
169
+ }
170
+ }
171
+ if (under.length > 0) this.recordChanged(key, under)
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Drop the workspace-wide partition (if one exists). Called after a
177
+ * cache-miss save — an executed task may have written undeclared
178
+ * files anywhere in its project dir, and the workspace partition
179
+ * spans that subtree; only a fresh enumeration can see them. No-op
180
+ * when the feature is unused.
181
+ */
182
+ invalidateWorkspacePartition(): void {
183
+ if (this.wsRoot !== null) this.delete(this.wsRoot)
184
+ }
185
+
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).
192
+ if (!this.has(partitionDir)) return
193
+ const cur = this.changed.get(partitionDir)
194
+ if (cur) cur.push(...relPaths)
195
+ else this.changed.set(partitionDir, [...relPaths])
196
+ // These paths just changed on disk; their index OIDs (if any) no
197
+ // longer describe the working-tree content.
198
+ const partitionOids = this.oids.get(partitionDir)
199
+ if (partitionOids) {
200
+ for (const rel of relPaths) partitionOids.delete(path.resolve(partitionDir, rel))
201
+ }
202
+ }
203
+
204
+ /** Trusted index OIDs for a project (abs path → oid), if any survive. */
205
+ oidsFor(projectDir: string): ReadonlyMap<string, string> | undefined {
206
+ return this.oids.get(projectDir)
207
+ }
208
+
209
+ setOids(projectDir: string, oids: Map<string, string>): void {
210
+ this.oids.set(projectDir, oids)
211
+ }
212
+
213
+ /** Snapshot if still valid for these input globs; undefined → re-spawn. */
214
+ snapshotFor(projectDir: string, inputGlobs: readonly Bun.Glob[]): readonly string[] | undefined {
215
+ const snap = this.get(projectDir)
216
+ if (snap === undefined) return undefined
217
+ const pending = this.changed.get(projectDir)
218
+ if (pending !== undefined && pending.some((p) => inputGlobs.some((g) => g.match(p)))) {
219
+ return undefined
220
+ }
221
+ return snap
222
+ }
223
+
224
+ override set(key: string, value: readonly string[]): this {
225
+ this.changed.delete(key)
226
+ this.oids.delete(key)
227
+ return super.set(key, value)
228
+ }
229
+
230
+ override delete(key: string): boolean {
231
+ this.changed.delete(key)
232
+ this.oids.delete(key)
233
+ return super.delete(key)
234
+ }
235
+
236
+ override clear(): void {
237
+ this.changed.clear()
238
+ this.oids.clear()
239
+ super.clear()
240
+ }
241
+ }
242
+
243
+ /** No glob metacharacter — the entry names one exact path. */
244
+
245
+ interface GitLsResult {
246
+ /** cwd-relative paths — same visibility set as `--cached --others`. */
247
+ files: string[]
248
+ /**
249
+ * cwd-relative path → index blob OID, for tracked regular files
250
+ * (mode 100644 / 100755) and symlinks (120000) at stage 0 only. A
251
+ * symlink's OID is the blob of its target string, which is exactly
252
+ * what `Cache.hashFile` computes for one, so the two paths agree.
253
+ * Merge-conflict stages and gitlinks are excluded. NOT yet filtered
254
+ * by working-tree dirtiness — callers intersect with `git status`
255
+ * before trusting.
256
+ */
257
+ oids: Map<string, string>
258
+ /** Paths flagged skip-worktree / assume-unchanged (only when `-v` was passed). */
259
+ flagged: Set<string>
260
+ /** cwd-relative paths whose names are not UTF-8, spelled lossily (`decodeGitZ`). */
261
+ undecodable: string[]
262
+ }
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
+
302
+ const LS_FILES_STAGE_RE = /^(?:([A-Za-z]) )?([0-7]{6}) ([0-9a-f]{40,64}) ([0-3])\t/
303
+
304
+ /**
305
+ * Run `git ls-files -s --others --exclude-standard -z .` in `cwd`.
306
+ * One spawn yields BOTH the file list (identical visibility to the
307
+ * pre-v20 `--cached --others` form, verified empirically: same set
308
+ * including staged-but-deleted files and per-stage conflict
309
+ * duplicates) AND each tracked file's index OID — the heart of the
310
+ * Turbo-parity "hashes come from git's index" fast path. Throws a
311
+ * `UserError` when git is unavailable or `cwd` isn't a git work
312
+ * tree; vx requires git. `-z` survives filenames with newlines /
313
+ * spaces.
314
+ */
315
+ export function runGitLsFiles(cwd: string): GitLsResult {
316
+ let proc
317
+ try {
318
+ proc = Bun.spawnSync({
319
+ cmd: [executablePath('git'), 'ls-files', '-s', '--others', '--exclude-standard', '-z', '.'],
320
+ cwd,
321
+ stdout: 'pipe',
322
+ stderr: 'pipe',
323
+ })
324
+ } catch {
325
+ throw gitSpawnRefusal(cwd)
326
+ }
327
+ if (proc.exitCode !== 0) {
328
+ // Exit 128 = not a git work tree; other non-zero = git failure.
329
+ // Either way we can't enumerate inputs reliably.
330
+ const stderr = new TextDecoder().decode(proc.stderr).trim()
331
+ throw new UserError(
332
+ `vx requires git: ${cwd} is not inside a git work tree. ` +
333
+ `Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
334
+ )
335
+ }
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}`
351
+ }
352
+
353
+ // Each `ls-files -s -v` record is `[<flag> ]<mode> <oid> <stage>\t<path>` —
354
+ // the staged-entry form, with an optional cache-state letter (`H`, `S`,
355
+ // `h`, …) in front. `--others` paths print bare; with `-z`, core.quotePath
356
+ // quoting is off, so a bare path containing a literal tab still cannot
357
+ // match the fixed-form prefix. Both answers come from ONE spawn.
358
+ function parseLsFilesOutput(out: string, undecodableRecords: ReadonlySet<string>): GitLsResult {
359
+ const files: string[] = []
360
+ const oids = new Map<string, string>()
361
+ const flagged = new Set<string>()
362
+ const undecodable: string[] = []
363
+ if (out.length === 0) return { files, oids, flagged, undecodable }
364
+ // NUL-separated; trailing NUL produces an empty segment we skip.
365
+ for (const record of out.split('\0')) {
366
+ if (record.length === 0) continue
367
+ const m = LS_FILES_STAGE_RE.exec(record)
368
+ const filePath = m === null ? record : record.slice(m[0].length) // --others: bare path
369
+ files.push(filePath)
370
+ if (undecodableRecords.size > 0 && undecodableRecords.has(record)) undecodable.push(filePath)
371
+ if (m === null) continue
372
+ const mode = m[2]!
373
+ const stage = m[4]!
374
+ if ((mode === '100644' || mode === '100755' || mode === '120000') && stage === '0') {
375
+ oids.set(filePath, fileIdentity(mode, m[3]!))
376
+ }
377
+ // A LOWERCASE letter means skip-worktree or assume-unchanged (`S` is the
378
+ // older spelling of skip-worktree): git has been told to stop looking at
379
+ // the worktree file, so its index OID says nothing about the disk.
380
+ const flag = m[1]
381
+ if (flag !== undefined && (flag === 'S' || (flag >= 'a' && flag <= 'z'))) flagged.add(filePath)
382
+ }
383
+ return { files, oids, flagged, undecodable }
384
+ }
385
+
386
+ /** One completed `git` invocation. */
387
+ interface GitRun {
388
+ exitCode: number
389
+ stdout: string
390
+ stderr: string
391
+ /** `stdout`'s records that were not UTF-8 (`decodeGitZ`). */
392
+ undecodable: ReadonlySet<string>
393
+ }
394
+
395
+ /**
396
+ * Parse `git check-attr -z text eol ident filter working-tree-encoding` output — a flat stream of
397
+ * `<path>\0<attr>\0<value>\0` triples — into the set of paths where a clean
398
+ * filter can rewrite bytes. `unspecified` means no rule matched and
399
+ * `unset` (`-text`) explicitly disables conversion; both leave the index blob
400
+ * byte-identical to the worktree file, so those OIDs stay trusted.
401
+ */
402
+ export function parseCheckAttrOutput(out: string): Set<string> {
403
+ const affected = new Set<string>()
404
+ const fields = out.split('\0')
405
+ for (let i = 0; i + 2 < fields.length; i += 3) {
406
+ const value = fields[i + 2]!
407
+ if (value !== 'unspecified' && value !== 'unset') affected.add(fields[i]!)
408
+ }
409
+ return affected
410
+ }
411
+
412
+ /**
413
+ * Remove from `trusted` every path whose index blob may differ from its
414
+ * worktree bytes because a clean filter applies.
415
+ *
416
+ * Gated in two steps so the common case pays NOTHING. The precise answer —
417
+ * `git ls-files --eol`, comparing `i/` to `w/` — was measured at 240 ms on a
418
+ * 15k-file tree because it must READ every worktree file: 13x the entire
419
+ * enumeration, on a run whose warm total is ~130 ms, and in the common Linux
420
+ * case it finds nothing. So instead:
421
+ *
422
+ * 1. If `core.autocrlf` is true/input, conversion applies to every
423
+ * auto-detected text file with no attribute needed — trust nothing.
424
+ * 2. Else, if no attributes source exists anywhere (no in-tree
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.
429
+ * 3. Otherwise ask `git check-attr` (measured 21 ms; it resolves attributes
430
+ * from the index WITHOUT reading worktree content) and drop only the
431
+ * paths that actually carry `text`/`eol`/`ident`/`filter`/
432
+ * `working-tree-encoding`.
433
+ *
434
+ * Never throws: a probe that fails leaves the map as-is, which is exactly the
435
+ * behaviour before this gate existed.
436
+ */
437
+ async function dropFilteredOids(
438
+ trusted: Map<string, string>,
439
+ args: {
440
+ /** Every path the enumeration listed: tracked (dirty ones too) and untracked. */
441
+ listed: readonly string[]
442
+ workspaceRoot: string
443
+ gitDir: string
444
+ gitPrefix: string
445
+ pathspecs: readonly string[]
446
+ gitVars: string
447
+ spawnGit: (a: string[], stdin?: string) => Promise<GitRun | null>
448
+ },
449
+ ): Promise<void> {
450
+ if (trusted.size === 0) return
451
+ if (autocrlfConverts(args.gitVars)) {
452
+ trusted.clear()
453
+ return
454
+ }
455
+ // Cheap checks first, and nothing is materialized until one of them fires —
456
+ // on a 15k-file repo with no attributes this whole function is a size check,
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.
459
+ let attributesPossible =
460
+ attributeFilesOutsideTree(args.gitVars, process.env).some((f) =>
461
+ existsSync(path.resolve(args.workspaceRoot, f)),
462
+ ) ||
463
+ (args.gitDir !== '' &&
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).
469
+ if (!attributesPossible) {
470
+ for (const rel of args.listed) {
471
+ if (rel === '.gitattributes' || rel.endsWith('/.gitattributes')) {
472
+ attributesPossible = true
473
+ break
474
+ }
475
+ }
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)
489
+ if (!attributesPossible) return
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.
496
+ const res = await args.spawnGit(
497
+ ['check-attr', '--stdin', '-z', 'text', 'eol', 'ident', 'filter', 'working-tree-encoding'],
498
+ [...trusted.keys()].join('\0'),
499
+ )
500
+ if (res === null || res.exitCode !== 0) return
501
+ for (const rel of parseCheckAttrOutput(res.stdout)) trusted.delete(rel)
502
+ }
503
+
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
541
+ }
542
+ }
543
+ return false
544
+ }
545
+
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> } {
619
+ const tokens = out.split('\0')
620
+ const dirty = new Set<string>()
621
+ const untracked: string[] = []
622
+ const undecodable = new Set<string>()
623
+ for (let i = 0; i < tokens.length; i++) {
624
+ const token = tokens[i]!
625
+ if (token.length < 4) continue
626
+ if (undecodableRecords.size > 0 && undecodableRecords.has(token))
627
+ undecodable.add(token.slice(3))
628
+ if (token[0] === '?') {
629
+ untracked.push(token.slice(3))
630
+ continue
631
+ }
632
+ dirty.add(token.slice(3))
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).
638
+ const x = token[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) {
642
+ i++
643
+ dirty.add(tokens[i]!)
644
+ }
645
+ }
646
+ return { dirty, untracked, undecodable }
647
+ }
648
+
649
+ /**
650
+ * Strip a repo→workspace `--show-prefix` (e.g. `code/`) from each path, keeping
651
+ * only paths inside the workspace — so a repo-root-relative set (from `git
652
+ * status`/`diff`) is re-keyed workspace-relative to match `git ls-files`. Empty
653
+ * prefix → returns the set unchanged (workspace root IS the git root).
654
+ */
655
+ function stripPrefixFromSet(paths: Set<string>, prefix: string): Set<string> {
656
+ if (prefix === '') return paths
657
+ const out = new Set<string>()
658
+ for (const p of paths) if (p.startsWith(prefix)) out.add(p.slice(prefix.length))
659
+ return out
660
+ }
661
+
662
+ /**
663
+ * Run `git ls-files` ONCE at the workspace root, then partition the
664
+ * result by project. Populates `cache` for every project in
665
+ * `projectDirs` with project-relative path lists matching what a
666
+ * per-project spawn would have produced. Throws `UserError` if the
667
+ * workspace isn't a git work tree (vx requires git).
668
+ *
669
+ * Why bulk: each spawn costs ~5-10ms (fork+exec). On a 200-project
670
+ * workspace that's 1-2s of pure overhead reclaimed.
671
+ *
672
+ * Files in nested-project subtrees stay in their parent's list — the
673
+ * boundary-ignore globs in `resolveFiles` filter them out the same way
674
+ * they did before. Cheaper to filter once-per-task than to subtract
675
+ * here.
676
+ *
677
+ * v20: the same `ls-files -s` spawn also yields each tracked file's
678
+ * index OID. A second spawn (`git status --porcelain`) prunes paths
679
+ * whose working tree diverges from the index; what survives is
680
+ * stored per project via `cache.setOids` and feeds `Cache.key`
681
+ * directly — clean-tree input hashing costs zero reads/stats/SQLite.
682
+ *
683
+ * `workspaceWide`: set when any loaded task declares
684
+ * `cache.inputs.workspaceFiles`. Disables pathspec scoping (those
685
+ * globs must see every file from the root) and additionally stores a
686
+ * workspace-wide partition keyed by `workspaceRoot` (files + trusted
687
+ * OIDs), which `resolveWorkspaceFiles` consumes. When false, the
688
+ * enumeration behavior — pathspecs, spawn count, stored partitions —
689
+ * is byte-identical to the pre-workspaceFiles code.
690
+ */
691
+ export async function populateGitFilesCache(
692
+ workspaceRoot: string,
693
+ projectDirs: readonly string[],
694
+ cache: GitFilesCache,
695
+ workspaceWide = false,
696
+ ): Promise<void> {
697
+ const enumeration = await startGitEnumeration(
698
+ workspaceRoot,
699
+ gitPathspecs(workspaceRoot, projectDirs, workspaceWide),
700
+ )
701
+ applyGitEnumeration(enumeration, workspaceRoot, projectDirs, cache, workspaceWide)
702
+ }
703
+
704
+ /** What one workspace-wide enumeration learned, before it is partitioned per project. */
705
+ export interface GitEnumeration {
706
+ /** Every enumerated path, workspace-relative, tracked and untracked. */
707
+ all: string[]
708
+ /** Workspace-relative path → index OID, for files whose worktree state matched the index. */
709
+ trusted: Map<string, string>
710
+ /** Whether the worktree had uncommitted changes; null when `git status` failed. */
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
716
+ }
717
+
718
+ /**
719
+ * Pathspec scoping: when the run only needs a handful of projects
720
+ * (scoped config loading), let git scan just those dirs — 75 ms →
721
+ * 11 ms on an 11k-file repo. Above 64 dirs (or when a project IS
722
+ * the root) the whole-tree scan wins on arg/exec overhead anyway.
723
+ */
724
+ export function gitPathspecs(
725
+ workspaceRoot: string,
726
+ projectDirs: readonly string[],
727
+ workspaceWide: boolean,
728
+ ): string[] {
729
+ const rels = projectDirs.map((d) => path.relative(workspaceRoot, d).split(path.sep).join('/'))
730
+ const scoped =
731
+ !workspaceWide &&
732
+ rels.length > 0 &&
733
+ rels.length <= 64 &&
734
+ rels.every((r) => r !== '' && r !== '.')
735
+ return scoped ? rels : ['.']
736
+ }
737
+
738
+ /**
739
+ * The spawn half of `populateGitFilesCache`, separated so an UNSCOPED run
740
+ * can start it before the configs are evaluated — the enumeration needs
741
+ * only the pathspecs, and `['.']` is right for every unscoped run — and
742
+ * overlap ~60 ms of git with ~80 ms of config evaluation on a
743
+ * 1000-project tree instead of paying them back to back.
744
+ */
745
+ export async function startGitEnumeration(
746
+ workspaceRoot: string,
747
+ pathspecs: readonly string[],
748
+ ): Promise<GitEnumeration> {
749
+ // The spawns are independent — run them concurrently so the
750
+ // bulk-populate costs max(ls-files, status) wall time, not the sum
751
+ // (status alone is ~74 ms on a 1000-project tree; serial spawning
752
+ // was a measurable warm-path regression vs the pre-OID code).
753
+ const spawnGit = async (args: string[], stdin?: string): Promise<GitRun | null> => {
754
+ try {
755
+ const proc = Bun.spawn({
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],
761
+ cwd: workspaceRoot,
762
+ stdin: stdin === undefined ? 'ignore' : new TextEncoder().encode(stdin),
763
+ stdout: 'pipe',
764
+ stderr: 'pipe',
765
+ })
766
+ const [stdout, stderr, exitCode] = await Promise.all([
767
+ new Response(proc.stdout).bytes(),
768
+ new Response(proc.stderr).text(),
769
+ proc.exited,
770
+ ])
771
+ const { text, undecodable } = decodeGitZ(stdout)
772
+ return { exitCode, stdout: text, stderr, undecodable }
773
+ } catch {
774
+ return null
775
+ }
776
+ }
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
780
+ // (~9 ms on a 1000-project tree) and answers two questions at once: every
781
+ // tracked path's OID and its cache-state flag. `status -uall` is the one
782
+ // command that walks the worktree, and it answers two as well: which
783
+ // tracked paths are dirty AND which files are untracked. Asking
784
+ // `ls-files --others` for the untracked set walked the same tree a second
785
+ // time (~50 ms of CPU, concurrent with status but contending with it).
786
+ const running = Promise.all([
787
+ spawnGit(['ls-files', '-s', '-v', '-z', '--', ...pathspecs]),
788
+ spawnGit(['status', '--porcelain', '-z', '-uall', '--', ...pathspecs]),
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']),
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
814
+ if (ls === null) {
815
+ throw gitSpawnRefusal(workspaceRoot)
816
+ }
817
+ if (ls.exitCode !== 0) {
818
+ const stderr = ls.stderr.trim()
819
+ throw new UserError(
820
+ `vx requires git: ${workspaceRoot} is not inside a git work tree. ` +
821
+ `Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
822
+ )
823
+ }
824
+ const {
825
+ files: tracked,
826
+ oids,
827
+ flagged,
828
+ undecodable,
829
+ } = parseLsFilesOutput(ls.stdout, ls.undecodable)
830
+ // Normalize `status`'s repo-root-relative paths to workspace-relative (strip
831
+ // the `--show-prefix`) so the dirty set is keyed identically to the trusted
832
+ // OID map. Without this, when the workspace root is a git subdir, a modified
833
+ // tracked file is never pruned from `trusted` and keeps its committed OID —
834
+ // a STALE cache hit serving old outputs. Empty prefix (workspace == git root,
835
+ // the common case) is a zero-cost no-op. Paths above the workspace can't be
836
+ // inputs, so they drop out of the set.
837
+ const gitPrefix = facts?.prefix ?? ''
838
+ const gitDir = facts?.commonDir ?? ''
839
+ const parsedStatus =
840
+ status !== null && status.exitCode === 0
841
+ ? parseStatusOutput(status.stdout, status.undecodable)
842
+ : null
843
+ const dirty = parsedStatus === null ? null : stripPrefixFromSet(parsedStatus.dirty, gitPrefix)
844
+ // Untracked files are inputs too (a new file is the commonest edit there
845
+ // is). They come from the status walk, repo-root-relative like the dirty
846
+ // set. Without a status answer the enumeration is the index alone.
847
+ const untracked =
848
+ parsedStatus === null ? [] : [...stripPrefixFromSet(new Set(parsedStatus.untracked), gitPrefix)]
849
+ const all = untracked.length === 0 ? tracked : tracked.concat(untracked)
850
+ // Aggregate dirtiness for the Tier-3 invocation record — derived from
851
+ // this same status spawn so `run()` needs no second `git status`.
852
+ // null when the status spawn failed (non-repo / git error).
853
+ const worktreeDirty = dirty === null ? null : dirty.size > 0
854
+ const trusted = dirty === null ? new Map<string, string>() : oids
855
+ if (dirty !== null) {
856
+ for (const rel of dirty) trusted.delete(rel)
857
+ }
858
+ // A skip-worktree / assume-unchanged path sits at stage 0 and `git status`
859
+ // reports nothing for it, so it would otherwise keep a trusted OID — and
860
+ // resolveFiles SKIPS its existence probe for OID-carrying paths. A sparse
861
+ // checkout would then count a file that is not on disk as an input, so
862
+ // materializing it later changes no key and the old artifact is replayed.
863
+ // Dropping the OID sends these back through the probe, where they correctly
864
+ // fall out of the input set while unmaterialized.
865
+ for (const rel of flagged) trusted.delete(rel)
866
+ // An index OID is only the file's content hash when git stores the worktree
867
+ // bytes VERBATIM. Under a clean filter (`text`/`eol`/`ident`/`filter`/`working-tree-encoding`) the blob is a
868
+ // DIFFERENT sequence of bytes — the LF-normalized form — while the task
869
+ // reads the CRLF worktree file. `git status` compares AFTER filtering, so
870
+ // such a file reports clean and keeps its OID: the CRLF and LF states then
871
+ // fold the SAME key and a real content change is invisible.
872
+ //
873
+ // Dropping an OID is not over-invalidation. It routes the path to
874
+ // `hashFile`, which hashes the worktree bytes — the source that was correct
875
+ // all along. The only cost is the read, so the gate below is about paying it
876
+ // ONLY where a filter can actually apply.
877
+ await dropFilteredOids(trusted, {
878
+ listed: all,
879
+ workspaceRoot,
880
+ gitDir,
881
+ gitPrefix,
882
+ pathspecs,
883
+ gitVars: vars !== null && vars.exitCode === 0 ? vars.stdout : '',
884
+ spawnGit,
885
+ })
886
+ if (parsedStatus !== null && parsedStatus.undecodable.size > 0) {
887
+ undecodable.push(...stripPrefixFromSet(parsedStatus.undecodable, gitPrefix))
888
+ }
889
+ return { all, trusted, dirty: worktreeDirty, undecodable, startedAtMs }
890
+ }
891
+
892
+ /** The partition half of `populateGitFilesCache`: store per-project slices of one enumeration. */
893
+ export function applyGitEnumeration(
894
+ enumeration: GitEnumeration,
895
+ workspaceRoot: string,
896
+ projectDirs: readonly string[],
897
+ cache: GitFilesCache,
898
+ workspaceWide = false,
899
+ ): void {
900
+ const { all, trusted } = enumeration
901
+ cache.setWorktreeDirty(enumeration.dirty)
902
+ cache.enumeratedAtMs = enumeration.startedAtMs
903
+ cache.markUndecodable(enumeration.undecodable.map((rel) => path.join(workspaceRoot, rel)))
904
+ // Sort once, then each project's files are a contiguous range found
905
+ // by binary search on its `dir/` prefix — O((F+P) log F) instead of
906
+ // the O(P·F) per-project startsWith scan (54 ms at 1090 projects ×
907
+ // ~9k files; ~5 ms this way). '/' sorts below most filename chars,
908
+ // so the range [prefix, prefix+'\xff…') is contiguous in the sorted
909
+ // array; lowerBound on `prefix` and on `prefix + '￿'` bracket it.
910
+ const sorted = [...all].sort()
911
+ const lowerBound = (key: string): number => {
912
+ let lo = 0
913
+ let hi = sorted.length
914
+ while (lo < hi) {
915
+ const mid = (lo + hi) >>> 1
916
+ if (sorted[mid]! < key) lo = mid + 1
917
+ else hi = mid
918
+ }
919
+ return lo
920
+ }
921
+ for (const projectDir of projectDirs) {
922
+ const relPrefix = path.relative(workspaceRoot, projectDir).split(path.sep).join('/')
923
+ if (relPrefix === '' || relPrefix === '.') {
924
+ cache.set(projectDir, all)
925
+ const rootOids = new Map<string, string>()
926
+ for (const [rel, oid] of trusted) rootOids.set(path.join(workspaceRoot, rel), oid)
927
+ cache.setOids(projectDir, rootOids)
928
+ continue
929
+ }
930
+ const prefix = `${relPrefix}/`
931
+ const start = lowerBound(prefix)
932
+ const end = lowerBound(`${prefix}￿`)
933
+ const matches: string[] = []
934
+ const projOids = new Map<string, string>()
935
+ for (let i = start; i < end; i++) {
936
+ const rel = sorted[i]!
937
+ matches.push(rel.slice(prefix.length))
938
+ const oid = trusted.get(rel)
939
+ if (oid !== undefined) projOids.set(path.join(workspaceRoot, rel), oid)
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
955
+ // setOids AFTER set — set() drops the project's OID slot.
956
+ cache.set(projectDir, matches)
957
+ cache.setOids(projectDir, projOids)
958
+ }
959
+ if (workspaceWide) {
960
+ const rootOids = new Map<string, string>()
961
+ for (const [rel, oid] of trusted) rootOids.set(path.join(workspaceRoot, rel), oid)
962
+ cache.set(workspaceRoot, all)
963
+ cache.setOids(workspaceRoot, rootOids)
964
+ cache.setWorkspaceRoot(workspaceRoot)
965
+ }
966
+ }