@vzn/vx 0.0.0 → 0.0.14

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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -1
  3. package/launcher.mjs +92 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +28 -0
  10. package/src/cache/inputs.ts +839 -0
  11. package/src/cache/layered-cache.ts +355 -0
  12. package/src/cache/tar.ts +343 -0
  13. package/src/cli/backend.ts +23 -0
  14. package/src/cli/cache.ts +96 -0
  15. package/src/cli/format.ts +15 -0
  16. package/src/cli/help.ts +129 -0
  17. package/src/cli/index.ts +85 -0
  18. package/src/cli/info.ts +80 -0
  19. package/src/cli/lock.ts +135 -0
  20. package/src/cli/mcp-rpc.ts +292 -0
  21. package/src/cli/mcp.ts +92 -0
  22. package/src/cli/migrate-nx.ts +450 -0
  23. package/src/cli/migrate-turbo.ts +364 -0
  24. package/src/cli/migrate.ts +219 -0
  25. package/src/cli/plan-format.ts +148 -0
  26. package/src/cli/run.ts +683 -0
  27. package/src/cli/show.ts +201 -0
  28. package/src/cli/upgrade.ts +103 -0
  29. package/src/cli/watch.ts +287 -0
  30. package/src/config.ts +460 -0
  31. package/src/exec/env.ts +74 -0
  32. package/src/exec/index.ts +13 -0
  33. package/src/exec/runner.ts +470 -0
  34. package/src/exec/sandbox-runtime.ts +578 -0
  35. package/src/graph/dependency-spec.ts +85 -0
  36. package/src/graph/index.ts +27 -0
  37. package/src/graph/scheduler.ts +677 -0
  38. package/src/graph/task-graph.ts +377 -0
  39. package/src/index.ts +222 -0
  40. package/src/orchestrator/colors.ts +63 -0
  41. package/src/orchestrator/devframe-surface.ts +74 -0
  42. package/src/orchestrator/events.ts +277 -0
  43. package/src/orchestrator/execute-task.ts +939 -0
  44. package/src/orchestrator/framed-output.ts +461 -0
  45. package/src/orchestrator/history.ts +148 -0
  46. package/src/orchestrator/index.ts +174 -0
  47. package/src/orchestrator/local-shortcircuit.ts +147 -0
  48. package/src/orchestrator/logger.ts +526 -0
  49. package/src/orchestrator/metrics.ts +1927 -0
  50. package/src/orchestrator/options.ts +197 -0
  51. package/src/orchestrator/plan.ts +129 -0
  52. package/src/orchestrator/plugin-host.ts +185 -0
  53. package/src/orchestrator/plugin.ts +283 -0
  54. package/src/orchestrator/predict.ts +106 -0
  55. package/src/orchestrator/prepare.ts +306 -0
  56. package/src/orchestrator/protocol.ts +156 -0
  57. package/src/orchestrator/remote-prefetch.ts +81 -0
  58. package/src/orchestrator/resources.ts +59 -0
  59. package/src/orchestrator/run-artifacts.ts +111 -0
  60. package/src/orchestrator/run-context.ts +205 -0
  61. package/src/orchestrator/run-report.ts +132 -0
  62. package/src/orchestrator/run-state.ts +145 -0
  63. package/src/orchestrator/run.ts +887 -0
  64. package/src/orchestrator/stable-keys.ts +177 -0
  65. package/src/orchestrator/status-line.ts +287 -0
  66. package/src/orchestrator/summary.ts +315 -0
  67. package/src/orchestrator/tally.ts +69 -0
  68. package/src/orchestrator/task-hash.ts +253 -0
  69. package/src/orchestrator/telemetry-host.ts +80 -0
  70. package/src/orchestrator/telemetry.ts +358 -0
  71. package/src/orchestrator/upstream.ts +121 -0
  72. package/src/orchestrator/verify.ts +164 -0
  73. package/src/orchestrator/wire-render.ts +92 -0
  74. package/src/orchestrator/wire.ts +212 -0
  75. package/src/util/errors.ts +11 -0
  76. package/src/util/hash.ts +25 -0
  77. package/src/util/index.ts +8 -0
  78. package/src/util/paths.ts +16 -0
  79. package/src/util/size.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +512 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,839 @@
1
+ // Resolve declared cache inputs into the concrete pieces that go into the
2
+ // cache key:
3
+ // - files: absolute paths whose contents are hashed
4
+ // - envValues: [name, value] pairs from parent process.env
5
+ //
6
+ // `cache.inputs.env` is the cache-tracking axis for env vars; it's
7
+ // independent of `exec.env`, which controls what reaches the child.
8
+ //
9
+ // File enumeration defers to git — same as Turbo and Nx. We ask git for
10
+ // the file set via `git ls-files --cached --others --exclude-standard`,
11
+ // which gives us:
12
+ // - all tracked files,
13
+ // - plus untracked-but-not-ignored files,
14
+ // - with nested .gitignore + .git/info/exclude + global excludes
15
+ // correctly applied (because git already does the cascade).
16
+ // The user's `inputs.files` globs are then matched as a *filter* on
17
+ // top of that file set. vx requires git to be installed and the
18
+ // workspace to be a git work tree; non-git environments are not
19
+ // supported.
20
+
21
+ import path from 'node:path'
22
+ import { rm } from 'node:fs/promises'
23
+ import type { CacheInputs } from '../config.js'
24
+ import { UserError } from '../util/index.js'
25
+
26
+ // vx-lock.json is committed (so git enumerates it) but it's vx's own
27
+ // frozen-config metadata — never a task input. Excluded globally so a
28
+ // re-lock can't bust every cache key the way a tracked source file
29
+ // would. (Literal pattern, not the workspace `LOCKFILE_NAME` constant:
30
+ // cache is a leaf module and must not import from workspace.)
31
+ const ALWAYS_IGNORE = [
32
+ '**/node_modules/**',
33
+ '**/.git/**',
34
+ '**/.vx/**',
35
+ '**/*.tsbuildinfo',
36
+ '**/vx-lock.json',
37
+ // `bun build --compile` writes a transient `.<hash>-<n>.bun-build` intermediate
38
+ // in the cwd. It never rests on disk, so it can't be a real input — but a
39
+ // broad `inputs.files: ['**/*']` on a compile task would try to hash the temp
40
+ // file that a CONCURRENT compile is mid-write, racing to EACCES/ENOENT. Always
41
+ // exclude it (vx is Bun-native; compiling standalone binaries is a common task).
42
+ '**/*.bun-build',
43
+ ]
44
+
45
+ const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
46
+
47
+ export interface ResolvedInputs {
48
+ files: string[]
49
+ envValues: Array<[name: string, value: string]>
50
+ runtimeValues: Array<[command: string, output: string]>
51
+ workspaceRuntimeValues: Array<[command: string, output: string]>
52
+ }
53
+
54
+ export interface ResolveInputsArgs {
55
+ projectDir: string
56
+ workspaceRoot: string
57
+ envSource: NodeJS.ProcessEnv
58
+ inputs: CacheInputs | undefined
59
+ /** Project-relative output globs to exclude from inputs. */
60
+ ownOutputs: string[]
61
+ /** Root-relative `outputs.workspaceFiles` globs to exclude from
62
+ * `inputs.workspaceFiles` (a task cannot invalidate itself). */
63
+ ownWorkspaceOutputs?: string[]
64
+ /** Absolute dirs of nested projects (cross-boundary isolation). */
65
+ nestedProjectDirs: string[]
66
+ /**
67
+ * Per-run memo for `git ls-files` output. The same project's file
68
+ * list is asked for once per task (build + test + …) — without
69
+ * memoization we spawn git 3× per project per run. The orchestrator
70
+ * passes a fresh Map at the top of every `vx run`.
71
+ */
72
+ gitFilesCache?: GitFilesCache
73
+ /**
74
+ * Run-scoped memo for `cache.inputs.runtime` command execution, keyed
75
+ * by `projectDir + '\0' + command`. Shared across a run's tasks so a
76
+ * project's command runs once even across build/test/lint and across
77
+ * the hash + sandbox-baseline resolveInputs calls.
78
+ */
79
+ runtimeCache?: Map<string, Promise<string>>
80
+ /**
81
+ * Run-scoped memo for `cache.inputs.workspaceRuntime`, keyed by command
82
+ * only — global dedup so a root-level probe spawns once per run.
83
+ */
84
+ workspaceRuntimeCache?: Map<string, Promise<string>>
85
+ }
86
+
87
+ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedInputs> {
88
+ const projectFiles = await resolveFiles({
89
+ projectDir: args.projectDir,
90
+ workspaceRoot: args.workspaceRoot,
91
+ files: args.inputs?.files,
92
+ ownOutputs: args.ownOutputs,
93
+ nestedProjectDirs: args.nestedProjectDirs,
94
+ ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
95
+ })
96
+ let files = projectFiles
97
+ const wsDecl = args.inputs?.workspaceFiles
98
+ if (wsDecl !== undefined && wsDecl.length > 0) {
99
+ const wsFiles = await resolveWorkspaceFiles({
100
+ workspaceRoot: args.workspaceRoot,
101
+ workspaceFiles: wsDecl,
102
+ ownWorkspaceOutputs: args.ownWorkspaceOutputs ?? [],
103
+ ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
104
+ })
105
+ // Dedupe: when the project dir IS the workspace root (or a glob
106
+ // overlaps), the same absolute path can arrive via both lists —
107
+ // it must contribute to the key exactly once.
108
+ if (wsFiles.length > 0) files = [...new Set([...projectFiles, ...wsFiles])].sort()
109
+ }
110
+ const [runtimeValues, workspaceRuntimeValues] = await Promise.all([
111
+ resolveRuntimeValues(
112
+ args.inputs?.runtime ?? [],
113
+ args.projectDir,
114
+ args.runtimeCache,
115
+ `${args.projectDir}\0`,
116
+ ),
117
+ resolveRuntimeValues(
118
+ args.inputs?.workspaceRuntime ?? [],
119
+ args.workspaceRoot,
120
+ args.workspaceRuntimeCache,
121
+ '',
122
+ ),
123
+ ])
124
+ return {
125
+ files,
126
+ envValues: resolveEnvValues(args.inputs?.env ?? [], args.envSource),
127
+ runtimeValues,
128
+ workspaceRuntimeValues,
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Resolve `cache.inputs.workspaceFiles` — workspace-root-relative
134
+ * globs matched against the workspace-wide git file set (tracked +
135
+ * untracked-not-ignored, same visibility as project inputs).
136
+ *
137
+ * Deliberately NO project-boundary rule: a workspaceFiles glob may
138
+ * match files inside any project's directory. This is the documented
139
+ * escape hatch for root-level shared inputs (root tsconfig, shared
140
+ * codegen); the hard boundary continues to apply to project-relative
141
+ * `files` globs only.
142
+ */
143
+ async function resolveWorkspaceFiles(args: {
144
+ workspaceRoot: string
145
+ workspaceFiles: readonly string[]
146
+ ownWorkspaceOutputs: readonly string[]
147
+ gitFilesCache?: GitFilesCache
148
+ }): Promise<string[]> {
149
+ const positive: string[] = []
150
+ const negative: string[] = []
151
+ for (const entry of args.workspaceFiles) {
152
+ if (entry.startsWith('!')) negative.push(entry.slice(1))
153
+ else positive.push(entry)
154
+ }
155
+ if (positive.length === 0) return []
156
+
157
+ const excludeGlobs = [...ALWAYS_IGNORE, ...args.ownWorkspaceOutputs, ...negative].map(
158
+ (p) => new Bun.Glob(p),
159
+ )
160
+ const positiveGlobs = positive.map((p) => new Bun.Glob(p))
161
+ // Workspace-wide partition, keyed by the workspace root. Populated
162
+ // up-front by `populateGitFilesCache(..., workspaceWide: true)` when
163
+ // any loaded task declares workspaceFiles; a missing/invalidated
164
+ // partition re-spawns git at the root on demand.
165
+ let gitFiles = args.gitFilesCache?.snapshotFor(args.workspaceRoot, positiveGlobs)
166
+ if (gitFiles === undefined) {
167
+ gitFiles = runGitLsFiles(args.workspaceRoot).files
168
+ args.gitFilesCache?.set(args.workspaceRoot, gitFiles)
169
+ }
170
+ const candidates: string[] = []
171
+ for (const rel of gitFiles) {
172
+ if (!positiveGlobs.some((g) => g.match(rel))) continue
173
+ if (excludeGlobs.some((g) => g.match(rel))) continue
174
+ candidates.push(path.resolve(args.workspaceRoot, rel))
175
+ }
176
+ // Same OID-trust shortcut as project files: a clean-per-status
177
+ // tracked file necessarily exists on disk.
178
+ const oids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
179
+ const exists = await Promise.all(
180
+ candidates.map((abs) => oids?.has(abs) === true || Bun.file(abs).exists()),
181
+ )
182
+ const matches: string[] = []
183
+ for (let i = 0; i < candidates.length; i++) {
184
+ if (exists[i]) matches.push(candidates[i]!)
185
+ }
186
+ return matches.sort()
187
+ }
188
+
189
+ function resolveEnvValues(
190
+ names: readonly string[],
191
+ source: NodeJS.ProcessEnv,
192
+ ): Array<[string, string]> {
193
+ return [...names].sort().map((name) => [name, source[name] ?? ''] as [string, string])
194
+ }
195
+
196
+ /**
197
+ * Run one runtime-input command via `sh -c` (so pipelines / redirects
198
+ * work — "shell is the API"). Returns trimmed stdout+stderr. A non-zero
199
+ * exit is a hard UserError naming the command (fail-loud, like git).
200
+ */
201
+ async function runRuntimeCommand(command: string, cwd: string): Promise<string> {
202
+ let proc
203
+ try {
204
+ proc = Bun.spawn(['sh', '-c', command], {
205
+ cwd,
206
+ stdin: 'ignore',
207
+ stdout: 'pipe',
208
+ stderr: 'pipe',
209
+ })
210
+ } catch {
211
+ throw new UserError(`cache.inputs runtime command failed to spawn: ${command} (cwd: ${cwd})`)
212
+ }
213
+ const [stdout, stderr, exitCode] = await Promise.all([
214
+ new Response(proc.stdout).text(),
215
+ new Response(proc.stderr).text(),
216
+ proc.exited,
217
+ ])
218
+ const output = `${stdout}${stderr}`.trim()
219
+ if (exitCode !== 0) {
220
+ throw new UserError(
221
+ `cache.inputs runtime command exited ${exitCode}: ${command} (cwd: ${cwd})` +
222
+ (output ? `\n${output}` : ''),
223
+ )
224
+ }
225
+ return output
226
+ }
227
+
228
+ /**
229
+ * Resolve a list of runtime-input commands to sorted [command, output]
230
+ * pairs. Dedups via the shared `memo` (Promise per key): the first
231
+ * caller fires the spawn, concurrent callers await the same promise.
232
+ * `memoKeyPrefix` namespaces project (`projectDir + '\0'`) vs workspace
233
+ * (`''`) so the two scopes never collide in one map (they're separate
234
+ * maps anyway, but the prefix keeps intent explicit). Distinct commands
235
+ * run concurrently via Promise.all.
236
+ */
237
+ async function resolveRuntimeValues(
238
+ commands: readonly string[],
239
+ cwd: string,
240
+ memo: Map<string, Promise<string>> | undefined,
241
+ memoKeyPrefix: string,
242
+ ): Promise<Array<[string, string]>> {
243
+ if (commands.length === 0) return []
244
+ const unique = [...new Set(commands)].sort()
245
+ return Promise.all(
246
+ unique.map(async (cmd) => {
247
+ const key = `${memoKeyPrefix}${cmd}`
248
+ let p = memo?.get(key)
249
+ if (p === undefined) {
250
+ p = runRuntimeCommand(cmd, cwd)
251
+ memo?.set(key, p)
252
+ }
253
+ return [cmd, await p] as [string, string]
254
+ }),
255
+ )
256
+ }
257
+
258
+ /** Resolve declared output globs (project-relative) to actual produced files. */
259
+ export async function resolveOutputs(args: {
260
+ projectDir: string
261
+ outputs: string[]
262
+ nestedProjectDirs: string[]
263
+ }): Promise<string[]> {
264
+ if (args.outputs.length === 0) return []
265
+ const excludeGlobs = boundaryIgnorePatterns(args.projectDir, args.nestedProjectDirs).map(
266
+ (p) => new Bun.Glob(p),
267
+ )
268
+ return [...(await scanUnion(args.outputs, excludeGlobs, args.projectDir))].sort()
269
+ }
270
+
271
+ /**
272
+ * Remove every file currently matching the declared output globs in
273
+ * the project dir. Called both before a cache-hit restore (so the
274
+ * restore lands on a clean slate, matching the cached snapshot bit-
275
+ * for-bit) and before a cache-miss exec (so the task's output dir
276
+ * doesn't carry stale stragglers from a prior run).
277
+ *
278
+ * Globs are evaluated against the *current* tree. Files in declared
279
+ * output paths that the user dropped by hand will be removed — that's
280
+ * the contract of declaring something as an output. Nested-project
281
+ * dirs are excluded the same way `resolveOutputs` does, so we never
282
+ * cross a project boundary.
283
+ */
284
+ export async function cleanOutputs(args: {
285
+ projectDir: string
286
+ outputs: string[]
287
+ nestedProjectDirs: string[]
288
+ }): Promise<string[]> {
289
+ const files = await resolveOutputs(args)
290
+ // `force: true` makes rm tolerate ENOENT (e.g. when two output
291
+ // globs overlap and a sibling already deleted a path mid-iteration).
292
+ await Promise.all(files.map((f) => rm(f, { force: true })))
293
+ // Project-relative posix paths of what was removed — the caller
294
+ // feeds these to GitFilesCache.markOutputsChanged after a restore.
295
+ return files.map((f) => path.relative(args.projectDir, f).split(path.sep).join('/'))
296
+ }
297
+
298
+ /**
299
+ * Resolve declared `outputs.workspaceFiles` globs (workspace-root-
300
+ * relative) to actual produced files. Live-FS glob like
301
+ * `resolveOutputs`, anchored at the workspace root — and deliberately
302
+ * with NO project-dir exclusion: workspace outputs are the documented
303
+ * boundary escape hatch.
304
+ */
305
+ export async function resolveWorkspaceOutputs(args: {
306
+ workspaceRoot: string
307
+ outputs: string[]
308
+ }): Promise<string[]> {
309
+ if (args.outputs.length === 0) return []
310
+ return [...(await scanUnion(args.outputs, [], args.workspaceRoot))].sort()
311
+ }
312
+
313
+ /**
314
+ * `cleanOutputs` for the workspace-output namespace: wipe every file
315
+ * currently matching the declared root-relative globs. Same contract
316
+ * (clean slate before restore AND before exec), same caching gate at
317
+ * the call site. Returns root-relative posix paths of what was
318
+ * removed — the caller feeds these to
319
+ * `GitFilesCache.markWorkspaceOutputsChanged`.
320
+ */
321
+ export async function cleanWorkspaceOutputs(args: {
322
+ workspaceRoot: string
323
+ outputs: string[]
324
+ }): Promise<string[]> {
325
+ const files = await resolveWorkspaceOutputs(args)
326
+ await Promise.all(files.map((f) => rm(f, { force: true })))
327
+ return files.map((f) => path.relative(args.workspaceRoot, f).split(path.sep).join('/'))
328
+ }
329
+
330
+ /**
331
+ * Per-run memo of each project's `git ls-files` output, plus the
332
+ * staleness bookkeeping that lets the warm path avoid re-spawning git.
333
+ *
334
+ * After a cache-hit restore we know EXACTLY which paths changed on
335
+ * disk: the declared outputs `cleanOutputs` wiped plus the artifact's
336
+ * output files. `markOutputsChanged` records them; `snapshotFor`
337
+ * hands back the existing snapshot when a resolving task's input
338
+ * globs can't match any changed path — provably identical to what a
339
+ * re-spawn would return, since glob matching ignores gitignore status
340
+ * entirely when the path doesn't match. When globs DO overlap,
341
+ * returning undefined forces the caller down the re-spawn path so
342
+ * gitignore semantics stay byte-identical.
343
+ *
344
+ * The cache-miss save path still uses plain `delete` — an executed
345
+ * task may write files outside its declared outputs, and only git can
346
+ * see those.
347
+ */
348
+ export class GitFilesCache extends Map<string, readonly string[]> {
349
+ private changed = new Map<string, string[]>()
350
+ /**
351
+ * Per-project trusted index OIDs: absolute path → git blob OID, for
352
+ * tracked regular files whose working-tree state matched the index
353
+ * at populate time (per one `git status --porcelain` snapshot).
354
+ * These feed `Cache.key` via `CacheKeyInput.fileHashes` so a clean
355
+ * tree derives input hashes with zero reads / stats / SQLite.
356
+ *
357
+ * Dropped wholesale on `set` / `delete`: a mid-run re-enumeration
358
+ * proves the project's tree changed, and index OIDs can't be
359
+ * re-trusted without a fresh status — the per-file fallback
360
+ * (`Cache.hashFile`) computes the identical blob OID from disk, so
361
+ * dropping is a pure perf concession, never a correctness one.
362
+ */
363
+ private oids = new Map<string, Map<string, string>>()
364
+ /**
365
+ * Set when `populateGitFilesCache` stored a WORKSPACE-WIDE partition
366
+ * (any loaded task declares `inputs.workspaceFiles`). Null when the
367
+ * feature is unused — every workspace-partition hook below is then a
368
+ * no-op, so unused-feature behavior stays byte-identical.
369
+ */
370
+ private wsRoot: string | null = null
371
+ /**
372
+ * Whether the worktree had any uncommitted changes at populate time,
373
+ * derived from the SAME `git status --porcelain` spawn that prunes
374
+ * dirty paths from the trusted-OID set. Lets the Tier-3 invocation
375
+ * record report `dirty` without a SECOND status spawn. `null` until
376
+ * populate runs, or when the status spawn failed (non-repo).
377
+ */
378
+ private dirty: boolean | null = null
379
+
380
+ setWorkspaceRoot(root: string): void {
381
+ this.wsRoot = root
382
+ }
383
+
384
+ /** Aggregate worktree dirtiness from the populate-time status spawn. */
385
+ get worktreeDirty(): boolean | null {
386
+ return this.dirty
387
+ }
388
+
389
+ setWorktreeDirty(dirty: boolean | null): void {
390
+ this.dirty = dirty
391
+ }
392
+
393
+ markOutputsChanged(projectDir: string, relPaths: readonly string[]): void {
394
+ this.recordChanged(projectDir, relPaths)
395
+ // The workspace-wide partition sees the same files under
396
+ // root-relative names; forward so a downstream workspaceFiles
397
+ // task can't reuse a snapshot its globs could now contradict.
398
+ if (this.wsRoot !== null && this.wsRoot !== projectDir) {
399
+ this.recordChanged(
400
+ this.wsRoot,
401
+ relPaths.map((rel) =>
402
+ path.relative(this.wsRoot!, path.resolve(projectDir, rel)).split(path.sep).join('/'),
403
+ ),
404
+ )
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Record root-anchored changed paths (cleaned/restored workspace
410
+ * outputs) against EVERY partition that can see them: the workspace
411
+ * partition under their root-relative names, and any project
412
+ * partition whose dir contains them (workspace outputs may land
413
+ * inside other projects' dirs — the no-boundary escape hatch).
414
+ */
415
+ markWorkspaceOutputsChanged(workspaceRoot: string, relPaths: readonly string[]): void {
416
+ if (relPaths.length === 0) return
417
+ for (const key of this.keys()) {
418
+ if (key === workspaceRoot) {
419
+ this.recordChanged(key, relPaths)
420
+ continue
421
+ }
422
+ const under: string[] = []
423
+ for (const rel of relPaths) {
424
+ const abs = path.resolve(workspaceRoot, rel)
425
+ if (abs.startsWith(key + path.sep)) {
426
+ under.push(path.relative(key, abs).split(path.sep).join('/'))
427
+ }
428
+ }
429
+ if (under.length > 0) this.recordChanged(key, under)
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Drop the workspace-wide partition (if one exists). Called after a
435
+ * cache-miss save — an executed task may have written undeclared
436
+ * files anywhere in its project dir, and the workspace partition
437
+ * spans that subtree; only a fresh enumeration can see them. No-op
438
+ * when the feature is unused.
439
+ */
440
+ invalidateWorkspacePartition(): void {
441
+ if (this.wsRoot !== null) this.delete(this.wsRoot)
442
+ }
443
+
444
+ private recordChanged(partitionDir: string, relPaths: readonly string[]): void {
445
+ if (!this.has(partitionDir)) return
446
+ const cur = this.changed.get(partitionDir)
447
+ if (cur) cur.push(...relPaths)
448
+ else this.changed.set(partitionDir, [...relPaths])
449
+ // These paths just changed on disk; their index OIDs (if any) no
450
+ // longer describe the working-tree content.
451
+ const partitionOids = this.oids.get(partitionDir)
452
+ if (partitionOids) {
453
+ for (const rel of relPaths) partitionOids.delete(path.resolve(partitionDir, rel))
454
+ }
455
+ }
456
+
457
+ /** Trusted index OIDs for a project (abs path → oid), if any survive. */
458
+ oidsFor(projectDir: string): ReadonlyMap<string, string> | undefined {
459
+ return this.oids.get(projectDir)
460
+ }
461
+
462
+ setOids(projectDir: string, oids: Map<string, string>): void {
463
+ this.oids.set(projectDir, oids)
464
+ }
465
+
466
+ /** Snapshot if still valid for these input globs; undefined → re-spawn. */
467
+ snapshotFor(projectDir: string, inputGlobs: readonly Bun.Glob[]): readonly string[] | undefined {
468
+ const snap = this.get(projectDir)
469
+ if (snap === undefined) return undefined
470
+ const pending = this.changed.get(projectDir)
471
+ if (pending !== undefined && pending.some((p) => inputGlobs.some((g) => g.match(p)))) {
472
+ return undefined
473
+ }
474
+ return snap
475
+ }
476
+
477
+ override set(key: string, value: readonly string[]): this {
478
+ this.changed.delete(key)
479
+ this.oids.delete(key)
480
+ return super.set(key, value)
481
+ }
482
+
483
+ override delete(key: string): boolean {
484
+ this.changed.delete(key)
485
+ this.oids.delete(key)
486
+ return super.delete(key)
487
+ }
488
+ }
489
+
490
+ interface ResolveFilesArgs {
491
+ projectDir: string
492
+ workspaceRoot: string
493
+ files: string[] | undefined
494
+ ownOutputs: string[]
495
+ nestedProjectDirs: string[]
496
+ gitFilesCache?: GitFilesCache
497
+ }
498
+
499
+ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
500
+ const positive: string[] = []
501
+ const negative: string[] = []
502
+
503
+ if (args.files === undefined) {
504
+ positive.push(...DEFAULT_FILE_GLOBS)
505
+ } else {
506
+ for (const entry of args.files) {
507
+ if (entry.startsWith('!')) negative.push(entry.slice(1))
508
+ else positive.push(entry)
509
+ }
510
+ }
511
+
512
+ if (positive.length === 0) return []
513
+
514
+ const boundaryIgnores = boundaryIgnorePatterns(args.projectDir, args.nestedProjectDirs)
515
+ const excludeGlobs = [...ALWAYS_IGNORE, ...boundaryIgnores, ...args.ownOutputs, ...negative].map(
516
+ (p) => new Bun.Glob(p),
517
+ )
518
+
519
+ // Defer to git for the file set (Turbo / Nx parity). Nested .gitignore
520
+ // files, .git/info/exclude, and global excludes all participate
521
+ // correctly because git applies the cascade for us.
522
+ //
523
+ // Per-run memo: each project's git ls-files output is asked for once
524
+ // per task (build + test + lint + …). Spawning git N times for the
525
+ // same project per run is wasteful; we cache the result for the
526
+ // duration of one orchestrator run.
527
+ const positiveGlobs = positive.map((p) => new Bun.Glob(p))
528
+ let gitFiles = args.gitFilesCache?.snapshotFor(args.projectDir, positiveGlobs)
529
+ if (gitFiles === undefined) {
530
+ // Mid-run re-enumeration. The OIDs this spawn could yield are NOT
531
+ // trusted (no fresh `git status` to vouch for them — the project's
532
+ // tree just changed); set() drops the project's OID slot and these
533
+ // files fall back to Cache.hashFile, which computes the identical
534
+ // blob OID from disk.
535
+ gitFiles = runGitLsFiles(args.projectDir).files
536
+ // set() also clears the project's pending-changed bookkeeping.
537
+ args.gitFilesCache?.set(args.projectDir, gitFiles)
538
+ }
539
+ // First pass: glob-filter to candidate absolute paths (no I/O).
540
+ const candidates: string[] = []
541
+ for (const rel of gitFiles) {
542
+ let matched = false
543
+ for (const g of positiveGlobs) {
544
+ if (g.match(rel)) {
545
+ matched = true
546
+ break
547
+ }
548
+ }
549
+ if (!matched) continue
550
+ if (excludeGlobs.some((g) => g.match(rel))) continue
551
+ candidates.push(path.resolve(args.projectDir, rel))
552
+ }
553
+ // Second pass: parallel existence check — but ONLY for paths
554
+ // without a trusted index OID. A clean-per-status tracked file
555
+ // necessarily exists on disk, so skipping its probe keeps the warm
556
+ // path free of per-file syscalls. Paths without an OID keep the
557
+ // probe: `git ls-files -s` can surface staged entries whose
558
+ // working-tree file is gone; the hasher would otherwise throw
559
+ // ENOENT.
560
+ const oids = args.gitFilesCache?.oidsFor(args.projectDir)
561
+ const exists = await Promise.all(
562
+ candidates.map((abs) => oids?.has(abs) === true || Bun.file(abs).exists()),
563
+ )
564
+ const matches: string[] = []
565
+ for (let i = 0; i < candidates.length; i++) {
566
+ if (exists[i]) matches.push(candidates[i]!)
567
+ }
568
+ return matches.sort()
569
+ }
570
+
571
+ interface GitLsResult {
572
+ /** cwd-relative paths — same visibility set as `--cached --others`. */
573
+ files: string[]
574
+ /**
575
+ * cwd-relative path → index blob OID, for tracked REGULAR files
576
+ * (mode 100644 / 100755) at stage 0 only. Symlinks are excluded
577
+ * (their OID hashes the link-target string, not the dereferenced
578
+ * content our fallback hasher would read); merge-conflict stages
579
+ * and gitlinks are excluded too. NOT yet filtered by working-tree
580
+ * dirtiness — callers intersect with `git status` before trusting.
581
+ */
582
+ oids: Map<string, string>
583
+ }
584
+
585
+ // `<mode> <oid> <stage>\t<path>` — the staged-entry form of
586
+ // `ls-files -s`. `--others` paths print bare; with `-z`,
587
+ // core.quotePath quoting is off, so a bare path containing a literal
588
+ // tab still can't match this fixed-form prefix.
589
+ const LS_FILES_STAGE_RE = /^([0-7]{6}) ([0-9a-f]{40,64}) ([0-3])\t/
590
+
591
+ /**
592
+ * Run `git ls-files -s --others --exclude-standard -z .` in `cwd`.
593
+ * One spawn yields BOTH the file list (identical visibility to the
594
+ * pre-v20 `--cached --others` form, verified empirically: same set
595
+ * including staged-but-deleted files and per-stage conflict
596
+ * duplicates) AND each tracked file's index OID — the heart of the
597
+ * Turbo-parity "hashes come from git's index" fast path. Throws a
598
+ * `UserError` when git is unavailable or `cwd` isn't a git work
599
+ * tree; vx requires git. `-z` survives filenames with newlines /
600
+ * spaces.
601
+ */
602
+ function runGitLsFiles(cwd: string): GitLsResult {
603
+ let proc
604
+ try {
605
+ proc = Bun.spawnSync({
606
+ cmd: ['git', 'ls-files', '-s', '--others', '--exclude-standard', '-z', '.'],
607
+ cwd,
608
+ stdout: 'pipe',
609
+ stderr: 'pipe',
610
+ })
611
+ } catch {
612
+ throw new UserError(
613
+ `vx requires git: failed to spawn 'git' (working dir: ${cwd}). Install git and re-run.`,
614
+ )
615
+ }
616
+ if (proc.exitCode !== 0) {
617
+ // Exit 128 = not a git work tree; other non-zero = git failure.
618
+ // Either way we can't enumerate inputs reliably.
619
+ const stderr = new TextDecoder().decode(proc.stderr).trim()
620
+ throw new UserError(
621
+ `vx requires git: ${cwd} is not inside a git work tree. ` +
622
+ `Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
623
+ )
624
+ }
625
+ return parseLsFilesOutput(new TextDecoder().decode(proc.stdout))
626
+ }
627
+
628
+ function parseLsFilesOutput(out: string): GitLsResult {
629
+ const files: string[] = []
630
+ const oids = new Map<string, string>()
631
+ if (out.length === 0) return { files, oids }
632
+ // NUL-separated; trailing NUL produces an empty segment we skip.
633
+ for (const record of out.split('\0')) {
634
+ if (record.length === 0) continue
635
+ const m = LS_FILES_STAGE_RE.exec(record)
636
+ if (m === null) {
637
+ files.push(record) // --others entry: bare path
638
+ continue
639
+ }
640
+ const filePath = record.slice(m[0].length)
641
+ files.push(filePath)
642
+ const mode = m[1]!
643
+ const stage = m[3]!
644
+ if ((mode === '100644' || mode === '100755') && stage === '0') {
645
+ oids.set(filePath, m[2]!)
646
+ }
647
+ }
648
+ return { files, oids }
649
+ }
650
+
651
+ function parseStatusOutput(out: string): Set<string> {
652
+ const tokens = out.split('\0')
653
+ const dirty = new Set<string>()
654
+ for (let i = 0; i < tokens.length; i++) {
655
+ const token = tokens[i]!
656
+ if (token.length < 4) continue
657
+ dirty.add(token.slice(3))
658
+ // X = R or C → the next token is the rename/copy source path.
659
+ const x = token[0]
660
+ if ((x === 'R' || x === 'C') && i + 1 < tokens.length && tokens[i + 1]!.length > 0) {
661
+ i++
662
+ dirty.add(tokens[i]!)
663
+ }
664
+ }
665
+ return dirty
666
+ }
667
+
668
+ /**
669
+ * Run `git ls-files` ONCE at the workspace root, then partition the
670
+ * result by project. Populates `cache` for every project in
671
+ * `projectDirs` with project-relative path lists matching what a
672
+ * per-project spawn would have produced. Throws `UserError` if the
673
+ * workspace isn't a git work tree (vx requires git).
674
+ *
675
+ * Why bulk: each spawn costs ~5-10ms (fork+exec). On a 200-project
676
+ * workspace that's 1-2s of pure overhead reclaimed.
677
+ *
678
+ * Files in nested-project subtrees stay in their parent's list — the
679
+ * boundary-ignore globs in `resolveFiles` filter them out the same way
680
+ * they did before. Cheaper to filter once-per-task than to subtract
681
+ * here.
682
+ *
683
+ * v20: the same `ls-files -s` spawn also yields each tracked file's
684
+ * index OID. A second spawn (`git status --porcelain`) prunes paths
685
+ * whose working tree diverges from the index; what survives is
686
+ * stored per project via `cache.setOids` and feeds `Cache.key`
687
+ * directly — clean-tree input hashing costs zero reads/stats/SQLite.
688
+ *
689
+ * `workspaceWide`: set when any loaded task declares
690
+ * `cache.inputs.workspaceFiles`. Disables pathspec scoping (those
691
+ * globs must see every file from the root) and additionally stores a
692
+ * workspace-wide partition keyed by `workspaceRoot` (files + trusted
693
+ * OIDs), which `resolveWorkspaceFiles` consumes. When false, the
694
+ * enumeration behavior — pathspecs, spawn count, stored partitions —
695
+ * is byte-identical to the pre-workspaceFiles code.
696
+ */
697
+ export async function populateGitFilesCache(
698
+ workspaceRoot: string,
699
+ projectDirs: readonly string[],
700
+ cache: GitFilesCache,
701
+ workspaceWide = false,
702
+ ): Promise<void> {
703
+ // The two spawns are independent — run them concurrently so the
704
+ // bulk-populate costs max(ls-files, status) wall time, not the sum
705
+ // (status alone is ~74 ms on a 1000-project tree; serial spawning
706
+ // was a measurable warm-path regression vs the pre-OID code).
707
+ const spawnGit = async (args: string[]) => {
708
+ try {
709
+ const proc = Bun.spawn({
710
+ cmd: ['git', ...args],
711
+ cwd: workspaceRoot,
712
+ stdin: 'ignore',
713
+ stdout: 'pipe',
714
+ stderr: 'pipe',
715
+ })
716
+ const [stdout, stderr, exitCode] = await Promise.all([
717
+ new Response(proc.stdout).text(),
718
+ new Response(proc.stderr).text(),
719
+ proc.exited,
720
+ ])
721
+ return { exitCode, stdout, stderr }
722
+ } catch {
723
+ return null
724
+ }
725
+ }
726
+ // Pathspec scoping: when the run only needs a handful of projects
727
+ // (scoped config loading), let git scan just those dirs — 75 ms →
728
+ // 11 ms on an 11k-file repo. Above 64 dirs (or when a project IS
729
+ // the root) the whole-tree scan wins on arg/exec overhead anyway.
730
+ const rels = projectDirs.map((d) => path.relative(workspaceRoot, d).split(path.sep).join('/'))
731
+ const scoped =
732
+ !workspaceWide &&
733
+ rels.length > 0 &&
734
+ rels.length <= 64 &&
735
+ rels.every((r) => r !== '' && r !== '.')
736
+ const pathspecs = scoped ? rels : ['.']
737
+ const [ls, status] = await Promise.all([
738
+ spawnGit(['ls-files', '-s', '--others', '--exclude-standard', '-z', '--', ...pathspecs]),
739
+ spawnGit(['status', '--porcelain', '-z', '--', ...pathspecs]),
740
+ ])
741
+ if (ls === null) {
742
+ throw new UserError(
743
+ `vx requires git: failed to spawn 'git' (working dir: ${workspaceRoot}). Install git and re-run.`,
744
+ )
745
+ }
746
+ if (ls.exitCode !== 0) {
747
+ const stderr = ls.stderr.trim()
748
+ throw new UserError(
749
+ `vx requires git: ${workspaceRoot} is not inside a git work tree. ` +
750
+ `Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
751
+ )
752
+ }
753
+ const { files: all, oids } = parseLsFilesOutput(ls.stdout)
754
+ const dirty = status !== null && status.exitCode === 0 ? parseStatusOutput(status.stdout) : null
755
+ // Aggregate dirtiness for the Tier-3 invocation record — derived from
756
+ // this same status spawn so `run()` needs no second `git status`.
757
+ // null when the status spawn failed (non-repo / git error).
758
+ cache.setWorktreeDirty(dirty === null ? null : dirty.size > 0)
759
+ const trusted = dirty === null ? new Map<string, string>() : oids
760
+ if (dirty !== null) {
761
+ for (const rel of dirty) trusted.delete(rel)
762
+ }
763
+ // Sort once, then each project's files are a contiguous range found
764
+ // by binary search on its `dir/` prefix — O((F+P) log F) instead of
765
+ // the O(P·F) per-project startsWith scan (54 ms at 1090 projects ×
766
+ // ~9k files; ~5 ms this way). '/' sorts below most filename chars,
767
+ // so the range [prefix, prefix+'\xff…') is contiguous in the sorted
768
+ // array; lowerBound on `prefix` and on `prefix + '￿'` bracket it.
769
+ const sorted = [...all].sort()
770
+ const lowerBound = (key: string): number => {
771
+ let lo = 0
772
+ let hi = sorted.length
773
+ while (lo < hi) {
774
+ const mid = (lo + hi) >>> 1
775
+ if (sorted[mid]! < key) lo = mid + 1
776
+ else hi = mid
777
+ }
778
+ return lo
779
+ }
780
+ for (const projectDir of projectDirs) {
781
+ const relPrefix = path.relative(workspaceRoot, projectDir).split(path.sep).join('/')
782
+ if (relPrefix === '' || relPrefix === '.') {
783
+ cache.set(projectDir, all)
784
+ const rootOids = new Map<string, string>()
785
+ for (const [rel, oid] of trusted) rootOids.set(path.join(workspaceRoot, rel), oid)
786
+ cache.setOids(projectDir, rootOids)
787
+ continue
788
+ }
789
+ const prefix = `${relPrefix}/`
790
+ const start = lowerBound(prefix)
791
+ const end = lowerBound(`${prefix}￿`)
792
+ const matches: string[] = []
793
+ const projOids = new Map<string, string>()
794
+ for (let i = start; i < end; i++) {
795
+ const rel = sorted[i]!
796
+ matches.push(rel.slice(prefix.length))
797
+ const oid = trusted.get(rel)
798
+ if (oid !== undefined) projOids.set(path.join(workspaceRoot, rel), oid)
799
+ }
800
+ // setOids AFTER set — set() drops the project's OID slot.
801
+ cache.set(projectDir, matches)
802
+ cache.setOids(projectDir, projOids)
803
+ }
804
+ if (workspaceWide) {
805
+ const rootOids = new Map<string, string>()
806
+ for (const [rel, oid] of trusted) rootOids.set(path.join(workspaceRoot, rel), oid)
807
+ cache.set(workspaceRoot, all)
808
+ cache.setOids(workspaceRoot, rootOids)
809
+ cache.setWorkspaceRoot(workspaceRoot)
810
+ }
811
+ }
812
+
813
+ /**
814
+ * Union of files matching any positive pattern in `cwd`, minus files
815
+ * matching any exclude glob (tested by Bun.Glob.match on the relative
816
+ * path). Bun.Glob takes a single pattern per instance, so we iterate.
817
+ */
818
+ async function scanUnion(
819
+ positive: readonly string[],
820
+ excludeGlobs: readonly Bun.Glob[],
821
+ cwd: string,
822
+ ): Promise<Set<string>> {
823
+ const matches = new Set<string>()
824
+ for (const pattern of positive) {
825
+ const glob = new Bun.Glob(pattern)
826
+ for await (const rel of glob.scan({ cwd, onlyFiles: true, dot: true })) {
827
+ if (excludeGlobs.some((g) => g.match(rel))) continue
828
+ matches.add(path.resolve(cwd, rel))
829
+ }
830
+ }
831
+ return matches
832
+ }
833
+
834
+ function boundaryIgnorePatterns(projectDir: string, nestedDirs: string[]): string[] {
835
+ return nestedDirs.map((d) => {
836
+ const rel = path.relative(projectDir, d).split(path.sep).join('/')
837
+ return `${rel}/**`
838
+ })
839
+ }