@vzn/vx 0.0.0 → 0.0.12

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