@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.
- package/LICENSE +21 -0
- package/README.md +349 -1
- package/launcher.mjs +92 -0
- package/package.json +44 -3
- package/src/bin.ts +25 -0
- package/src/cache/cache.ts +1832 -0
- package/src/cache/cas-backend.ts +97 -0
- package/src/cache/digest.ts +47 -0
- package/src/cache/index.ts +28 -0
- package/src/cache/inputs.ts +839 -0
- package/src/cache/layered-cache.ts +355 -0
- package/src/cache/tar.ts +343 -0
- package/src/cli/backend.ts +23 -0
- package/src/cli/cache.ts +96 -0
- package/src/cli/format.ts +15 -0
- package/src/cli/help.ts +129 -0
- package/src/cli/index.ts +85 -0
- package/src/cli/info.ts +80 -0
- package/src/cli/lock.ts +135 -0
- package/src/cli/mcp-rpc.ts +292 -0
- package/src/cli/mcp.ts +92 -0
- package/src/cli/migrate-nx.ts +450 -0
- package/src/cli/migrate-turbo.ts +364 -0
- package/src/cli/migrate.ts +219 -0
- package/src/cli/plan-format.ts +148 -0
- package/src/cli/run.ts +683 -0
- package/src/cli/show.ts +201 -0
- package/src/cli/upgrade.ts +103 -0
- package/src/cli/watch.ts +287 -0
- package/src/config.ts +460 -0
- package/src/exec/env.ts +74 -0
- package/src/exec/index.ts +13 -0
- package/src/exec/runner.ts +470 -0
- package/src/exec/sandbox-runtime.ts +578 -0
- package/src/graph/dependency-spec.ts +85 -0
- package/src/graph/index.ts +27 -0
- package/src/graph/scheduler.ts +677 -0
- package/src/graph/task-graph.ts +377 -0
- package/src/index.ts +222 -0
- package/src/orchestrator/colors.ts +63 -0
- package/src/orchestrator/devframe-surface.ts +74 -0
- package/src/orchestrator/events.ts +277 -0
- package/src/orchestrator/execute-task.ts +939 -0
- package/src/orchestrator/framed-output.ts +461 -0
- package/src/orchestrator/history.ts +148 -0
- package/src/orchestrator/index.ts +174 -0
- package/src/orchestrator/local-shortcircuit.ts +147 -0
- package/src/orchestrator/logger.ts +526 -0
- package/src/orchestrator/metrics.ts +1927 -0
- package/src/orchestrator/options.ts +197 -0
- package/src/orchestrator/plan.ts +129 -0
- package/src/orchestrator/plugin-host.ts +185 -0
- package/src/orchestrator/plugin.ts +283 -0
- package/src/orchestrator/predict.ts +106 -0
- package/src/orchestrator/prepare.ts +306 -0
- package/src/orchestrator/protocol.ts +156 -0
- package/src/orchestrator/remote-prefetch.ts +81 -0
- package/src/orchestrator/resources.ts +59 -0
- package/src/orchestrator/run-artifacts.ts +111 -0
- package/src/orchestrator/run-context.ts +205 -0
- package/src/orchestrator/run-report.ts +132 -0
- package/src/orchestrator/run-state.ts +145 -0
- package/src/orchestrator/run.ts +887 -0
- package/src/orchestrator/stable-keys.ts +177 -0
- package/src/orchestrator/status-line.ts +287 -0
- package/src/orchestrator/summary.ts +315 -0
- package/src/orchestrator/tally.ts +69 -0
- package/src/orchestrator/task-hash.ts +253 -0
- package/src/orchestrator/telemetry-host.ts +80 -0
- package/src/orchestrator/telemetry.ts +358 -0
- package/src/orchestrator/upstream.ts +121 -0
- package/src/orchestrator/verify.ts +164 -0
- package/src/orchestrator/wire-render.ts +92 -0
- package/src/orchestrator/wire.ts +212 -0
- package/src/util/errors.ts +11 -0
- package/src/util/hash.ts +25 -0
- package/src/util/index.ts +8 -0
- package/src/util/paths.ts +16 -0
- package/src/util/size.ts +16 -0
- package/src/util/ulid.ts +15 -0
- package/src/version.ts +10 -0
- package/src/workspace/affected.ts +118 -0
- package/src/workspace/filter.ts +155 -0
- package/src/workspace/fingerprint.ts +41 -0
- package/src/workspace/index.ts +27 -0
- package/src/workspace/lockfile.ts +118 -0
- package/src/workspace/nested-dirs.ts +34 -0
- package/src/workspace/package-graph.ts +148 -0
- package/src/workspace/project-loader.ts +512 -0
- 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
|
+
}
|