@vzn/vx 0.0.18 → 0.0.21

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 (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. package/src/plugins/schedule-history/index.ts +0 -109
@@ -0,0 +1,637 @@
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 } from 'node:fs'
10
+ import { UserError } from '../util/index.js'
11
+
12
+ export class GitFilesCache extends Map<string, readonly string[]> {
13
+ private changed = new Map<string, string[]>()
14
+ /**
15
+ * Per-project trusted index OIDs: absolute path → git blob OID, for
16
+ * tracked regular files whose working-tree state matched the index
17
+ * at populate time (per one `git status --porcelain` snapshot).
18
+ * These feed `Cache.key` via `CacheKeyInput.fileHashes` so a clean
19
+ * tree derives input hashes with zero reads / stats / SQLite.
20
+ *
21
+ * Dropped wholesale on `set` / `delete`: a mid-run re-enumeration
22
+ * proves the project's tree changed, and index OIDs can't be
23
+ * re-trusted without a fresh status — the per-file fallback
24
+ * (`Cache.hashFile`) computes the identical blob OID from disk, so
25
+ * dropping is a pure perf concession, never a correctness one.
26
+ */
27
+ private oids = new Map<string, Map<string, string>>()
28
+ /**
29
+ * Set when `populateGitFilesCache` stored a WORKSPACE-WIDE partition
30
+ * (any loaded task declares `inputs.workspaceFiles`). Null when the
31
+ * feature is unused — every workspace-partition hook below is then a
32
+ * no-op, so unused-feature behavior stays byte-identical.
33
+ */
34
+ private wsRoot: string | null = null
35
+ /**
36
+ * Whether the worktree had any uncommitted changes at populate time,
37
+ * derived from the SAME `git status --porcelain` spawn that prunes
38
+ * dirty paths from the trusted-OID set. Lets the Tier-3 invocation
39
+ * record report `dirty` without a SECOND status spawn. `null` until
40
+ * populate runs, or when the status spawn failed (non-repo).
41
+ */
42
+ private dirty: boolean | null = null
43
+
44
+ setWorkspaceRoot(root: string): void {
45
+ this.wsRoot = root
46
+ }
47
+
48
+ /** Aggregate worktree dirtiness from the populate-time status spawn. */
49
+ get worktreeDirty(): boolean | null {
50
+ return this.dirty
51
+ }
52
+
53
+ setWorktreeDirty(dirty: boolean | null): void {
54
+ this.dirty = dirty
55
+ }
56
+
57
+ markOutputsChanged(projectDir: string, relPaths: readonly string[]): void {
58
+ this.recordChanged(projectDir, relPaths)
59
+ // The workspace-wide partition sees the same files under
60
+ // root-relative names; forward so a downstream workspaceFiles
61
+ // task can't reuse a snapshot its globs could now contradict.
62
+ if (this.wsRoot !== null && this.wsRoot !== projectDir) {
63
+ this.recordChanged(
64
+ this.wsRoot,
65
+ relPaths.map((rel) =>
66
+ path.relative(this.wsRoot!, path.resolve(projectDir, rel)).split(path.sep).join('/'),
67
+ ),
68
+ )
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Record root-anchored changed paths (cleaned/restored workspace
74
+ * outputs) against EVERY partition that can see them: the workspace
75
+ * partition under their root-relative names, and any project
76
+ * partition whose dir contains them (workspace outputs may land
77
+ * inside other projects' dirs — the no-boundary escape hatch).
78
+ */
79
+ markWorkspaceOutputsChanged(workspaceRoot: string, relPaths: readonly string[]): void {
80
+ if (relPaths.length === 0) return
81
+ for (const key of this.keys()) {
82
+ if (key === workspaceRoot) {
83
+ this.recordChanged(key, relPaths)
84
+ continue
85
+ }
86
+ const under: string[] = []
87
+ for (const rel of relPaths) {
88
+ const abs = path.resolve(workspaceRoot, rel)
89
+ if (abs.startsWith(key + path.sep)) {
90
+ under.push(path.relative(key, abs).split(path.sep).join('/'))
91
+ }
92
+ }
93
+ if (under.length > 0) this.recordChanged(key, under)
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Drop the workspace-wide partition (if one exists). Called after a
99
+ * cache-miss save — an executed task may have written undeclared
100
+ * files anywhere in its project dir, and the workspace partition
101
+ * spans that subtree; only a fresh enumeration can see them. No-op
102
+ * when the feature is unused.
103
+ */
104
+ invalidateWorkspacePartition(): void {
105
+ if (this.wsRoot !== null) this.delete(this.wsRoot)
106
+ }
107
+
108
+ private recordChanged(partitionDir: string, relPaths: readonly string[]): void {
109
+ if (!this.has(partitionDir)) return
110
+ const cur = this.changed.get(partitionDir)
111
+ if (cur) cur.push(...relPaths)
112
+ else this.changed.set(partitionDir, [...relPaths])
113
+ // These paths just changed on disk; their index OIDs (if any) no
114
+ // longer describe the working-tree content.
115
+ const partitionOids = this.oids.get(partitionDir)
116
+ if (partitionOids) {
117
+ for (const rel of relPaths) partitionOids.delete(path.resolve(partitionDir, rel))
118
+ }
119
+ }
120
+
121
+ /** Trusted index OIDs for a project (abs path → oid), if any survive. */
122
+ oidsFor(projectDir: string): ReadonlyMap<string, string> | undefined {
123
+ return this.oids.get(projectDir)
124
+ }
125
+
126
+ setOids(projectDir: string, oids: Map<string, string>): void {
127
+ this.oids.set(projectDir, oids)
128
+ }
129
+
130
+ /** Snapshot if still valid for these input globs; undefined → re-spawn. */
131
+ snapshotFor(projectDir: string, inputGlobs: readonly Bun.Glob[]): readonly string[] | undefined {
132
+ const snap = this.get(projectDir)
133
+ if (snap === undefined) return undefined
134
+ const pending = this.changed.get(projectDir)
135
+ if (pending !== undefined && pending.some((p) => inputGlobs.some((g) => g.match(p)))) {
136
+ return undefined
137
+ }
138
+ return snap
139
+ }
140
+
141
+ override set(key: string, value: readonly string[]): this {
142
+ this.changed.delete(key)
143
+ this.oids.delete(key)
144
+ return super.set(key, value)
145
+ }
146
+
147
+ override delete(key: string): boolean {
148
+ this.changed.delete(key)
149
+ this.oids.delete(key)
150
+ return super.delete(key)
151
+ }
152
+ }
153
+
154
+ /** No glob metacharacter — the entry names one exact path. */
155
+
156
+ interface GitLsResult {
157
+ /** cwd-relative paths — same visibility set as `--cached --others`. */
158
+ files: string[]
159
+ /**
160
+ * cwd-relative path → index blob OID, for tracked regular files
161
+ * (mode 100644 / 100755) and symlinks (120000) at stage 0 only. A
162
+ * symlink's OID is the blob of its target string, which is exactly
163
+ * what `Cache.hashFile` computes for one, so the two paths agree.
164
+ * Merge-conflict stages and gitlinks are excluded. NOT yet filtered
165
+ * by working-tree dirtiness — callers intersect with `git status`
166
+ * before trusting.
167
+ */
168
+ oids: Map<string, string>
169
+ /** Paths flagged skip-worktree / assume-unchanged (only when `-v` was passed). */
170
+ flagged: Set<string>
171
+ }
172
+
173
+ const LS_FILES_STAGE_RE = /^(?:([A-Za-z]) )?([0-7]{6}) ([0-9a-f]{40,64}) ([0-3])\t/
174
+
175
+ /**
176
+ * Run `git ls-files -s --others --exclude-standard -z .` in `cwd`.
177
+ * One spawn yields BOTH the file list (identical visibility to the
178
+ * pre-v20 `--cached --others` form, verified empirically: same set
179
+ * including staged-but-deleted files and per-stage conflict
180
+ * duplicates) AND each tracked file's index OID — the heart of the
181
+ * Turbo-parity "hashes come from git's index" fast path. Throws a
182
+ * `UserError` when git is unavailable or `cwd` isn't a git work
183
+ * tree; vx requires git. `-z` survives filenames with newlines /
184
+ * spaces.
185
+ */
186
+ export function runGitLsFiles(cwd: string): GitLsResult {
187
+ let proc
188
+ try {
189
+ proc = Bun.spawnSync({
190
+ cmd: ['git', 'ls-files', '-s', '--others', '--exclude-standard', '-z', '.'],
191
+ cwd,
192
+ stdout: 'pipe',
193
+ stderr: 'pipe',
194
+ })
195
+ } catch {
196
+ throw new UserError(
197
+ `vx requires git: failed to spawn 'git' (working dir: ${cwd}). Install git and re-run.`,
198
+ )
199
+ }
200
+ if (proc.exitCode !== 0) {
201
+ // Exit 128 = not a git work tree; other non-zero = git failure.
202
+ // Either way we can't enumerate inputs reliably.
203
+ const stderr = new TextDecoder().decode(proc.stderr).trim()
204
+ throw new UserError(
205
+ `vx requires git: ${cwd} is not inside a git work tree. ` +
206
+ `Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
207
+ )
208
+ }
209
+ return parseLsFilesOutput(new TextDecoder().decode(proc.stdout))
210
+ }
211
+
212
+ // Each `ls-files -s -v` record is `[<flag> ]<mode> <oid> <stage>\t<path>` —
213
+ // the staged-entry form, with an optional cache-state letter (`H`, `S`,
214
+ // `h`, …) in front. `--others` paths print bare; with `-z`, core.quotePath
215
+ // quoting is off, so a bare path containing a literal tab still cannot
216
+ // match the fixed-form prefix. Both answers come from ONE spawn.
217
+ function parseLsFilesOutput(out: string): GitLsResult {
218
+ const files: string[] = []
219
+ const oids = new Map<string, string>()
220
+ const flagged = new Set<string>()
221
+ if (out.length === 0) return { files, oids, flagged }
222
+ // NUL-separated; trailing NUL produces an empty segment we skip.
223
+ for (const record of out.split('\0')) {
224
+ if (record.length === 0) continue
225
+ 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)
231
+ files.push(filePath)
232
+ const mode = m[2]!
233
+ const stage = m[4]!
234
+ if ((mode === '100644' || mode === '100755' || mode === '120000') && stage === '0') {
235
+ oids.set(filePath, m[3]!)
236
+ }
237
+ // A LOWERCASE letter means skip-worktree or assume-unchanged (`S` is the
238
+ // older spelling of skip-worktree): git has been told to stop looking at
239
+ // the worktree file, so its index OID says nothing about the disk.
240
+ const flag = m[1]
241
+ if (flag !== undefined && (flag === 'S' || (flag >= 'a' && flag <= 'z'))) flagged.add(filePath)
242
+ }
243
+ return { files, oids, flagged }
244
+ }
245
+
246
+ /** One completed `git` invocation. */
247
+ interface GitRun {
248
+ exitCode: number
249
+ stdout: string
250
+ stderr: string
251
+ }
252
+
253
+ /**
254
+ * Parse `git check-attr -z text eol ident` output — a flat stream of
255
+ * `<path>\0<attr>\0<value>\0` triples — into the set of paths where a clean
256
+ * filter can rewrite bytes. `unspecified` means no rule matched and
257
+ * `unset` (`-text`) explicitly disables conversion; both leave the index blob
258
+ * byte-identical to the worktree file, so those OIDs stay trusted.
259
+ */
260
+ export function parseCheckAttrOutput(out: string): Set<string> {
261
+ const affected = new Set<string>()
262
+ const fields = out.split('\0')
263
+ for (let i = 0; i + 2 < fields.length; i += 3) {
264
+ const value = fields[i + 2]!
265
+ if (value !== 'unspecified' && value !== 'unset') affected.add(fields[i]!)
266
+ }
267
+ return affected
268
+ }
269
+
270
+ /**
271
+ * Remove from `trusted` every path whose index blob may differ from its
272
+ * worktree bytes because a clean filter applies.
273
+ *
274
+ * Gated in two steps so the common case pays NOTHING. The precise answer —
275
+ * `git ls-files --eol`, comparing `i/` to `w/` — was measured at 240 ms on a
276
+ * 15k-file tree because it must READ every worktree file: 13x the entire
277
+ * enumeration, on a run whose warm total is ~130 ms, and in the common Linux
278
+ * case it finds nothing. So instead:
279
+ *
280
+ * 1. If `core.autocrlf` is true/input, conversion applies to every
281
+ * auto-detected text file with no attribute needed — trust nothing.
282
+ * 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.
286
+ * 3. Otherwise ask `git check-attr` (measured 21 ms; it resolves attributes
287
+ * from the index WITHOUT reading worktree content) and drop only the
288
+ * paths that actually carry `text`/`eol`/`ident`.
289
+ *
290
+ * Never throws: a probe that fails leaves the map as-is, which is exactly the
291
+ * behaviour before this gate existed.
292
+ */
293
+ async function dropFilteredOids(
294
+ trusted: Map<string, string>,
295
+ args: {
296
+ workspaceRoot: string
297
+ gitDir: string
298
+ coreConfig: string
299
+ spawnGit: (a: string[], stdin?: string) => Promise<GitRun | null>
300
+ },
301
+ ): Promise<void> {
302
+ if (trusted.size === 0) return
303
+ if (autocrlfConverts(args.coreConfig)) {
304
+ trusted.clear()
305
+ return
306
+ }
307
+ // Cheap checks first, and nothing is materialized until one of them fires —
308
+ // 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.
311
+ let attributesPossible =
312
+ args.coreConfig.toLowerCase().includes('core.attributesfile') ||
313
+ (args.gitDir !== '' &&
314
+ existsSync(path.resolve(args.workspaceRoot, args.gitDir, 'info', 'attributes')))
315
+ if (!attributesPossible) {
316
+ for (const rel of trusted.keys()) {
317
+ if (rel === '.gitattributes' || rel.endsWith('/.gitattributes')) {
318
+ attributesPossible = true
319
+ break
320
+ }
321
+ }
322
+ }
323
+ if (!attributesPossible) return
324
+
325
+ const res = await args.spawnGit(
326
+ ['check-attr', '--stdin', '-z', 'text', 'eol', 'ident'],
327
+ [...trusted.keys()].join('\0'),
328
+ )
329
+ if (res === null || res.exitCode !== 0) return
330
+ for (const rel of parseCheckAttrOutput(res.stdout)) trusted.delete(rel)
331
+ }
332
+
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'
340
+ }
341
+ }
342
+ return false
343
+ }
344
+
345
+ function parseStatusOutput(out: string): { dirty: Set<string>; untracked: string[] } {
346
+ const tokens = out.split('\0')
347
+ const dirty = new Set<string>()
348
+ const untracked: string[] = []
349
+ for (let i = 0; i < tokens.length; i++) {
350
+ const token = tokens[i]!
351
+ if (token.length < 4) continue
352
+ if (token[0] === '?') {
353
+ untracked.push(token.slice(3))
354
+ continue
355
+ }
356
+ dirty.add(token.slice(3))
357
+ // X = R or C → the next token is the rename/copy source path.
358
+ const x = token[0]
359
+ if ((x === 'R' || x === 'C') && i + 1 < tokens.length && tokens[i + 1]!.length > 0) {
360
+ i++
361
+ dirty.add(tokens[i]!)
362
+ }
363
+ }
364
+ return { dirty, untracked }
365
+ }
366
+
367
+ /**
368
+ * Strip a repo→workspace `--show-prefix` (e.g. `code/`) from each path, keeping
369
+ * only paths inside the workspace — so a repo-root-relative set (from `git
370
+ * status`/`diff`) is re-keyed workspace-relative to match `git ls-files`. Empty
371
+ * prefix → returns the set unchanged (workspace root IS the git root).
372
+ */
373
+ function stripPrefixFromSet(paths: Set<string>, prefix: string): Set<string> {
374
+ if (prefix === '') return paths
375
+ const out = new Set<string>()
376
+ for (const p of paths) if (p.startsWith(prefix)) out.add(p.slice(prefix.length))
377
+ return out
378
+ }
379
+
380
+ /**
381
+ * Run `git ls-files` ONCE at the workspace root, then partition the
382
+ * result by project. Populates `cache` for every project in
383
+ * `projectDirs` with project-relative path lists matching what a
384
+ * per-project spawn would have produced. Throws `UserError` if the
385
+ * workspace isn't a git work tree (vx requires git).
386
+ *
387
+ * Why bulk: each spawn costs ~5-10ms (fork+exec). On a 200-project
388
+ * workspace that's 1-2s of pure overhead reclaimed.
389
+ *
390
+ * Files in nested-project subtrees stay in their parent's list — the
391
+ * boundary-ignore globs in `resolveFiles` filter them out the same way
392
+ * they did before. Cheaper to filter once-per-task than to subtract
393
+ * here.
394
+ *
395
+ * v20: the same `ls-files -s` spawn also yields each tracked file's
396
+ * index OID. A second spawn (`git status --porcelain`) prunes paths
397
+ * whose working tree diverges from the index; what survives is
398
+ * stored per project via `cache.setOids` and feeds `Cache.key`
399
+ * directly — clean-tree input hashing costs zero reads/stats/SQLite.
400
+ *
401
+ * `workspaceWide`: set when any loaded task declares
402
+ * `cache.inputs.workspaceFiles`. Disables pathspec scoping (those
403
+ * globs must see every file from the root) and additionally stores a
404
+ * workspace-wide partition keyed by `workspaceRoot` (files + trusted
405
+ * OIDs), which `resolveWorkspaceFiles` consumes. When false, the
406
+ * enumeration behavior — pathspecs, spawn count, stored partitions —
407
+ * is byte-identical to the pre-workspaceFiles code.
408
+ */
409
+ export async function populateGitFilesCache(
410
+ workspaceRoot: string,
411
+ projectDirs: readonly string[],
412
+ cache: GitFilesCache,
413
+ workspaceWide = false,
414
+ ): Promise<void> {
415
+ const enumeration = await startGitEnumeration(
416
+ workspaceRoot,
417
+ gitPathspecs(workspaceRoot, projectDirs, workspaceWide),
418
+ )
419
+ applyGitEnumeration(enumeration, workspaceRoot, projectDirs, cache, workspaceWide)
420
+ }
421
+
422
+ /** What one workspace-wide enumeration learned, before it is partitioned per project. */
423
+ export interface GitEnumeration {
424
+ /** Every enumerated path, workspace-relative, tracked and untracked. */
425
+ all: string[]
426
+ /** Workspace-relative path → index OID, for files whose worktree state matched the index. */
427
+ trusted: Map<string, string>
428
+ /** Whether the worktree had uncommitted changes; null when `git status` failed. */
429
+ dirty: boolean | null
430
+ }
431
+
432
+ /**
433
+ * Pathspec scoping: when the run only needs a handful of projects
434
+ * (scoped config loading), let git scan just those dirs — 75 ms →
435
+ * 11 ms on an 11k-file repo. Above 64 dirs (or when a project IS
436
+ * the root) the whole-tree scan wins on arg/exec overhead anyway.
437
+ */
438
+ export function gitPathspecs(
439
+ workspaceRoot: string,
440
+ projectDirs: readonly string[],
441
+ workspaceWide: boolean,
442
+ ): string[] {
443
+ const rels = projectDirs.map((d) => path.relative(workspaceRoot, d).split(path.sep).join('/'))
444
+ const scoped =
445
+ !workspaceWide &&
446
+ rels.length > 0 &&
447
+ rels.length <= 64 &&
448
+ rels.every((r) => r !== '' && r !== '.')
449
+ return scoped ? rels : ['.']
450
+ }
451
+
452
+ /**
453
+ * The spawn half of `populateGitFilesCache`, separated so an UNSCOPED run
454
+ * can start it before the configs are evaluated — the enumeration needs
455
+ * only the pathspecs, and `['.']` is right for every unscoped run — and
456
+ * overlap ~60 ms of git with ~80 ms of config evaluation on a
457
+ * 1000-project tree instead of paying them back to back.
458
+ */
459
+ export async function startGitEnumeration(
460
+ workspaceRoot: string,
461
+ pathspecs: readonly string[],
462
+ ): Promise<GitEnumeration> {
463
+ // The spawns are independent — run them concurrently so the
464
+ // bulk-populate costs max(ls-files, status) wall time, not the sum
465
+ // (status alone is ~74 ms on a 1000-project tree; serial spawning
466
+ // was a measurable warm-path regression vs the pre-OID code).
467
+ const spawnGit = async (args: string[], stdin?: string): Promise<GitRun | null> => {
468
+ try {
469
+ const proc = Bun.spawn({
470
+ cmd: ['git', ...args],
471
+ cwd: workspaceRoot,
472
+ stdin: stdin === undefined ? 'ignore' : new TextEncoder().encode(stdin),
473
+ stdout: 'pipe',
474
+ stderr: 'pipe',
475
+ })
476
+ const [stdout, stderr, exitCode] = await Promise.all([
477
+ new Response(proc.stdout).text(),
478
+ new Response(proc.stderr).text(),
479
+ proc.exited,
480
+ ])
481
+ return { exitCode, stdout, stderr }
482
+ } catch {
483
+ return null
484
+ }
485
+ }
486
+ // Four spawns, one worktree walk. `ls-files -s -v` reads the INDEX only
487
+ // (~9 ms on a 1000-project tree) and answers two questions at once: every
488
+ // tracked path's OID and its cache-state flag. `status -uall` is the one
489
+ // command that walks the worktree, and it answers two as well: which
490
+ // tracked paths are dirty AND which files are untracked. Asking
491
+ // `ls-files --others` for the untracked set walked the same tree a second
492
+ // time (~50 ms of CPU, concurrent with status but contending with it).
493
+ const [ls, status, prefixRes, coreCfg] = await Promise.all([
494
+ spawnGit(['ls-files', '-s', '-v', '-z', '--', ...pathspecs]),
495
+ 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)$']),
508
+ ])
509
+ if (ls === null) {
510
+ throw new UserError(
511
+ `vx requires git: failed to spawn 'git' (working dir: ${workspaceRoot}). Install git and re-run.`,
512
+ )
513
+ }
514
+ if (ls.exitCode !== 0) {
515
+ const stderr = ls.stderr.trim()
516
+ throw new UserError(
517
+ `vx requires git: ${workspaceRoot} is not inside a git work tree. ` +
518
+ `Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
519
+ )
520
+ }
521
+ const { files: tracked, oids, flagged } = parseLsFilesOutput(ls.stdout)
522
+ // Normalize `status`'s repo-root-relative paths to workspace-relative (strip
523
+ // the `--show-prefix`) so the dirty set is keyed identically to the trusted
524
+ // OID map. Without this, when the workspace root is a git subdir, a modified
525
+ // tracked file is never pruned from `trusted` and keeps its committed OID —
526
+ // a STALE cache hit serving old outputs. Empty prefix (workspace == git root,
527
+ // the common case) is a zero-cost no-op. Paths above the workspace can't be
528
+ // 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()
534
+ const parsedStatus =
535
+ status !== null && status.exitCode === 0 ? parseStatusOutput(status.stdout) : null
536
+ const dirty = parsedStatus === null ? null : stripPrefixFromSet(parsedStatus.dirty, gitPrefix)
537
+ // Untracked files are inputs too (a new file is the commonest edit there
538
+ // is). They come from the status walk, repo-root-relative like the dirty
539
+ // set. Without a status answer the enumeration is the index alone.
540
+ const untracked =
541
+ parsedStatus === null ? [] : [...stripPrefixFromSet(new Set(parsedStatus.untracked), gitPrefix)]
542
+ const all = untracked.length === 0 ? tracked : tracked.concat(untracked)
543
+ // Aggregate dirtiness for the Tier-3 invocation record — derived from
544
+ // this same status spawn so `run()` needs no second `git status`.
545
+ // null when the status spawn failed (non-repo / git error).
546
+ const worktreeDirty = dirty === null ? null : dirty.size > 0
547
+ const trusted = dirty === null ? new Map<string, string>() : oids
548
+ if (dirty !== null) {
549
+ for (const rel of dirty) trusted.delete(rel)
550
+ }
551
+ // A skip-worktree / assume-unchanged path sits at stage 0 and `git status`
552
+ // reports nothing for it, so it would otherwise keep a trusted OID — and
553
+ // resolveFiles SKIPS its existence probe for OID-carrying paths. A sparse
554
+ // checkout would then count a file that is not on disk as an input, so
555
+ // materializing it later changes no key and the old artifact is replayed.
556
+ // Dropping the OID sends these back through the probe, where they correctly
557
+ // fall out of the input set while unmaterialized.
558
+ for (const rel of flagged) trusted.delete(rel)
559
+ // 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
561
+ // DIFFERENT sequence of bytes — the LF-normalized form — while the task
562
+ // reads the CRLF worktree file. `git status` compares AFTER filtering, so
563
+ // such a file reports clean and keeps its OID: the CRLF and LF states then
564
+ // fold the SAME key and a real content change is invisible.
565
+ //
566
+ // Dropping an OID is not over-invalidation. It routes the path to
567
+ // `hashFile`, which hashes the worktree bytes — the source that was correct
568
+ // all along. The only cost is the read, so the gate below is about paying it
569
+ // ONLY where a filter can actually apply.
570
+ await dropFilteredOids(trusted, {
571
+ workspaceRoot,
572
+ gitDir,
573
+ coreConfig: coreCfg !== null && coreCfg.exitCode === 0 ? coreCfg.stdout : '',
574
+ spawnGit,
575
+ })
576
+ return { all, trusted, dirty: worktreeDirty }
577
+ }
578
+
579
+ /** The partition half of `populateGitFilesCache`: store per-project slices of one enumeration. */
580
+ export function applyGitEnumeration(
581
+ enumeration: GitEnumeration,
582
+ workspaceRoot: string,
583
+ projectDirs: readonly string[],
584
+ cache: GitFilesCache,
585
+ workspaceWide = false,
586
+ ): void {
587
+ const { all, trusted } = enumeration
588
+ cache.setWorktreeDirty(enumeration.dirty)
589
+ // Sort once, then each project's files are a contiguous range found
590
+ // by binary search on its `dir/` prefix — O((F+P) log F) instead of
591
+ // the O(P·F) per-project startsWith scan (54 ms at 1090 projects ×
592
+ // ~9k files; ~5 ms this way). '/' sorts below most filename chars,
593
+ // so the range [prefix, prefix+'\xff…') is contiguous in the sorted
594
+ // array; lowerBound on `prefix` and on `prefix + '￿'` bracket it.
595
+ const sorted = [...all].sort()
596
+ const lowerBound = (key: string): number => {
597
+ let lo = 0
598
+ let hi = sorted.length
599
+ while (lo < hi) {
600
+ const mid = (lo + hi) >>> 1
601
+ if (sorted[mid]! < key) lo = mid + 1
602
+ else hi = mid
603
+ }
604
+ return lo
605
+ }
606
+ for (const projectDir of projectDirs) {
607
+ const relPrefix = path.relative(workspaceRoot, projectDir).split(path.sep).join('/')
608
+ if (relPrefix === '' || relPrefix === '.') {
609
+ cache.set(projectDir, all)
610
+ const rootOids = new Map<string, string>()
611
+ for (const [rel, oid] of trusted) rootOids.set(path.join(workspaceRoot, rel), oid)
612
+ cache.setOids(projectDir, rootOids)
613
+ continue
614
+ }
615
+ const prefix = `${relPrefix}/`
616
+ const start = lowerBound(prefix)
617
+ const end = lowerBound(`${prefix}￿`)
618
+ const matches: string[] = []
619
+ const projOids = new Map<string, string>()
620
+ for (let i = start; i < end; i++) {
621
+ const rel = sorted[i]!
622
+ matches.push(rel.slice(prefix.length))
623
+ const oid = trusted.get(rel)
624
+ if (oid !== undefined) projOids.set(path.join(workspaceRoot, rel), oid)
625
+ }
626
+ // setOids AFTER set — set() drops the project's OID slot.
627
+ cache.set(projectDir, matches)
628
+ cache.setOids(projectDir, projOids)
629
+ }
630
+ if (workspaceWide) {
631
+ const rootOids = new Map<string, string>()
632
+ for (const [rel, oid] of trusted) rootOids.set(path.join(workspaceRoot, rel), oid)
633
+ cache.set(workspaceRoot, all)
634
+ cache.setOids(workspaceRoot, rootOids)
635
+ cache.setWorkspaceRoot(workspaceRoot)
636
+ }
637
+ }
@@ -1,6 +1,8 @@
1
1
  // Module contract. Cross-module imports must come through here; see
2
2
  // docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
3
- // tar.ts is internal — artifact pack/extract is an implementation detail.
3
+ // archive.ts / tar-stream.ts / zstd.ts are internal — artifact pack, extract
4
+ // and framing are implementation details; the layer CONTRACT is layer.ts,
5
+ // re-exported through cache.ts.
4
6
 
5
7
  export {
6
8
  Cache,
@@ -18,21 +20,28 @@ export {
18
20
  WORKSPACE_OUTPUT_PREFIX,
19
21
  type OutputDirRow,
20
22
  OUTPUT_DIRS_CAP,
23
+ FILE_HASH_RACY_MS,
21
24
  OUTPUT_DIRS_RACY_MS,
25
+ noteSchemaReset,
26
+ type SchemaReset,
27
+ CACHE_VERSION,
28
+ SCHEMA_VERSION,
22
29
  } from './cache.js'
23
- export { type CASBackend, FsCASBackend } from './cas-backend.js'
24
- export { type Digest } from './digest.js'
25
30
  export {
26
31
  cleanOutputs,
27
32
  cleanWorkspaceOutputs,
33
+ resolveInputs,
34
+ resolveOutputs,
35
+ resolveWorkspaceOutputs,
36
+ asTrees,
37
+ type WorkspaceFilesCache,
38
+ } from './inputs.js'
39
+ export {
28
40
  GitFilesCache,
29
41
  applyGitEnumeration,
30
42
  gitPathspecs,
31
43
  startGitEnumeration,
32
44
  type GitEnumeration,
33
- resolveInputs,
34
- resolveOutputs,
35
- resolveWorkspaceOutputs,
36
- } from './inputs.js'
45
+ } from './git-inputs.js'
37
46
  export { LayeredCache, type RemoteCacheLayer } from './layered-cache.js'
38
47
  export { ChainedCache } from './chained-cache.js'