@vzn/vx 0.0.16 → 0.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
package/src/cache/inputs.ts
CHANGED
|
@@ -6,22 +6,37 @@
|
|
|
6
6
|
// `cache.inputs.env` is the cache-tracking axis for env vars; it's
|
|
7
7
|
// independent of `exec.env`, which controls what reaches the child.
|
|
8
8
|
//
|
|
9
|
-
// File enumeration defers to git — same as Turbo and Nx.
|
|
10
|
-
// the
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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.
|
|
9
|
+
// File enumeration defers to git — same as Turbo and Nx. git-inputs.ts
|
|
10
|
+
// asks for the tracked set with `git ls-files -s -v` (index OIDs and the
|
|
11
|
+
// skip-worktree flag in the same spawn) and for the dirty and untracked
|
|
12
|
+
// paths with `git status -uall`, with nested .gitignore, .git/info/exclude
|
|
13
|
+
// and the global excludes applied because git already does the cascade.
|
|
14
|
+
// The user's `inputs.files` globs are then matched as a *filter* on top
|
|
15
|
+
// of that file set. vx requires git to be installed and the workspace to
|
|
16
|
+
// be a git work tree; non-git environments are not supported.
|
|
20
17
|
|
|
21
18
|
import path from 'node:path'
|
|
22
|
-
import {
|
|
19
|
+
import { lstatSync } from 'node:fs'
|
|
20
|
+
import { realpath, rm, rmdir } from 'node:fs/promises'
|
|
23
21
|
import type { CacheInputs } from '../config.js'
|
|
24
22
|
import { UserError } from '../util/index.js'
|
|
23
|
+
import { GitFilesCache, runGitLsFiles } from './git-inputs.js'
|
|
24
|
+
import { normalizeGlob } from '../util/index.js'
|
|
25
|
+
|
|
26
|
+
// The git side lives in git-inputs.ts; its whole public surface is
|
|
27
|
+
// re-exported here so a reader that reaches the resolver for it (the
|
|
28
|
+
// tests do, by deep import) keeps working.
|
|
29
|
+
export {
|
|
30
|
+
GitFilesCache,
|
|
31
|
+
populateGitFilesCache,
|
|
32
|
+
runGitLsFiles,
|
|
33
|
+
startGitEnumeration,
|
|
34
|
+
applyGitEnumeration,
|
|
35
|
+
gitPathspecs,
|
|
36
|
+
parseCheckAttrOutput,
|
|
37
|
+
autocrlfConverts,
|
|
38
|
+
type GitEnumeration,
|
|
39
|
+
} from './git-inputs.js'
|
|
25
40
|
|
|
26
41
|
// vx-lock.json is committed (so git enumerates it) but it's vx's own
|
|
27
42
|
// frozen-config metadata — never a task input. Excluded globally so a
|
|
@@ -74,7 +89,11 @@ export interface ResolveInputsArgs {
|
|
|
74
89
|
* Run-scoped memo for `cache.inputs.runtime` command execution, keyed
|
|
75
90
|
* by `projectDir + '\0' + command`. Shared across a run's tasks so a
|
|
76
91
|
* project's command runs once even across build/test/lint and across
|
|
77
|
-
* the hash + sandbox-baseline resolveInputs calls.
|
|
92
|
+
* the hash + sandbox-baseline resolveInputs calls. The key is sound
|
|
93
|
+
* ONLY because the probe runs in vx's own ambient environment, never a
|
|
94
|
+
* task's `exec.env` (`runRuntimeCommand`): two tasks with different
|
|
95
|
+
* `define`s share one value. Threading the task env in would have to
|
|
96
|
+
* widen this key too, or task A's probe becomes task B's key component.
|
|
78
97
|
*/
|
|
79
98
|
runtimeCache?: Map<string, Promise<string>>
|
|
80
99
|
/**
|
|
@@ -82,8 +101,21 @@ export interface ResolveInputsArgs {
|
|
|
82
101
|
* only — global dedup so a root-level probe spawns once per run.
|
|
83
102
|
*/
|
|
84
103
|
workspaceRuntimeCache?: Map<string, Promise<string>>
|
|
104
|
+
/**
|
|
105
|
+
* Run-scoped memo for `cache.inputs.workspaceFiles`, keyed by the
|
|
106
|
+
* declaration and valid for one enumeration snapshot. A Turbo-mapped
|
|
107
|
+
* workspace gives every task the same `globalDependencies`, and
|
|
108
|
+
* resolving one literal against medusa's 24k-file enumeration per task
|
|
109
|
+
* was 930 ms of a 3.0 s warm no-op (159 scans, 2026-09-11).
|
|
110
|
+
*/
|
|
111
|
+
workspaceFilesCache?: WorkspaceFilesCache
|
|
85
112
|
}
|
|
86
113
|
|
|
114
|
+
export type WorkspaceFilesCache = Map<
|
|
115
|
+
string,
|
|
116
|
+
{ snapshot: readonly string[]; result: Promise<string[]> }
|
|
117
|
+
>
|
|
118
|
+
|
|
87
119
|
export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedInputs> {
|
|
88
120
|
const projectFiles = await resolveFiles({
|
|
89
121
|
projectDir: args.projectDir,
|
|
@@ -101,26 +133,29 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
|
|
|
101
133
|
workspaceFiles: wsDecl,
|
|
102
134
|
ownWorkspaceOutputs: args.ownWorkspaceOutputs ?? [],
|
|
103
135
|
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
136
|
+
...(args.workspaceFilesCache !== undefined ? { memo: args.workspaceFilesCache } : {}),
|
|
104
137
|
})
|
|
105
138
|
// Dedupe: when the project dir IS the workspace root (or a glob
|
|
106
139
|
// overlaps), the same absolute path can arrive via both lists —
|
|
107
140
|
// it must contribute to the key exactly once.
|
|
108
141
|
if (wsFiles.length > 0) files = [...new Set([...projectFiles, ...wsFiles])].sort()
|
|
109
142
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
143
|
+
// Two awaited empty resolutions per task were ~3 ms of a 1000-task warm
|
|
144
|
+
// run (profiled 2026-09-09); a task declaring neither skips the fan-out.
|
|
145
|
+
const runtimeDecl = args.inputs?.runtime ?? []
|
|
146
|
+
const wsRuntimeDecl = args.inputs?.workspaceRuntime ?? []
|
|
147
|
+
const [runtimeValues, workspaceRuntimeValues] =
|
|
148
|
+
runtimeDecl.length === 0 && wsRuntimeDecl.length === 0
|
|
149
|
+
? [[], []]
|
|
150
|
+
: await Promise.all([
|
|
151
|
+
resolveRuntimeValues(
|
|
152
|
+
runtimeDecl,
|
|
153
|
+
args.projectDir,
|
|
154
|
+
args.runtimeCache,
|
|
155
|
+
`${args.projectDir}\0`,
|
|
156
|
+
),
|
|
157
|
+
resolveRuntimeValues(wsRuntimeDecl, args.workspaceRoot, args.workspaceRuntimeCache, ''),
|
|
158
|
+
])
|
|
124
159
|
return {
|
|
125
160
|
files,
|
|
126
161
|
envValues: resolveEnvValues(args.inputs?.env ?? [], args.envSource),
|
|
@@ -145,6 +180,7 @@ async function resolveWorkspaceFiles(args: {
|
|
|
145
180
|
workspaceFiles: readonly string[]
|
|
146
181
|
ownWorkspaceOutputs: readonly string[]
|
|
147
182
|
gitFilesCache?: GitFilesCache
|
|
183
|
+
memo?: WorkspaceFilesCache
|
|
148
184
|
}): Promise<string[]> {
|
|
149
185
|
const positive: string[] = []
|
|
150
186
|
const negative: string[] = []
|
|
@@ -154,10 +190,12 @@ async function resolveWorkspaceFiles(args: {
|
|
|
154
190
|
}
|
|
155
191
|
if (positive.length === 0) return []
|
|
156
192
|
|
|
157
|
-
const excludeGlobs = [
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
193
|
+
const excludeGlobs = [
|
|
194
|
+
...ALWAYS_IGNORE,
|
|
195
|
+
...asTrees(args.ownWorkspaceOutputs),
|
|
196
|
+
...asTrees(negative),
|
|
197
|
+
].map(globFor)
|
|
198
|
+
const positiveGlobs = asTrees(positive).map(globFor)
|
|
161
199
|
// Workspace-wide partition, keyed by the workspace root. Populated
|
|
162
200
|
// up-front by `populateGitFilesCache(..., workspaceWide: true)` when
|
|
163
201
|
// any loaded task declares workspaceFiles; a missing/invalidated
|
|
@@ -167,23 +205,70 @@ async function resolveWorkspaceFiles(args: {
|
|
|
167
205
|
gitFiles = runGitLsFiles(args.workspaceRoot).files
|
|
168
206
|
args.gitFilesCache?.set(args.workspaceRoot, gitFiles)
|
|
169
207
|
}
|
|
208
|
+
// The memo is valid for the snapshot it was computed over: a task that
|
|
209
|
+
// wrote workspace outputs mid-run replaces the partition, and the next
|
|
210
|
+
// caller sees a different array and scans again.
|
|
211
|
+
const memoKey =
|
|
212
|
+
args.memo === undefined
|
|
213
|
+
? undefined
|
|
214
|
+
: JSON.stringify([positive, negative, args.ownWorkspaceOutputs])
|
|
215
|
+
if (memoKey !== undefined) {
|
|
216
|
+
const hit = args.memo!.get(memoKey)
|
|
217
|
+
if (hit !== undefined && hit.snapshot === gitFiles) return hit.result
|
|
218
|
+
}
|
|
219
|
+
const result = resolveWorkspaceFilesOver(args, gitFiles, positive, positiveGlobs, excludeGlobs)
|
|
220
|
+
if (memoKey !== undefined) args.memo!.set(memoKey, { snapshot: gitFiles, result })
|
|
221
|
+
return result
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function resolveWorkspaceFilesOver(
|
|
225
|
+
args: { workspaceRoot: string; gitFilesCache?: GitFilesCache },
|
|
226
|
+
gitFiles: readonly string[],
|
|
227
|
+
positive: readonly string[],
|
|
228
|
+
positiveGlobs: readonly Bun.Glob[],
|
|
229
|
+
excludeGlobs: readonly Bun.Glob[],
|
|
230
|
+
): Promise<string[]> {
|
|
231
|
+
// Second call site of the literal-input guard. `resolveWorkspaceFiles`
|
|
232
|
+
// carries its own copy of the filter-over-git-set design, so the same
|
|
233
|
+
// silently-folds-nothing hazard exists here — and a fix applied only to the
|
|
234
|
+
// project half would pass that half's tests while leaving this one live.
|
|
235
|
+
const unmatchedLiterals = new Set(
|
|
236
|
+
positive.map(normalizeGlob).filter(isLiteralPath).map(stripTrailingSlash),
|
|
237
|
+
)
|
|
170
238
|
const candidates: string[] = []
|
|
171
239
|
for (const rel of gitFiles) {
|
|
240
|
+
if (unmatchedLiterals.size > 0) settleLiterals(unmatchedLiterals, rel)
|
|
172
241
|
if (!positiveGlobs.some((g) => g.match(rel))) continue
|
|
173
242
|
if (excludeGlobs.some((g) => g.match(rel))) continue
|
|
174
243
|
candidates.push(path.resolve(args.workspaceRoot, rel))
|
|
175
244
|
}
|
|
245
|
+
if (unmatchedLiterals.size > 0) {
|
|
246
|
+
await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.workspaceRoot, 'workspaceFiles')
|
|
247
|
+
}
|
|
176
248
|
// Same OID-trust shortcut as project files: a clean-per-status
|
|
177
249
|
// tracked file necessarily exists on disk.
|
|
178
250
|
const oids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
251
|
+
return candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* What an enumerated path must be to count as an input: a regular file, or
|
|
256
|
+
* a symlink (to anything, or to nothing — its target STRING is what folds,
|
|
257
|
+
* as in git). A directory is not one: git lists a gitlink (a submodule) at
|
|
258
|
+
* its path and the glob `**\/*` matches it, and the hasher has nothing to
|
|
259
|
+
* read there. `Bun.file(p).exists()` answered false for every symlink to a
|
|
260
|
+
* directory and every dangling link too, which silently dropped a tracked
|
|
261
|
+
* link from the key — retargeting it was a stale hit. lstat, synchronously:
|
|
262
|
+
* the paths that reach this probe are the ones without a trusted index OID
|
|
263
|
+
* (untracked and dirty files), a handful on a warm run.
|
|
264
|
+
*/
|
|
265
|
+
function isInputOnDisk(abs: string): boolean {
|
|
266
|
+
try {
|
|
267
|
+
const st = lstatSync(abs)
|
|
268
|
+
return st.isFile() || st.isSymbolicLink()
|
|
269
|
+
} catch {
|
|
270
|
+
return false
|
|
185
271
|
}
|
|
186
|
-
return matches.sort()
|
|
187
272
|
}
|
|
188
273
|
|
|
189
274
|
function resolveEnvValues(
|
|
@@ -197,6 +282,11 @@ function resolveEnvValues(
|
|
|
197
282
|
* Run one runtime-input command via `sh -c` (so pipelines / redirects
|
|
198
283
|
* work — "shell is the API"). Returns trimmed stdout+stderr. A non-zero
|
|
199
284
|
* exit is a hard UserError naming the command (fail-loud, like git).
|
|
285
|
+
*
|
|
286
|
+
* The probe inherits vx's AMBIENT environment on purpose — not the task's
|
|
287
|
+
* `exec.env.define` / `passThrough`, which describe the command's
|
|
288
|
+
* environment, not the machine's. That is what lets the run-scoped memo
|
|
289
|
+
* key on (projectDir, command) alone; see `runtimeCache`.
|
|
200
290
|
*/
|
|
201
291
|
async function runRuntimeCommand(command: string, cwd: string): Promise<string> {
|
|
202
292
|
let proc
|
|
@@ -262,10 +352,70 @@ export async function resolveOutputs(args: {
|
|
|
262
352
|
nestedProjectDirs: string[]
|
|
263
353
|
}): Promise<string[]> {
|
|
264
354
|
if (args.outputs.length === 0) return []
|
|
265
|
-
const excludeGlobs = boundaryIgnorePatterns(args.projectDir, args.nestedProjectDirs).map(
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
355
|
+
const excludeGlobs = boundaryIgnorePatterns(args.projectDir, args.nestedProjectDirs).map(globFor)
|
|
356
|
+
const scanned = [
|
|
357
|
+
...(await scanUnion(asTrees(args.outputs), excludeGlobs, args.projectDir, 'outputs')),
|
|
358
|
+
]
|
|
359
|
+
// Containment, enforced HERE and not only at the loader. `cleanOutputs`
|
|
360
|
+
// DELETES whatever this returns, and `Bun.Glob.scan` happily walks `..` out
|
|
361
|
+
// of its cwd — so the loader's `..`/absolute rejection alone was a single
|
|
362
|
+
// point of failure. The resolver that feeds the delete refuses to name a path
|
|
363
|
+
// outside the project, so any future caller reaching it by another route (a
|
|
364
|
+
// programmatic embedder, a config source that skips the loader) is contained
|
|
365
|
+
// by construction.
|
|
366
|
+
return (await containedIn(args.projectDir, scanned)).sort()
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Keep only the paths that are REALLY inside `root` — lexically, and after
|
|
371
|
+
* resolving symlinks.
|
|
372
|
+
*
|
|
373
|
+
* Lexical alone was sufficient only while `Bun.Glob.scan` refused to descend
|
|
374
|
+
* into symlinked directories, which this repo pinned as a deliberate tripwire
|
|
375
|
+
* on a DEPENDENCY's behaviour. **Bun 1.4.0 tripped it**: with `dist ->
|
|
376
|
+
* ../victim` the scan now yields `dist/precious.txt`, a path that is lexically
|
|
377
|
+
* inside the project while the file it names is not — and the caller rm()s
|
|
378
|
+
* whatever this returns. Measured on 1.4.0 before this guard: a plain
|
|
379
|
+
* `outputs.files: ['dist/**']` deleted a file outside the project.
|
|
380
|
+
*
|
|
381
|
+
* DIRECTORIES are what matter, not files: `rm` on a symlinked FILE unlinks the
|
|
382
|
+
* link and never its target, so a link sitting inside a real output directory
|
|
383
|
+
* is harmless. Resolving per directory also keeps this cheap — one syscall per
|
|
384
|
+
* distinct output directory rather than per output file, concurrently, and a
|
|
385
|
+
* `dist/**` of ten thousand files in one directory costs exactly one.
|
|
386
|
+
*
|
|
387
|
+
* A path whose directory will not resolve (a broken link, or a race with the
|
|
388
|
+
* task that produced it) is REFUSED. When the caller deletes, unresolvable
|
|
389
|
+
* means leave it alone.
|
|
390
|
+
*/
|
|
391
|
+
async function containedIn(root: string, paths: readonly string[]): Promise<string[]> {
|
|
392
|
+
// One `path.dirname` per path, kept alongside — computing it again in the
|
|
393
|
+
// final filter measured as the DOMINANT added cost on a wide output tree
|
|
394
|
+
// (string work, not syscalls: 10k files in 20 dirs cost more than 5k files
|
|
395
|
+
// in 200, which is the wrong shape for a per-directory probe).
|
|
396
|
+
const lexical: string[] = []
|
|
397
|
+
const lexDirs: string[] = []
|
|
398
|
+
for (const p of paths) {
|
|
399
|
+
if (!isInside(root, p)) continue
|
|
400
|
+
lexical.push(p)
|
|
401
|
+
lexDirs.push(path.dirname(p))
|
|
402
|
+
}
|
|
403
|
+
if (lexical.length === 0) return []
|
|
404
|
+
const realRoot = await realpath(root).catch(() => root)
|
|
405
|
+
const uniqueDirs = [...new Set(lexDirs)]
|
|
406
|
+
const resolved = await Promise.all(uniqueDirs.map((d) => realpath(d).catch(() => null)))
|
|
407
|
+
const contained = new Set<string>()
|
|
408
|
+
for (const [i, dir] of uniqueDirs.entries()) {
|
|
409
|
+
const real = resolved[i]
|
|
410
|
+
if (real !== null && real !== undefined && isInside(realRoot, real)) contained.add(dir)
|
|
411
|
+
}
|
|
412
|
+
return lexical.filter((_p, i) => contained.has(lexDirs[i]!))
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Is `abs` the directory `dir` itself or something beneath it? */
|
|
416
|
+
function isInside(dir: string, abs: string): boolean {
|
|
417
|
+
if (abs === dir) return true
|
|
418
|
+
return abs.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep)
|
|
269
419
|
}
|
|
270
420
|
|
|
271
421
|
/**
|
|
@@ -289,12 +439,43 @@ export async function cleanOutputs(args: {
|
|
|
289
439
|
const files = await resolveOutputs(args)
|
|
290
440
|
// `force: true` makes rm tolerate ENOENT (e.g. when two output
|
|
291
441
|
// globs overlap and a sibling already deleted a path mid-iteration).
|
|
442
|
+
// A symlink is unlinked, never followed.
|
|
292
443
|
await Promise.all(files.map((f) => rm(f, { force: true })))
|
|
444
|
+
await pruneEmptiedDirs(args.projectDir, files)
|
|
293
445
|
// Project-relative posix paths of what was removed — the caller
|
|
294
446
|
// feeds these to GitFilesCache.markOutputsChanged after a restore.
|
|
295
447
|
return files.map((f) => path.relative(args.projectDir, f).split(path.sep).join('/'))
|
|
296
448
|
}
|
|
297
449
|
|
|
450
|
+
/**
|
|
451
|
+
* Remove the directories a clean emptied, bottom-up, never `root` itself.
|
|
452
|
+
* A restore writes files where the cached entry has them; a directory left
|
|
453
|
+
* standing where the entry holds a FILE of the same name (a task that once
|
|
454
|
+
* wrote `dist/out/…` and now writes `dist/out`) blocks the rename, and an
|
|
455
|
+
* empty directory is not an output anyone declared. A directory that still
|
|
456
|
+
* holds something — a stray the globs do not cover — stays, and the restore
|
|
457
|
+
* says so if it is in the way.
|
|
458
|
+
*/
|
|
459
|
+
async function pruneEmptiedDirs(root: string, removed: readonly string[]): Promise<void> {
|
|
460
|
+
const rootResolved = path.resolve(root)
|
|
461
|
+
const tried = new Set<string>()
|
|
462
|
+
// Deepest first, so a parent is attempted after every child had its turn.
|
|
463
|
+
const dirs = [...new Set(removed.map((f) => path.dirname(f)))].sort(
|
|
464
|
+
(a, b) => b.split(path.sep).length - a.split(path.sep).length,
|
|
465
|
+
)
|
|
466
|
+
for (let dir of dirs) {
|
|
467
|
+
while (dir !== rootResolved && dir.startsWith(rootResolved + path.sep) && !tried.has(dir)) {
|
|
468
|
+
tried.add(dir)
|
|
469
|
+
const gone = await rmdir(dir).then(
|
|
470
|
+
() => true,
|
|
471
|
+
(err: NodeJS.ErrnoException) => err.code === 'ENOENT',
|
|
472
|
+
)
|
|
473
|
+
if (!gone) break
|
|
474
|
+
dir = path.dirname(dir)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
298
479
|
/**
|
|
299
480
|
* Resolve declared `outputs.workspaceFiles` globs (workspace-root-
|
|
300
481
|
* relative) to actual produced files. Live-FS glob like
|
|
@@ -307,7 +488,12 @@ export async function resolveWorkspaceOutputs(args: {
|
|
|
307
488
|
outputs: string[]
|
|
308
489
|
}): Promise<string[]> {
|
|
309
490
|
if (args.outputs.length === 0) return []
|
|
310
|
-
|
|
491
|
+
const scanned = [...(await scanUnion(asTrees(args.outputs), [], args.workspaceRoot, 'outputs'))]
|
|
492
|
+
// Same containment as the project twin, anchored one level out. These globs
|
|
493
|
+
// deliberately ignore PROJECT boundaries — that is the escape hatch — but
|
|
494
|
+
// escaping the WORKSPACE was never part of it, and `cleanWorkspaceOutputs`
|
|
495
|
+
// deletes what this returns.
|
|
496
|
+
return (await containedIn(args.workspaceRoot, scanned)).sort()
|
|
311
497
|
}
|
|
312
498
|
|
|
313
499
|
/**
|
|
@@ -324,166 +510,87 @@ export async function cleanWorkspaceOutputs(args: {
|
|
|
324
510
|
}): Promise<string[]> {
|
|
325
511
|
const files = await resolveWorkspaceOutputs(args)
|
|
326
512
|
await Promise.all(files.map((f) => rm(f, { force: true })))
|
|
513
|
+
await pruneEmptiedDirs(args.workspaceRoot, files)
|
|
327
514
|
return files.map((f) => path.relative(args.workspaceRoot, f).split(path.sep).join('/'))
|
|
328
515
|
}
|
|
329
516
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
}
|
|
517
|
+
function isLiteralPath(glob: string): boolean {
|
|
518
|
+
return !/[*?[\]{}]/.test(glob)
|
|
519
|
+
}
|
|
461
520
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
521
|
+
function stripTrailingSlash(p: string): string {
|
|
522
|
+
return p.replace(/\/+$/, '')
|
|
523
|
+
}
|
|
465
524
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
525
|
+
/**
|
|
526
|
+
* A literal entry names a file OR a directory tree: `src/` and `src` both
|
|
527
|
+
* mean everything under `src`, as they do in Turbo and every `.gitignore`.
|
|
528
|
+
* A glob matcher sees only the literal path, so `['src/']` folded ZERO
|
|
529
|
+
* files — a key that never moves with its source, and the most common
|
|
530
|
+
* turbo.json shape (`"outputs": ["dist"]`) captured nothing. Every
|
|
531
|
+
* literal therefore compiles to itself plus its subtree; a literal that
|
|
532
|
+
* names a file still matches exactly that file, since `x/**` matches
|
|
533
|
+
* nothing under a file.
|
|
534
|
+
*/
|
|
535
|
+
export function asTrees(patterns: readonly string[]): string[] {
|
|
536
|
+
const out: string[] = []
|
|
537
|
+
for (const raw of patterns) {
|
|
538
|
+
const p = normalizeGlob(raw)
|
|
539
|
+
if (!isLiteralPath(p)) {
|
|
540
|
+
out.push(p)
|
|
541
|
+
continue
|
|
473
542
|
}
|
|
474
|
-
|
|
543
|
+
const lit = stripTrailingSlash(p)
|
|
544
|
+
if (lit.length === 0) continue
|
|
545
|
+
out.push(lit, `${lit}/**`)
|
|
475
546
|
}
|
|
547
|
+
return out
|
|
548
|
+
}
|
|
476
549
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
550
|
+
/** A literal is answered by the path itself or by anything under it. */
|
|
551
|
+
function settleLiterals(unmatched: Set<string>, rel: string): void {
|
|
552
|
+
for (const lit of unmatched) {
|
|
553
|
+
if (rel === lit || rel.startsWith(`${lit}/`)) unmatched.delete(lit)
|
|
481
554
|
}
|
|
555
|
+
}
|
|
482
556
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
557
|
+
/**
|
|
558
|
+
* Refuse a literal `cache.inputs.files` entry that EXISTS ON DISK but is
|
|
559
|
+
* invisible to git — gitignored, or otherwise absent from
|
|
560
|
+
* `git ls-files --cached --others --exclude-standard`.
|
|
561
|
+
*
|
|
562
|
+
* Such an entry contributes nothing to the cache key, so the task stops
|
|
563
|
+
* tracking a file its own config names as an input. That is a stale hit, and a
|
|
564
|
+
* quiet one: the run replays cached stdout, so it even LOOKS like it executed.
|
|
565
|
+
*
|
|
566
|
+
* Turbo honours the declaration instead (an explicit `inputs` entry overrides
|
|
567
|
+
* gitignore). vx cannot afford to, and the reason is not cost: honouring it
|
|
568
|
+
* would make the cache key depend on a change that `git diff` and
|
|
569
|
+
* `git ls-files --others` CANNOT SEE, so editing that input would re-key the
|
|
570
|
+
* task while `--affected` selected nothing. `docs/cli.md` states the invariant
|
|
571
|
+
* those two surfaces owe each other as a principle — "input hashing sees it, so
|
|
572
|
+
* `--affected` must too" — and it was closed for lockfiles only recently.
|
|
573
|
+
* Honouring a gitignored input would reopen it from the other side.
|
|
574
|
+
*
|
|
575
|
+
* An entry that does NOT exist on disk stays silent: that is an ordinary stale
|
|
576
|
+
* declaration, and refusing it would break every config that lists an
|
|
577
|
+
* optional file.
|
|
578
|
+
*/
|
|
579
|
+
async function assertNoInvisibleLiteralInputs(
|
|
580
|
+
literals: ReadonlySet<string>,
|
|
581
|
+
base: string,
|
|
582
|
+
field: 'files' | 'workspaceFiles' = 'files',
|
|
583
|
+
): Promise<void> {
|
|
584
|
+
for (const rel of literals) {
|
|
585
|
+
if (!(await Bun.file(path.resolve(base, rel)).exists())) continue
|
|
586
|
+
throw new UserError(
|
|
587
|
+
`cache.inputs.${field}: "${rel}" exists in ${base} but git does not report it, ` +
|
|
588
|
+
`so it contributes NOTHING to the cache key — input globs filter the files git ` +
|
|
589
|
+
`lists, and a filter cannot add one back. The task would keep reporting ` +
|
|
590
|
+
`up-to-date after that file changed. If it is generated, depend on the task ` +
|
|
591
|
+
`that produces it via cache.inputs.tasks; if it should be tracked, remove it ` +
|
|
592
|
+
`from .gitignore.`,
|
|
593
|
+
)
|
|
487
594
|
}
|
|
488
595
|
}
|
|
489
596
|
|
|
@@ -512,9 +619,12 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
512
619
|
if (positive.length === 0) return []
|
|
513
620
|
|
|
514
621
|
const boundaryIgnores = boundaryIgnorePatterns(args.projectDir, args.nestedProjectDirs)
|
|
515
|
-
const excludeGlobs = [
|
|
516
|
-
|
|
517
|
-
|
|
622
|
+
const excludeGlobs = [
|
|
623
|
+
...ALWAYS_IGNORE,
|
|
624
|
+
...boundaryIgnores,
|
|
625
|
+
...asTrees(args.ownOutputs),
|
|
626
|
+
...asTrees(negative),
|
|
627
|
+
].map(globFor)
|
|
518
628
|
|
|
519
629
|
// Defer to git for the file set (Turbo / Nx parity). Nested .gitignore
|
|
520
630
|
// files, .git/info/exclude, and global excludes all participate
|
|
@@ -524,7 +634,7 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
524
634
|
// per task (build + test + lint + …). Spawning git N times for the
|
|
525
635
|
// same project per run is wasteful; we cache the result for the
|
|
526
636
|
// duration of one orchestrator run.
|
|
527
|
-
const positiveGlobs = positive.map(
|
|
637
|
+
const positiveGlobs = asTrees(positive).map(globFor)
|
|
528
638
|
let gitFiles = args.gitFilesCache?.snapshotFor(args.projectDir, positiveGlobs)
|
|
529
639
|
if (gitFiles === undefined) {
|
|
530
640
|
// Mid-run re-enumeration. The OIDs this spawn could yield are NOT
|
|
@@ -536,9 +646,24 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
536
646
|
// set() also clears the project's pending-changed bookkeeping.
|
|
537
647
|
args.gitFilesCache?.set(args.projectDir, gitFiles)
|
|
538
648
|
}
|
|
649
|
+
// A LITERAL entry — one with no glob metacharacter — names exactly one file,
|
|
650
|
+
// so "this matched nothing" is unambiguous. For a glob it is not: matching
|
|
651
|
+
// nothing is perfectly legitimate (`src/**/*.gen.ts` in a package with no
|
|
652
|
+
// generated code), which is why only literals are tracked here.
|
|
653
|
+
//
|
|
654
|
+
// The reason it has to be tracked at all: the user's globs FILTER the set git
|
|
655
|
+
// reports, and a filter can only remove. So naming a gitignored file by hand
|
|
656
|
+
// folds NOTHING — silently. The task then reports `up-to-date` while replaying
|
|
657
|
+
// an artifact built from an older version of a file the config explicitly
|
|
658
|
+
// claims as an input. See the refusal below for why this is not simply
|
|
659
|
+
// honoured instead.
|
|
660
|
+
const unmatchedLiterals = new Set(
|
|
661
|
+
positive.map(normalizeGlob).filter(isLiteralPath).map(stripTrailingSlash),
|
|
662
|
+
)
|
|
539
663
|
// First pass: glob-filter to candidate absolute paths (no I/O).
|
|
540
664
|
const candidates: string[] = []
|
|
541
665
|
for (const rel of gitFiles) {
|
|
666
|
+
if (unmatchedLiterals.size > 0) settleLiterals(unmatchedLiterals, rel)
|
|
542
667
|
let matched = false
|
|
543
668
|
for (const g of positiveGlobs) {
|
|
544
669
|
if (g.match(rel)) {
|
|
@@ -550,280 +675,62 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
550
675
|
if (excludeGlobs.some((g) => g.match(rel))) continue
|
|
551
676
|
candidates.push(path.resolve(args.projectDir, rel))
|
|
552
677
|
}
|
|
553
|
-
|
|
554
|
-
|
|
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)
|
|
678
|
+
if (unmatchedLiterals.size > 0) {
|
|
679
|
+
await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.projectDir, 'files')
|
|
810
680
|
}
|
|
681
|
+
// Second pass: existence check — but ONLY for paths without a
|
|
682
|
+
// trusted index OID. A clean-per-status tracked file necessarily
|
|
683
|
+
// exists on disk, so skipping its probe keeps the warm path free of
|
|
684
|
+
// per-file syscalls. Paths without an OID keep the probe:
|
|
685
|
+
// `git ls-files -s` can surface staged entries whose working-tree
|
|
686
|
+
// file is gone; the hasher would otherwise throw ENOENT.
|
|
687
|
+
const oids = args.gitFilesCache?.oidsFor(args.projectDir)
|
|
688
|
+
return candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
|
|
811
689
|
}
|
|
812
690
|
|
|
813
691
|
/**
|
|
814
692
|
* Union of files matching any positive pattern in `cwd`, minus files
|
|
815
693
|
* matching any exclude glob (tested by Bun.Glob.match on the relative
|
|
816
694
|
* path). Bun.Glob takes a single pattern per instance, so we iterate.
|
|
695
|
+
*
|
|
696
|
+
* `outputs` mode also yields SYMLINKS (never followed, never descended):
|
|
697
|
+
* a task that emits `dist/out -> ../src/x` has produced an output, so the
|
|
698
|
+
* save must capture it (as its target's bytes, `planArtifact`) and the
|
|
699
|
+
* clean must remove it — a link the clean leaves standing blocks the
|
|
700
|
+
* directory a later entry restores there. Bun's `onlyFiles` walk drops
|
|
701
|
+
* every symlink, so this mode lists everything and sorts by `lstat`.
|
|
817
702
|
*/
|
|
818
703
|
async function scanUnion(
|
|
819
704
|
positive: readonly string[],
|
|
820
705
|
excludeGlobs: readonly Bun.Glob[],
|
|
821
706
|
cwd: string,
|
|
707
|
+
mode: 'files' | 'outputs' = 'files',
|
|
822
708
|
): Promise<Set<string>> {
|
|
823
709
|
const matches = new Set<string>()
|
|
824
710
|
for (const pattern of positive) {
|
|
825
|
-
const glob =
|
|
826
|
-
|
|
711
|
+
const glob = globFor(pattern)
|
|
712
|
+
if (mode === 'outputs') {
|
|
713
|
+
for (const rel of glob.scanSync({
|
|
714
|
+
cwd,
|
|
715
|
+
onlyFiles: false,
|
|
716
|
+
followSymlinks: false,
|
|
717
|
+
dot: true,
|
|
718
|
+
})) {
|
|
719
|
+
if (excludeGlobs.some((g) => g.match(rel))) continue
|
|
720
|
+
const abs = path.resolve(cwd, rel)
|
|
721
|
+
const st = lstatSync(abs, { throwIfNoEntry: false })
|
|
722
|
+
if (st !== undefined && (st.isFile() || st.isSymbolicLink())) matches.add(abs)
|
|
723
|
+
}
|
|
724
|
+
continue
|
|
725
|
+
}
|
|
726
|
+
// Sync scan. The async walk was chosen on 2026-09-02, when a warm HIT
|
|
727
|
+
// globbed its outputs and the thread pool overlapped a thousand of
|
|
728
|
+
// them; the directory short-circuit took the glob off the hit path, and
|
|
729
|
+
// what is left runs on the MISS path twice per task (clean, then
|
|
730
|
+
// resolve), where the walk is CPU-bound and each async chunk costs a
|
|
731
|
+
// main-thread round trip. Measured 2026-09-09 on a 1,000-task cold run
|
|
732
|
+
// (see STATUS).
|
|
733
|
+
for (const rel of glob.scanSync({ cwd, onlyFiles: true, dot: true })) {
|
|
827
734
|
if (excludeGlobs.some((g) => g.match(rel))) continue
|
|
828
735
|
matches.add(path.resolve(cwd, rel))
|
|
829
736
|
}
|
|
@@ -831,6 +738,16 @@ async function scanUnion(
|
|
|
831
738
|
return matches
|
|
832
739
|
}
|
|
833
740
|
|
|
741
|
+
// Compiled once per pattern string for the life of the process: a `Bun.Glob`
|
|
742
|
+
// is immutable, and the same handful of declared globs are compiled again
|
|
743
|
+
// for every task otherwise (three sites, up to thousands of times per run).
|
|
744
|
+
const globMemo = new Map<string, Bun.Glob>()
|
|
745
|
+
function globFor(pattern: string): Bun.Glob {
|
|
746
|
+
let g = globMemo.get(pattern)
|
|
747
|
+
if (g === undefined) globMemo.set(pattern, (g = new Bun.Glob(pattern)))
|
|
748
|
+
return g
|
|
749
|
+
}
|
|
750
|
+
|
|
834
751
|
function boundaryIgnorePatterns(projectDir: string, nestedDirs: string[]): string[] {
|
|
835
752
|
return nestedDirs.map((d) => {
|
|
836
753
|
const rel = path.relative(projectDir, d).split(path.sep).join('/')
|