@vzn/vx 0.0.18 → 0.0.22
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 +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
package/src/cache/inputs.ts
CHANGED
|
@@ -1,28 +1,50 @@
|
|
|
1
1
|
// Resolve declared cache inputs into the concrete pieces that go into the
|
|
2
2
|
// cache key:
|
|
3
3
|
// - files: absolute paths whose contents are hashed
|
|
4
|
-
// - envValues: [name, value] pairs from parent process.env
|
|
4
|
+
// - envValues: [name, value] pairs from parent process.env (value
|
|
5
|
+
// undefined for an unset name)
|
|
5
6
|
//
|
|
6
7
|
// `cache.inputs.env` is the cache-tracking axis for env vars; it's
|
|
7
8
|
// independent of `exec.env`, which controls what reaches the child.
|
|
8
9
|
//
|
|
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.
|
|
10
|
+
// File enumeration defers to git — same as Turbo and Nx. git-inputs.ts
|
|
11
|
+
// asks for the tracked set with `git ls-files -s -v` (index OIDs and the
|
|
12
|
+
// skip-worktree flag in the same spawn) and for the dirty and untracked
|
|
13
|
+
// paths with `git status -uall`, with nested .gitignore, .git/info/exclude
|
|
14
|
+
// and the global excludes applied because git already does the cascade.
|
|
15
|
+
// The user's `inputs.files` globs are then matched as a *filter* on top
|
|
16
|
+
// of that file set. vx requires git to be installed and the workspace to
|
|
17
|
+
// be a git work tree; non-git environments are not supported.
|
|
20
18
|
|
|
21
19
|
import path from 'node:path'
|
|
22
|
-
import {
|
|
23
|
-
import { realpath, rm } from 'node:fs/promises'
|
|
20
|
+
import { lstatSync, readdirSync } from 'node:fs'
|
|
21
|
+
import { realpath, rm, rmdir } from 'node:fs/promises'
|
|
24
22
|
import type { CacheInputs } from '../config.js'
|
|
25
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
asTrees,
|
|
25
|
+
isExecutableMissing,
|
|
26
|
+
isLiteralPattern,
|
|
27
|
+
normalizeGlob,
|
|
28
|
+
taskGlob,
|
|
29
|
+
UserError,
|
|
30
|
+
} from '../util/index.js'
|
|
31
|
+
import { GitFilesCache, runGitLsFiles } from './git-inputs.js'
|
|
32
|
+
|
|
33
|
+
// The git side lives in git-inputs.ts; its whole public surface is
|
|
34
|
+
// re-exported here so a reader that reaches the resolver for it (the
|
|
35
|
+
// tests do, by deep import) keeps working.
|
|
36
|
+
export {
|
|
37
|
+
GitFilesCache,
|
|
38
|
+
populateGitFilesCache,
|
|
39
|
+
runGitLsFiles,
|
|
40
|
+
startGitEnumeration,
|
|
41
|
+
applyGitEnumeration,
|
|
42
|
+
gitPathspecs,
|
|
43
|
+
parseCheckAttrOutput,
|
|
44
|
+
autocrlfConverts,
|
|
45
|
+
attributeFilesOutsideTree,
|
|
46
|
+
type GitEnumeration,
|
|
47
|
+
} from './git-inputs.js'
|
|
26
48
|
|
|
27
49
|
// vx-lock.json is committed (so git enumerates it) but it's vx's own
|
|
28
50
|
// frozen-config metadata — never a task input. Excluded globally so a
|
|
@@ -31,6 +53,17 @@ import { UserError } from '../util/index.js'
|
|
|
31
53
|
// cache is a leaf module and must not import from workspace.)
|
|
32
54
|
const ALWAYS_IGNORE = [
|
|
33
55
|
'**/node_modules/**',
|
|
56
|
+
// Defense in depth, and measured as exactly that (item 497): git never
|
|
57
|
+
// reports a path under `.git`, so dropping this line changes NOTHING on
|
|
58
|
+
// any route into the input set. Probed three ways with the pattern in
|
|
59
|
+
// and out — a tracked `**/*` glob, an UNTRACKED one, and a literal
|
|
60
|
+
// `inputs.files: ['.git/HEAD']` (refused by the git-reports-it check,
|
|
61
|
+
// not by this list) — all identical, while the control in the same
|
|
62
|
+
// fixture showed an untracked ORDINARY file does enter the set. So the
|
|
63
|
+
// enumeration is the live guard and this is the backstop; every other
|
|
64
|
+
// member of this list is load-bearing and has a row that fails without
|
|
65
|
+
// it. Kept: the day an enumeration stops going through git, this is
|
|
66
|
+
// what keeps a repository's object store out of a cache key.
|
|
34
67
|
'**/.git/**',
|
|
35
68
|
'**/.vx/**',
|
|
36
69
|
'**/*.tsbuildinfo',
|
|
@@ -47,7 +80,7 @@ const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
|
|
|
47
80
|
|
|
48
81
|
export interface ResolvedInputs {
|
|
49
82
|
files: string[]
|
|
50
|
-
envValues: Array<[name: string, value: string]>
|
|
83
|
+
envValues: Array<[name: string, value: string | undefined]>
|
|
51
84
|
runtimeValues: Array<[command: string, output: string]>
|
|
52
85
|
workspaceRuntimeValues: Array<[command: string, output: string]>
|
|
53
86
|
}
|
|
@@ -75,7 +108,11 @@ export interface ResolveInputsArgs {
|
|
|
75
108
|
* Run-scoped memo for `cache.inputs.runtime` command execution, keyed
|
|
76
109
|
* by `projectDir + '\0' + command`. Shared across a run's tasks so a
|
|
77
110
|
* project's command runs once even across build/test/lint and across
|
|
78
|
-
* the hash + sandbox-baseline resolveInputs calls.
|
|
111
|
+
* the hash + sandbox-baseline resolveInputs calls. The key is sound
|
|
112
|
+
* ONLY because the probe runs in vx's own ambient environment, never a
|
|
113
|
+
* task's `exec.env` (`runRuntimeCommand`): two tasks with different
|
|
114
|
+
* `define`s share one value. Threading the task env in would have to
|
|
115
|
+
* widen this key too, or task A's probe becomes task B's key component.
|
|
79
116
|
*/
|
|
80
117
|
runtimeCache?: Map<string, Promise<string>>
|
|
81
118
|
/**
|
|
@@ -83,8 +120,42 @@ export interface ResolveInputsArgs {
|
|
|
83
120
|
* only — global dedup so a root-level probe spawns once per run.
|
|
84
121
|
*/
|
|
85
122
|
workspaceRuntimeCache?: Map<string, Promise<string>>
|
|
123
|
+
/**
|
|
124
|
+
* Run-scoped memo for `cache.inputs.workspaceFiles`, keyed by the
|
|
125
|
+
* declaration and valid for one enumeration snapshot. A Turbo-mapped
|
|
126
|
+
* workspace gives every task the same `globalDependencies`, and
|
|
127
|
+
* resolving one literal against medusa's 24k-file enumeration per task
|
|
128
|
+
* was 930 ms of a 3.0 s warm no-op (159 scans, 2026-09-11).
|
|
129
|
+
*/
|
|
130
|
+
workspaceFilesCache?: WorkspaceFilesCache
|
|
131
|
+
/** Per-run memo for `inputs.files`; see `ProjectFilesCache`. */
|
|
132
|
+
projectFilesCache?: ProjectFilesCache
|
|
86
133
|
}
|
|
87
134
|
|
|
135
|
+
export type WorkspaceFilesCache = Map<
|
|
136
|
+
string,
|
|
137
|
+
{ snapshot: readonly string[]; result: Promise<string[]> }
|
|
138
|
+
>
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Per-run memo of `cache.inputs.files` resolution, keyed by the project and
|
|
142
|
+
* the DECLARATION — tasks of one project that declare the same inputs and
|
|
143
|
+
* the same outputs resolve to the same list, and this repo's own config is
|
|
144
|
+
* the shape that pays for it: twelve shard tasks, each declaring the same
|
|
145
|
+
* whole-tree glob over the same three thousand files (measured 1.2 ms of
|
|
146
|
+
* resolution per task, 2026-09-20).
|
|
147
|
+
*
|
|
148
|
+
* Reuse is gated on the git snapshot being the SAME ARRAY the entry was
|
|
149
|
+
* built from, not on equal contents: a mid-run re-enumeration replaces it
|
|
150
|
+
* (`GitFilesCache.set`), so a task whose inputs a previous task rewrote
|
|
151
|
+
* misses the memo and walks again. Same discipline as WorkspaceFilesCache
|
|
152
|
+
* above.
|
|
153
|
+
*/
|
|
154
|
+
export type ProjectFilesCache = Map<
|
|
155
|
+
string,
|
|
156
|
+
{ snapshot: readonly string[]; result: readonly string[] }
|
|
157
|
+
>
|
|
158
|
+
|
|
88
159
|
export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedInputs> {
|
|
89
160
|
const projectFiles = await resolveFiles({
|
|
90
161
|
projectDir: args.projectDir,
|
|
@@ -93,6 +164,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
|
|
|
93
164
|
ownOutputs: args.ownOutputs,
|
|
94
165
|
nestedProjectDirs: args.nestedProjectDirs,
|
|
95
166
|
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
167
|
+
...(args.projectFilesCache !== undefined ? { projectFilesCache: args.projectFilesCache } : {}),
|
|
96
168
|
})
|
|
97
169
|
let files = projectFiles
|
|
98
170
|
const wsDecl = args.inputs?.workspaceFiles
|
|
@@ -102,26 +174,36 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
|
|
|
102
174
|
workspaceFiles: wsDecl,
|
|
103
175
|
ownWorkspaceOutputs: args.ownWorkspaceOutputs ?? [],
|
|
104
176
|
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
177
|
+
...(args.workspaceFilesCache !== undefined ? { memo: args.workspaceFilesCache } : {}),
|
|
105
178
|
})
|
|
106
179
|
// Dedupe: when the project dir IS the workspace root (or a glob
|
|
107
180
|
// overlaps), the same absolute path can arrive via both lists —
|
|
108
181
|
// it must contribute to the key exactly once.
|
|
109
182
|
if (wsFiles.length > 0) files = [...new Set([...projectFiles, ...wsFiles])].sort()
|
|
110
183
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
184
|
+
// Two awaited empty resolutions per task were ~3 ms of a 1000-task warm
|
|
185
|
+
// run (profiled 2026-09-09); a task declaring neither skips the fan-out.
|
|
186
|
+
const runtimeDecl = args.inputs?.runtime ?? []
|
|
187
|
+
const wsRuntimeDecl = args.inputs?.workspaceRuntime ?? []
|
|
188
|
+
const [runtimeValues, workspaceRuntimeValues] =
|
|
189
|
+
runtimeDecl.length === 0 && wsRuntimeDecl.length === 0
|
|
190
|
+
? [[], []]
|
|
191
|
+
: await Promise.all([
|
|
192
|
+
resolveRuntimeValues(
|
|
193
|
+
runtimeDecl,
|
|
194
|
+
args.projectDir,
|
|
195
|
+
projectBinDirs(args.projectDir, args.workspaceRoot),
|
|
196
|
+
args.runtimeCache,
|
|
197
|
+
`${args.projectDir}\0`,
|
|
198
|
+
),
|
|
199
|
+
resolveRuntimeValues(
|
|
200
|
+
wsRuntimeDecl,
|
|
201
|
+
args.workspaceRoot,
|
|
202
|
+
[path.join(args.workspaceRoot, 'node_modules', '.bin')],
|
|
203
|
+
args.workspaceRuntimeCache,
|
|
204
|
+
'',
|
|
205
|
+
),
|
|
206
|
+
])
|
|
125
207
|
return {
|
|
126
208
|
files,
|
|
127
209
|
envValues: resolveEnvValues(args.inputs?.env ?? [], args.envSource),
|
|
@@ -146,6 +228,7 @@ async function resolveWorkspaceFiles(args: {
|
|
|
146
228
|
workspaceFiles: readonly string[]
|
|
147
229
|
ownWorkspaceOutputs: readonly string[]
|
|
148
230
|
gitFilesCache?: GitFilesCache
|
|
231
|
+
memo?: WorkspaceFilesCache
|
|
149
232
|
}): Promise<string[]> {
|
|
150
233
|
const positive: string[] = []
|
|
151
234
|
const negative: string[] = []
|
|
@@ -155,25 +238,65 @@ async function resolveWorkspaceFiles(args: {
|
|
|
155
238
|
}
|
|
156
239
|
if (positive.length === 0) return []
|
|
157
240
|
|
|
158
|
-
const excludeGlobs = [
|
|
159
|
-
|
|
241
|
+
const excludeGlobs = [
|
|
242
|
+
...ALWAYS_IGNORE,
|
|
243
|
+
...asTrees(args.ownWorkspaceOutputs),
|
|
244
|
+
...asTrees(negative),
|
|
245
|
+
].map(globFor)
|
|
246
|
+
const positiveGlobs = asTrees(positive).map(globFor)
|
|
160
247
|
// Workspace-wide partition, keyed by the workspace root. Populated
|
|
161
248
|
// up-front by `populateGitFilesCache(..., workspaceWide: true)` when
|
|
162
249
|
// any loaded task declares workspaceFiles; a missing/invalidated
|
|
163
250
|
// partition re-spawns git at the root on demand.
|
|
164
251
|
let gitFiles = args.gitFilesCache?.snapshotFor(args.workspaceRoot, positiveGlobs)
|
|
252
|
+
let undecodable = args.gitFilesCache?.undecodableNames
|
|
165
253
|
if (gitFiles === undefined) {
|
|
166
|
-
|
|
254
|
+
const ls = runGitLsFiles(args.workspaceRoot)
|
|
255
|
+
gitFiles = ls.files
|
|
167
256
|
args.gitFilesCache?.set(args.workspaceRoot, gitFiles)
|
|
257
|
+
undecodable = noteUndecodable(args.gitFilesCache, args.workspaceRoot, ls.undecodable)
|
|
168
258
|
}
|
|
259
|
+
// The memo is valid for the snapshot it was computed over: a task that
|
|
260
|
+
// wrote workspace outputs mid-run replaces the partition, and the next
|
|
261
|
+
// caller sees a different array and scans again.
|
|
262
|
+
const memoKey =
|
|
263
|
+
args.memo === undefined
|
|
264
|
+
? undefined
|
|
265
|
+
: JSON.stringify([positive, negative, args.ownWorkspaceOutputs])
|
|
266
|
+
if (memoKey !== undefined) {
|
|
267
|
+
const hit = args.memo!.get(memoKey)
|
|
268
|
+
if (hit !== undefined && hit.snapshot === gitFiles) return hit.result
|
|
269
|
+
}
|
|
270
|
+
const result = resolveWorkspaceFilesOver(
|
|
271
|
+
args,
|
|
272
|
+
gitFiles,
|
|
273
|
+
positive,
|
|
274
|
+
positiveGlobs,
|
|
275
|
+
excludeGlobs,
|
|
276
|
+
undecodable,
|
|
277
|
+
)
|
|
278
|
+
if (memoKey !== undefined) args.memo!.set(memoKey, { snapshot: gitFiles, result })
|
|
279
|
+
return result
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function resolveWorkspaceFilesOver(
|
|
283
|
+
args: { workspaceRoot: string; gitFilesCache?: GitFilesCache },
|
|
284
|
+
gitFiles: readonly string[],
|
|
285
|
+
positive: readonly string[],
|
|
286
|
+
positiveGlobs: readonly Bun.Glob[],
|
|
287
|
+
excludeGlobs: readonly Bun.Glob[],
|
|
288
|
+
undecodable: ReadonlySet<string> | undefined,
|
|
289
|
+
): Promise<string[]> {
|
|
169
290
|
// Second call site of the literal-input guard. `resolveWorkspaceFiles`
|
|
170
291
|
// carries its own copy of the filter-over-git-set design, so the same
|
|
171
292
|
// silently-folds-nothing hazard exists here — and a fix applied only to the
|
|
172
293
|
// project half would pass that half's tests while leaving this one live.
|
|
173
|
-
const unmatchedLiterals = new Set(
|
|
294
|
+
const unmatchedLiterals = new Set(
|
|
295
|
+
positive.map(normalizeGlob).filter(isLiteralPattern).map(stripTrailingSlash),
|
|
296
|
+
)
|
|
174
297
|
const candidates: string[] = []
|
|
175
298
|
for (const rel of gitFiles) {
|
|
176
|
-
if (unmatchedLiterals.size > 0) unmatchedLiterals
|
|
299
|
+
if (unmatchedLiterals.size > 0) settleLiterals(unmatchedLiterals, rel)
|
|
177
300
|
if (!positiveGlobs.some((g) => g.match(rel))) continue
|
|
178
301
|
if (excludeGlobs.some((g) => g.match(rel))) continue
|
|
179
302
|
candidates.push(path.resolve(args.workspaceRoot, rel))
|
|
@@ -181,41 +304,96 @@ async function resolveWorkspaceFiles(args: {
|
|
|
181
304
|
if (unmatchedLiterals.size > 0) {
|
|
182
305
|
await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.workspaceRoot, 'workspaceFiles')
|
|
183
306
|
}
|
|
307
|
+
refuseUndecodable(candidates, undecodable, args.workspaceRoot, 'workspaceFiles')
|
|
184
308
|
// Same OID-trust shortcut as project files: a clean-per-status
|
|
185
309
|
// tracked file necessarily exists on disk.
|
|
186
310
|
const oids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
311
|
+
return candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Anything at the path — file, directory, symlink to anything or to nothing. */
|
|
315
|
+
function existsOnDisk(abs: string): boolean {
|
|
316
|
+
try {
|
|
317
|
+
lstatSync(abs)
|
|
318
|
+
return true
|
|
319
|
+
} catch {
|
|
320
|
+
return false
|
|
193
321
|
}
|
|
194
|
-
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* What an enumerated path must be to count as an input: a regular file, or
|
|
326
|
+
* a symlink (to anything, or to nothing — its target STRING is what folds,
|
|
327
|
+
* as in git). A directory is not one: git lists a gitlink (a submodule) at
|
|
328
|
+
* its path and the glob `**\/*` matches it, and the hasher has nothing to
|
|
329
|
+
* read there. `Bun.file(p).exists()` answered false for every symlink to a
|
|
330
|
+
* directory and every dangling link too, which silently dropped a tracked
|
|
331
|
+
* link from the key — retargeting it was a stale hit. lstat, synchronously:
|
|
332
|
+
* the paths that reach this probe are the ones without a trusted index OID
|
|
333
|
+
* (untracked and dirty files), a handful on a warm run.
|
|
334
|
+
*/
|
|
335
|
+
function isInputOnDisk(abs: string): boolean {
|
|
336
|
+
try {
|
|
337
|
+
const st = lstatSync(abs)
|
|
338
|
+
return st.isFile() || st.isSymbolicLink()
|
|
339
|
+
} catch {
|
|
340
|
+
return false
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** The task's `node_modules/.bin` directories, as `taskBinDirs` gives its PATH. */
|
|
345
|
+
function projectBinDirs(projectDir: string, workspaceRoot: string): string[] {
|
|
346
|
+
const own = path.join(projectDir, 'node_modules', '.bin')
|
|
347
|
+
const root = path.join(workspaceRoot, 'node_modules', '.bin')
|
|
348
|
+
return own === root ? [own] : [own, root]
|
|
195
349
|
}
|
|
196
350
|
|
|
197
351
|
function resolveEnvValues(
|
|
198
352
|
names: readonly string[],
|
|
199
353
|
source: NodeJS.ProcessEnv,
|
|
200
|
-
): Array<[string, string]> {
|
|
201
|
-
return [...names].sort().map((name) => [name, source[name]
|
|
354
|
+
): Array<[string, string | undefined]> {
|
|
355
|
+
return [...names].sort().map((name) => [name, source[name]])
|
|
202
356
|
}
|
|
203
357
|
|
|
204
358
|
/**
|
|
205
359
|
* Run one runtime-input command via `sh -c` (so pipelines / redirects
|
|
206
360
|
* work — "shell is the API"). Returns trimmed stdout+stderr. A non-zero
|
|
207
361
|
* exit is a hard UserError naming the command (fail-loud, like git).
|
|
362
|
+
*
|
|
363
|
+
* The probe inherits vx's AMBIENT environment on purpose — not the task's
|
|
364
|
+
* `exec.env.define` / `passThrough`, which describe the command's
|
|
365
|
+
* environment, not the machine's. That is what lets the run-scoped memo
|
|
366
|
+
* key on (projectDir, command) alone; see `runtimeCache`.
|
|
367
|
+
*
|
|
368
|
+
* Its PATH does lead with the task's own `node_modules/.bin` directories
|
|
369
|
+
* (`binDirs`), as the task's does: `tsc --version` otherwise keyed the
|
|
370
|
+
* global tsc while the task ran the workspace's, and a changed local tool
|
|
371
|
+
* replayed the old output (item 996).
|
|
208
372
|
*/
|
|
209
|
-
async function runRuntimeCommand(
|
|
373
|
+
async function runRuntimeCommand(
|
|
374
|
+
command: string,
|
|
375
|
+
cwd: string,
|
|
376
|
+
binDirs: readonly string[],
|
|
377
|
+
): Promise<string> {
|
|
378
|
+
const ambient = process.env['PATH']
|
|
379
|
+
const prefix = binDirs.join(path.delimiter)
|
|
210
380
|
let proc
|
|
211
381
|
try {
|
|
212
382
|
proc = Bun.spawn(['sh', '-c', command], {
|
|
213
383
|
cwd,
|
|
384
|
+
env: { ...process.env, PATH: ambient ? `${prefix}${path.delimiter}${ambient}` : prefix },
|
|
214
385
|
stdin: 'ignore',
|
|
215
386
|
stdout: 'pipe',
|
|
216
387
|
stderr: 'pipe',
|
|
217
388
|
})
|
|
218
|
-
} catch {
|
|
389
|
+
} catch (err) {
|
|
390
|
+
// The probe runs through `sh -c` like a task: a box without sh names
|
|
391
|
+
// the shell, not the command (item 244).
|
|
392
|
+
if (isExecutableMissing(err)) {
|
|
393
|
+
throw new UserError(
|
|
394
|
+
`cache.inputs runtime command could not run: vx runs it with sh -c and 'sh' is not on PATH (command: ${command}, cwd: ${cwd}). Install a POSIX sh and re-run.`,
|
|
395
|
+
)
|
|
396
|
+
}
|
|
219
397
|
throw new UserError(`cache.inputs runtime command failed to spawn: ${command} (cwd: ${cwd})`)
|
|
220
398
|
}
|
|
221
399
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
@@ -245,6 +423,7 @@ async function runRuntimeCommand(command: string, cwd: string): Promise<string>
|
|
|
245
423
|
async function resolveRuntimeValues(
|
|
246
424
|
commands: readonly string[],
|
|
247
425
|
cwd: string,
|
|
426
|
+
binDirs: readonly string[],
|
|
248
427
|
memo: Map<string, Promise<string>> | undefined,
|
|
249
428
|
memoKeyPrefix: string,
|
|
250
429
|
): Promise<Array<[string, string]>> {
|
|
@@ -255,7 +434,7 @@ async function resolveRuntimeValues(
|
|
|
255
434
|
const key = `${memoKeyPrefix}${cmd}`
|
|
256
435
|
let p = memo?.get(key)
|
|
257
436
|
if (p === undefined) {
|
|
258
|
-
p = runRuntimeCommand(cmd, cwd)
|
|
437
|
+
p = runRuntimeCommand(cmd, cwd, binDirs)
|
|
259
438
|
memo?.set(key, p)
|
|
260
439
|
}
|
|
261
440
|
return [cmd, await p] as [string, string]
|
|
@@ -263,6 +442,15 @@ async function resolveRuntimeValues(
|
|
|
263
442
|
)
|
|
264
443
|
}
|
|
265
444
|
|
|
445
|
+
/**
|
|
446
|
+
* What no output glob reaches. An output is otherwise taken as written —
|
|
447
|
+
* `node_modules/**` IS an install task's output, so `ALWAYS_IGNORE` does not
|
|
448
|
+
* apply here — but these two directories no task produces, and a root
|
|
449
|
+
* project declaring `**` would otherwise wipe its repository and the cache
|
|
450
|
+
* it is restoring from (2026-09-16).
|
|
451
|
+
*/
|
|
452
|
+
const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**']
|
|
453
|
+
|
|
266
454
|
/** Resolve declared output globs (project-relative) to actual produced files. */
|
|
267
455
|
export async function resolveOutputs(args: {
|
|
268
456
|
projectDir: string
|
|
@@ -270,8 +458,11 @@ export async function resolveOutputs(args: {
|
|
|
270
458
|
nestedProjectDirs: string[]
|
|
271
459
|
}): Promise<string[]> {
|
|
272
460
|
if (args.outputs.length === 0) return []
|
|
273
|
-
const excludeGlobs =
|
|
274
|
-
|
|
461
|
+
const excludeGlobs = [
|
|
462
|
+
...OUTPUT_NEVER,
|
|
463
|
+
...boundaryIgnorePatterns(args.projectDir, args.nestedProjectDirs),
|
|
464
|
+
].map(globFor)
|
|
465
|
+
const scanned = [...(await scanUnion(asTrees(args.outputs), excludeGlobs, args.projectDir))]
|
|
275
466
|
// Containment, enforced HERE and not only at the loader. `cleanOutputs`
|
|
276
467
|
// DELETES whatever this returns, and `Bun.Glob.scan` happily walks `..` out
|
|
277
468
|
// of its cwd — so the loader's `..`/absolute rejection alone was a single
|
|
@@ -355,12 +546,148 @@ export async function cleanOutputs(args: {
|
|
|
355
546
|
const files = await resolveOutputs(args)
|
|
356
547
|
// `force: true` makes rm tolerate ENOENT (e.g. when two output
|
|
357
548
|
// globs overlap and a sibling already deleted a path mid-iteration).
|
|
358
|
-
|
|
549
|
+
// A symlink is unlinked, never followed.
|
|
550
|
+
await removeAll(files, args.projectDir)
|
|
551
|
+
await pruneEmptiedDirs(args.projectDir, files)
|
|
359
552
|
// Project-relative posix paths of what was removed — the caller
|
|
360
553
|
// feeds these to GitFilesCache.markOutputsChanged after a restore.
|
|
361
554
|
return files.map((f) => path.relative(args.projectDir, f).split(path.sep).join('/'))
|
|
362
555
|
}
|
|
363
556
|
|
|
557
|
+
/**
|
|
558
|
+
* Remove exactly these project-relative paths — the recorded rows of an
|
|
559
|
+
* ADDITIVE task's own artifact (item 588), never a glob: the glob would
|
|
560
|
+
* take the upstream's files the task adds beside. Emptied directories are
|
|
561
|
+
* pruned as `cleanOutputs` prunes them, and stop at one the upstream still
|
|
562
|
+
* fills.
|
|
563
|
+
*/
|
|
564
|
+
export async function cleanOutputPaths(args: {
|
|
565
|
+
projectDir: string
|
|
566
|
+
rels: readonly string[]
|
|
567
|
+
}): Promise<void> {
|
|
568
|
+
const files = args.rels.map((r) => path.resolve(args.projectDir, r))
|
|
569
|
+
await removeAll(files, args.projectDir)
|
|
570
|
+
await pruneEmptiedDirs(args.projectDir, files)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** A file's identity for the additive diff: what the hit path's fingerprint trusts too. */
|
|
574
|
+
export interface OutputStamp {
|
|
575
|
+
size: number
|
|
576
|
+
mtimeMs: number
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* The stamps of every file the declared outputs currently select — taken
|
|
581
|
+
* BEFORE an additive task runs, so what it added or changed can be told
|
|
582
|
+
* from what it found (item 588).
|
|
583
|
+
*/
|
|
584
|
+
export async function stampOutputs(args: {
|
|
585
|
+
projectDir: string
|
|
586
|
+
outputs: string[]
|
|
587
|
+
nestedProjectDirs: string[]
|
|
588
|
+
}): Promise<Map<string, OutputStamp>> {
|
|
589
|
+
const files = await resolveOutputs(args)
|
|
590
|
+
const out = new Map<string, OutputStamp>()
|
|
591
|
+
for (const f of files) {
|
|
592
|
+
try {
|
|
593
|
+
const st = lstatSync(f)
|
|
594
|
+
out.set(f, { size: st.size, mtimeMs: st.mtimeMs })
|
|
595
|
+
} catch {
|
|
596
|
+
// Gone between the walk and the stat: not a file the task found.
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return out
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* The files an additive task's run ADDED or CHANGED under its declared
|
|
604
|
+
* outputs: every selected file that was not in `before`, or whose size or
|
|
605
|
+
* mtime moved. Size + mtime is the proof the hit path already trusts for
|
|
606
|
+
* a current tree, so no new trust is introduced. A file the run rewrote
|
|
607
|
+
* with identical bytes counts as its own (the mtime moved), which is the
|
|
608
|
+
* rewrite-in-place cost the design note records.
|
|
609
|
+
*/
|
|
610
|
+
export async function ownOutputsSince(
|
|
611
|
+
args: { projectDir: string; outputs: string[]; nestedProjectDirs: string[] },
|
|
612
|
+
before: ReadonlyMap<string, OutputStamp>,
|
|
613
|
+
): Promise<string[]> {
|
|
614
|
+
const after = await resolveOutputs(args)
|
|
615
|
+
const own: string[] = []
|
|
616
|
+
for (const f of after) {
|
|
617
|
+
const was = before.get(f)
|
|
618
|
+
if (was === undefined) {
|
|
619
|
+
own.push(f)
|
|
620
|
+
continue
|
|
621
|
+
}
|
|
622
|
+
try {
|
|
623
|
+
const st = lstatSync(f)
|
|
624
|
+
if (st.size !== was.size || st.mtimeMs !== was.mtimeMs) own.push(f)
|
|
625
|
+
} catch {
|
|
626
|
+
// Vanished since the walk: not an output.
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return own
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* A declared output the process cannot remove (a `dist/` another user
|
|
634
|
+
* wrote, a read-only checkout) is the environment's failure, not vx's:
|
|
635
|
+
* the scheduler prints any other error as an "internal error", which
|
|
636
|
+
* sends the reader to file a bug against a permission bit.
|
|
637
|
+
*/
|
|
638
|
+
async function removeAll(files: readonly string[], root: string): Promise<void> {
|
|
639
|
+
await Promise.all(
|
|
640
|
+
files.map((f) =>
|
|
641
|
+
rm(f, { force: true }).catch((err: NodeJS.ErrnoException) => {
|
|
642
|
+
const rel = path.relative(root, f).split(path.sep).join('/')
|
|
643
|
+
throw new UserError(
|
|
644
|
+
`cannot remove declared output ${rel}: ${err.code ?? err.message} — vx clears a task's ` +
|
|
645
|
+
`declared outputs before it runs and before a restore; make the path removable ` +
|
|
646
|
+
`by this user, or stop declaring it as an output`,
|
|
647
|
+
)
|
|
648
|
+
}),
|
|
649
|
+
),
|
|
650
|
+
)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Remove the directories a clean emptied, bottom-up, never `root` itself.
|
|
655
|
+
* A restore writes files where the cached entry has them; a directory left
|
|
656
|
+
* standing where the entry holds a FILE of the same name (a task that once
|
|
657
|
+
* wrote `dist/out/…` and now writes `dist/out`) blocks the rename, and an
|
|
658
|
+
* empty directory is not an output anyone declared. A directory that still
|
|
659
|
+
* holds something — a stray the globs do not cover — stays, and the restore
|
|
660
|
+
* says so if it is in the way.
|
|
661
|
+
*/
|
|
662
|
+
async function pruneEmptiedDirs(root: string, removed: readonly string[]): Promise<void> {
|
|
663
|
+
const rootResolved = path.resolve(root)
|
|
664
|
+
// LEVEL ORDER, not a walk-up per directory. A parent is attempted only
|
|
665
|
+
// once every one of its children has had its turn, which is what makes
|
|
666
|
+
// "the last child empties it" work — and it costs ONE rmdir per
|
|
667
|
+
// directory rather than one per directory PER CHILD.
|
|
668
|
+
//
|
|
669
|
+
// Measured (200 dirs x 20 files, min of 7, three interleaved passes):
|
|
670
|
+
// a walk-up with a `tried` memo is 46 ms but leaves an emptied parent
|
|
671
|
+
// standing when a sibling's turn came first; the same walk-up without
|
|
672
|
+
// the memo is correct and 60 ms, because every child re-attempts the
|
|
673
|
+
// shared parent. This is correct at 44 ms.
|
|
674
|
+
let level = new Set(removed.map((f) => path.dirname(f)))
|
|
675
|
+
while (level.size > 0) {
|
|
676
|
+
const parents = new Set<string>()
|
|
677
|
+
await Promise.all(
|
|
678
|
+
[...level].map(async (dir) => {
|
|
679
|
+
if (dir === rootResolved || !dir.startsWith(rootResolved + path.sep)) return
|
|
680
|
+
const gone = await rmdir(dir).then(
|
|
681
|
+
() => true,
|
|
682
|
+
(err: NodeJS.ErrnoException) => err.code === 'ENOENT',
|
|
683
|
+
)
|
|
684
|
+
if (gone) parents.add(path.dirname(dir))
|
|
685
|
+
}),
|
|
686
|
+
)
|
|
687
|
+
level = parents
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
364
691
|
/**
|
|
365
692
|
* Resolve declared `outputs.workspaceFiles` globs (workspace-root-
|
|
366
693
|
* relative) to actual produced files. Live-FS glob like
|
|
@@ -373,7 +700,7 @@ export async function resolveWorkspaceOutputs(args: {
|
|
|
373
700
|
outputs: string[]
|
|
374
701
|
}): Promise<string[]> {
|
|
375
702
|
if (args.outputs.length === 0) return []
|
|
376
|
-
const scanned = [...(await scanUnion(args.outputs, [], args.workspaceRoot))]
|
|
703
|
+
const scanned = [...(await scanUnion(asTrees(args.outputs), [], args.workspaceRoot))]
|
|
377
704
|
// Same containment as the project twin, anchored one level out. These globs
|
|
378
705
|
// deliberately ignore PROJECT boundaries — that is the escape hatch — but
|
|
379
706
|
// escaping the WORKSPACE was never part of it, and `cleanWorkspaceOutputs`
|
|
@@ -394,182 +721,27 @@ export async function cleanWorkspaceOutputs(args: {
|
|
|
394
721
|
outputs: string[]
|
|
395
722
|
}): Promise<string[]> {
|
|
396
723
|
const files = await resolveWorkspaceOutputs(args)
|
|
397
|
-
await
|
|
724
|
+
await removeAll(files, args.workspaceRoot)
|
|
725
|
+
await pruneEmptiedDirs(args.workspaceRoot, files)
|
|
398
726
|
return files.map((f) => path.relative(args.workspaceRoot, f).split(path.sep).join('/'))
|
|
399
727
|
}
|
|
400
728
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
* staleness bookkeeping that lets the warm path avoid re-spawning git.
|
|
404
|
-
*
|
|
405
|
-
* After a cache-hit restore we know EXACTLY which paths changed on
|
|
406
|
-
* disk: the declared outputs `cleanOutputs` wiped plus the artifact's
|
|
407
|
-
* output files. `markOutputsChanged` records them; `snapshotFor`
|
|
408
|
-
* hands back the existing snapshot when a resolving task's input
|
|
409
|
-
* globs can't match any changed path — provably identical to what a
|
|
410
|
-
* re-spawn would return, since glob matching ignores gitignore status
|
|
411
|
-
* entirely when the path doesn't match. When globs DO overlap,
|
|
412
|
-
* returning undefined forces the caller down the re-spawn path so
|
|
413
|
-
* gitignore semantics stay byte-identical.
|
|
414
|
-
*
|
|
415
|
-
* The cache-miss save path still uses plain `delete` — an executed
|
|
416
|
-
* task may write files outside its declared outputs, and only git can
|
|
417
|
-
* see those.
|
|
418
|
-
*/
|
|
419
|
-
export class GitFilesCache extends Map<string, readonly string[]> {
|
|
420
|
-
private changed = new Map<string, string[]>()
|
|
421
|
-
/**
|
|
422
|
-
* Per-project trusted index OIDs: absolute path → git blob OID, for
|
|
423
|
-
* tracked regular files whose working-tree state matched the index
|
|
424
|
-
* at populate time (per one `git status --porcelain` snapshot).
|
|
425
|
-
* These feed `Cache.key` via `CacheKeyInput.fileHashes` so a clean
|
|
426
|
-
* tree derives input hashes with zero reads / stats / SQLite.
|
|
427
|
-
*
|
|
428
|
-
* Dropped wholesale on `set` / `delete`: a mid-run re-enumeration
|
|
429
|
-
* proves the project's tree changed, and index OIDs can't be
|
|
430
|
-
* re-trusted without a fresh status — the per-file fallback
|
|
431
|
-
* (`Cache.hashFile`) computes the identical blob OID from disk, so
|
|
432
|
-
* dropping is a pure perf concession, never a correctness one.
|
|
433
|
-
*/
|
|
434
|
-
private oids = new Map<string, Map<string, string>>()
|
|
435
|
-
/**
|
|
436
|
-
* Set when `populateGitFilesCache` stored a WORKSPACE-WIDE partition
|
|
437
|
-
* (any loaded task declares `inputs.workspaceFiles`). Null when the
|
|
438
|
-
* feature is unused — every workspace-partition hook below is then a
|
|
439
|
-
* no-op, so unused-feature behavior stays byte-identical.
|
|
440
|
-
*/
|
|
441
|
-
private wsRoot: string | null = null
|
|
442
|
-
/**
|
|
443
|
-
* Whether the worktree had any uncommitted changes at populate time,
|
|
444
|
-
* derived from the SAME `git status --porcelain` spawn that prunes
|
|
445
|
-
* dirty paths from the trusted-OID set. Lets the Tier-3 invocation
|
|
446
|
-
* record report `dirty` without a SECOND status spawn. `null` until
|
|
447
|
-
* populate runs, or when the status spawn failed (non-repo).
|
|
448
|
-
*/
|
|
449
|
-
private dirty: boolean | null = null
|
|
450
|
-
|
|
451
|
-
setWorkspaceRoot(root: string): void {
|
|
452
|
-
this.wsRoot = root
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/** Aggregate worktree dirtiness from the populate-time status spawn. */
|
|
456
|
-
get worktreeDirty(): boolean | null {
|
|
457
|
-
return this.dirty
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
setWorktreeDirty(dirty: boolean | null): void {
|
|
461
|
-
this.dirty = dirty
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
markOutputsChanged(projectDir: string, relPaths: readonly string[]): void {
|
|
465
|
-
this.recordChanged(projectDir, relPaths)
|
|
466
|
-
// The workspace-wide partition sees the same files under
|
|
467
|
-
// root-relative names; forward so a downstream workspaceFiles
|
|
468
|
-
// task can't reuse a snapshot its globs could now contradict.
|
|
469
|
-
if (this.wsRoot !== null && this.wsRoot !== projectDir) {
|
|
470
|
-
this.recordChanged(
|
|
471
|
-
this.wsRoot,
|
|
472
|
-
relPaths.map((rel) =>
|
|
473
|
-
path.relative(this.wsRoot!, path.resolve(projectDir, rel)).split(path.sep).join('/'),
|
|
474
|
-
),
|
|
475
|
-
)
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/**
|
|
480
|
-
* Record root-anchored changed paths (cleaned/restored workspace
|
|
481
|
-
* outputs) against EVERY partition that can see them: the workspace
|
|
482
|
-
* partition under their root-relative names, and any project
|
|
483
|
-
* partition whose dir contains them (workspace outputs may land
|
|
484
|
-
* inside other projects' dirs — the no-boundary escape hatch).
|
|
485
|
-
*/
|
|
486
|
-
markWorkspaceOutputsChanged(workspaceRoot: string, relPaths: readonly string[]): void {
|
|
487
|
-
if (relPaths.length === 0) return
|
|
488
|
-
for (const key of this.keys()) {
|
|
489
|
-
if (key === workspaceRoot) {
|
|
490
|
-
this.recordChanged(key, relPaths)
|
|
491
|
-
continue
|
|
492
|
-
}
|
|
493
|
-
const under: string[] = []
|
|
494
|
-
for (const rel of relPaths) {
|
|
495
|
-
const abs = path.resolve(workspaceRoot, rel)
|
|
496
|
-
if (abs.startsWith(key + path.sep)) {
|
|
497
|
-
under.push(path.relative(key, abs).split(path.sep).join('/'))
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
if (under.length > 0) this.recordChanged(key, under)
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/**
|
|
505
|
-
* Drop the workspace-wide partition (if one exists). Called after a
|
|
506
|
-
* cache-miss save — an executed task may have written undeclared
|
|
507
|
-
* files anywhere in its project dir, and the workspace partition
|
|
508
|
-
* spans that subtree; only a fresh enumeration can see them. No-op
|
|
509
|
-
* when the feature is unused.
|
|
510
|
-
*/
|
|
511
|
-
invalidateWorkspacePartition(): void {
|
|
512
|
-
if (this.wsRoot !== null) this.delete(this.wsRoot)
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
private recordChanged(partitionDir: string, relPaths: readonly string[]): void {
|
|
516
|
-
if (!this.has(partitionDir)) return
|
|
517
|
-
const cur = this.changed.get(partitionDir)
|
|
518
|
-
if (cur) cur.push(...relPaths)
|
|
519
|
-
else this.changed.set(partitionDir, [...relPaths])
|
|
520
|
-
// These paths just changed on disk; their index OIDs (if any) no
|
|
521
|
-
// longer describe the working-tree content.
|
|
522
|
-
const partitionOids = this.oids.get(partitionDir)
|
|
523
|
-
if (partitionOids) {
|
|
524
|
-
for (const rel of relPaths) partitionOids.delete(path.resolve(partitionDir, rel))
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
/** Trusted index OIDs for a project (abs path → oid), if any survive. */
|
|
529
|
-
oidsFor(projectDir: string): ReadonlyMap<string, string> | undefined {
|
|
530
|
-
return this.oids.get(projectDir)
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
setOids(projectDir: string, oids: Map<string, string>): void {
|
|
534
|
-
this.oids.set(projectDir, oids)
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
/** Snapshot if still valid for these input globs; undefined → re-spawn. */
|
|
538
|
-
snapshotFor(projectDir: string, inputGlobs: readonly Bun.Glob[]): readonly string[] | undefined {
|
|
539
|
-
const snap = this.get(projectDir)
|
|
540
|
-
if (snap === undefined) return undefined
|
|
541
|
-
const pending = this.changed.get(projectDir)
|
|
542
|
-
if (pending !== undefined && pending.some((p) => inputGlobs.some((g) => g.match(p)))) {
|
|
543
|
-
return undefined
|
|
544
|
-
}
|
|
545
|
-
return snap
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
override set(key: string, value: readonly string[]): this {
|
|
549
|
-
this.changed.delete(key)
|
|
550
|
-
this.oids.delete(key)
|
|
551
|
-
return super.set(key, value)
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
override delete(key: string): boolean {
|
|
555
|
-
this.changed.delete(key)
|
|
556
|
-
this.oids.delete(key)
|
|
557
|
-
return super.delete(key)
|
|
558
|
-
}
|
|
729
|
+
function stripTrailingSlash(p: string): string {
|
|
730
|
+
return p.replace(/\/+$/, '')
|
|
559
731
|
}
|
|
560
732
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
gitFilesCache?: GitFilesCache
|
|
568
|
-
}
|
|
733
|
+
// The literal-is-a-tree rule moved to `util/paths.ts` (item 442): it
|
|
734
|
+
// decides what a clean deletes as well as what a key folds, and the
|
|
735
|
+
// graph's overlapping-output refusal — which may not import `cache` —
|
|
736
|
+
// has to read the same one. Re-exported here so the cache contract is
|
|
737
|
+
// unchanged.
|
|
738
|
+
export { asTrees } from '../util/index.js'
|
|
569
739
|
|
|
570
|
-
/**
|
|
571
|
-
function
|
|
572
|
-
|
|
740
|
+
/** A literal is answered by the path itself or by anything under it. */
|
|
741
|
+
function settleLiterals(unmatched: Set<string>, rel: string): void {
|
|
742
|
+
for (const lit of unmatched) {
|
|
743
|
+
if (rel === lit || rel.startsWith(`${lit}/`)) unmatched.delete(lit)
|
|
744
|
+
}
|
|
573
745
|
}
|
|
574
746
|
|
|
575
747
|
/**
|
|
@@ -593,6 +765,13 @@ function isLiteralPath(glob: string): boolean {
|
|
|
593
765
|
* An entry that does NOT exist on disk stays silent: that is an ordinary stale
|
|
594
766
|
* declaration, and refusing it would break every config that lists an
|
|
595
767
|
* optional file.
|
|
768
|
+
*
|
|
769
|
+
* "Exists" is lstat, not `Bun.file(p).exists()`: that answers false for a
|
|
770
|
+
* DIRECTORY, so a literal naming a gitignored `gen/` was never judged and
|
|
771
|
+
* folded nothing in silence — the stale hit this refusal exists to stop, one
|
|
772
|
+
* directory above where it looked (item 565, fixed in 576). A literal naming
|
|
773
|
+
* an EMPTY directory is refused by the same rule, and that is right: git
|
|
774
|
+
* lists no file under it, so it folds nothing whether tracked or not.
|
|
596
775
|
*/
|
|
597
776
|
async function assertNoInvisibleLiteralInputs(
|
|
598
777
|
literals: ReadonlySet<string>,
|
|
@@ -600,7 +779,7 @@ async function assertNoInvisibleLiteralInputs(
|
|
|
600
779
|
field: 'files' | 'workspaceFiles' = 'files',
|
|
601
780
|
): Promise<void> {
|
|
602
781
|
for (const rel of literals) {
|
|
603
|
-
if (!(
|
|
782
|
+
if (!existsOnDisk(path.resolve(base, rel))) continue
|
|
604
783
|
throw new UserError(
|
|
605
784
|
`cache.inputs.${field}: "${rel}" exists in ${base} but git does not report it, ` +
|
|
606
785
|
`so it contributes NOTHING to the cache key — input globs filter the files git ` +
|
|
@@ -612,6 +791,54 @@ async function assertNoInvisibleLiteralInputs(
|
|
|
612
791
|
}
|
|
613
792
|
}
|
|
614
793
|
|
|
794
|
+
interface ResolveFilesArgs {
|
|
795
|
+
projectDir: string
|
|
796
|
+
workspaceRoot: string
|
|
797
|
+
files: string[] | undefined
|
|
798
|
+
ownOutputs: string[]
|
|
799
|
+
nestedProjectDirs: string[]
|
|
800
|
+
gitFilesCache?: GitFilesCache
|
|
801
|
+
projectFilesCache?: ProjectFilesCache
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Record the names a fresh `git ls-files` could not decode, in the run's
|
|
806
|
+
* cache when there is one, and return the set to check against.
|
|
807
|
+
*/
|
|
808
|
+
function noteUndecodable(
|
|
809
|
+
cache: GitFilesCache | undefined,
|
|
810
|
+
dir: string,
|
|
811
|
+
rels: readonly string[],
|
|
812
|
+
): ReadonlySet<string> {
|
|
813
|
+
const abs = rels.map((rel) => path.join(dir, rel))
|
|
814
|
+
if (cache === undefined) return new Set(abs)
|
|
815
|
+
cache.markUndecodable(abs)
|
|
816
|
+
return cache.undecodableNames
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* An input whose name is not UTF-8 is one vx cannot open (a string cannot
|
|
821
|
+
* spell it), so it could only drop out of the key, where an edit to it is a
|
|
822
|
+
* hit. Named instead, with the way out. One git still lists after it left
|
|
823
|
+
* the disk (a tracked file deleted) drops out as any deleted file does.
|
|
824
|
+
*/
|
|
825
|
+
function refuseUndecodable(
|
|
826
|
+
candidates: readonly string[],
|
|
827
|
+
undecodable: ReadonlySet<string> | undefined,
|
|
828
|
+
root: string,
|
|
829
|
+
field: 'files' | 'workspaceFiles',
|
|
830
|
+
): void {
|
|
831
|
+
if (undecodable === undefined || undecodable.size === 0) return
|
|
832
|
+
const bad = candidates.filter((abs) => undecodable.has(abs) && undecodableOnDisk(abs))
|
|
833
|
+
if (bad.length === 0) return
|
|
834
|
+
const names = bad.map((abs) => JSON.stringify(path.relative(root, abs).split(path.sep).join('/')))
|
|
835
|
+
throw new UserError(
|
|
836
|
+
`cache.inputs.${field} matched ${names.join(', ')} in ${root}: the name is not valid UTF-8 ` +
|
|
837
|
+
`(shown with \ufffd), and vx cannot read a file by it. Rename it, or exclude it with a ` +
|
|
838
|
+
`negated glob.`,
|
|
839
|
+
)
|
|
840
|
+
}
|
|
841
|
+
|
|
615
842
|
async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
616
843
|
const positive: string[] = []
|
|
617
844
|
const negative: string[] = []
|
|
@@ -628,9 +855,12 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
628
855
|
if (positive.length === 0) return []
|
|
629
856
|
|
|
630
857
|
const boundaryIgnores = boundaryIgnorePatterns(args.projectDir, args.nestedProjectDirs)
|
|
631
|
-
const excludeGlobs = [
|
|
632
|
-
|
|
633
|
-
|
|
858
|
+
const excludeGlobs = [
|
|
859
|
+
...ALWAYS_IGNORE,
|
|
860
|
+
...boundaryIgnores,
|
|
861
|
+
...asTrees(args.ownOutputs),
|
|
862
|
+
...asTrees(negative),
|
|
863
|
+
].map(globFor)
|
|
634
864
|
|
|
635
865
|
// Defer to git for the file set (Turbo / Nx parity). Nested .gitignore
|
|
636
866
|
// files, .git/info/exclude, and global excludes all participate
|
|
@@ -640,17 +870,34 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
640
870
|
// per task (build + test + lint + …). Spawning git N times for the
|
|
641
871
|
// same project per run is wasteful; we cache the result for the
|
|
642
872
|
// duration of one orchestrator run.
|
|
643
|
-
const positiveGlobs = positive.map(globFor)
|
|
873
|
+
const positiveGlobs = asTrees(positive).map(globFor)
|
|
874
|
+
// Everything below the snapshot that decides the result: the project, what
|
|
875
|
+
// it declares, what it excludes as its own outputs, and the boundaries.
|
|
876
|
+
const memoKey = `${args.projectDir}\0${positive.join('\u0001')}\0${negative.join('\u0001')}\0${args.ownOutputs.join('\u0001')}\0${boundaryIgnores.join('\u0001')}`
|
|
644
877
|
let gitFiles = args.gitFilesCache?.snapshotFor(args.projectDir, positiveGlobs)
|
|
878
|
+
let undecodable = args.gitFilesCache?.undecodableNames
|
|
879
|
+
if (gitFiles !== undefined) {
|
|
880
|
+
const memo = args.projectFilesCache?.get(memoKey)
|
|
881
|
+
// Identity, not equality: a re-enumeration hands back a new array even
|
|
882
|
+
// when the file set is unchanged, and that is exactly when this task's
|
|
883
|
+
// inputs must be walked again.
|
|
884
|
+
if (memo !== undefined && memo.snapshot === gitFiles) return [...memo.result]
|
|
885
|
+
}
|
|
645
886
|
if (gitFiles === undefined) {
|
|
646
|
-
// Mid-run re-enumeration
|
|
887
|
+
// Mid-run re-enumeration — or a project the workspace-wide populate
|
|
888
|
+
// left without a partition because the workspace's git did not see its
|
|
889
|
+
// directory (a submodule, an embedded repository); spawned in the
|
|
890
|
+
// project dir, git answers from the nested repository.
|
|
891
|
+
// The OIDs this spawn could yield are NOT
|
|
647
892
|
// trusted (no fresh `git status` to vouch for them — the project's
|
|
648
893
|
// tree just changed); set() drops the project's OID slot and these
|
|
649
894
|
// files fall back to Cache.hashFile, which computes the identical
|
|
650
895
|
// blob OID from disk.
|
|
651
|
-
|
|
896
|
+
const ls = runGitLsFiles(args.projectDir)
|
|
897
|
+
gitFiles = ls.files
|
|
652
898
|
// set() also clears the project's pending-changed bookkeeping.
|
|
653
899
|
args.gitFilesCache?.set(args.projectDir, gitFiles)
|
|
900
|
+
undecodable = noteUndecodable(args.gitFilesCache, args.projectDir, ls.undecodable)
|
|
654
901
|
}
|
|
655
902
|
// A LITERAL entry — one with no glob metacharacter — names exactly one file,
|
|
656
903
|
// so "this matched nothing" is unambiguous. For a glob it is not: matching
|
|
@@ -663,11 +910,13 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
663
910
|
// an artifact built from an older version of a file the config explicitly
|
|
664
911
|
// claims as an input. See the refusal below for why this is not simply
|
|
665
912
|
// honoured instead.
|
|
666
|
-
const unmatchedLiterals = new Set(
|
|
913
|
+
const unmatchedLiterals = new Set(
|
|
914
|
+
positive.map(normalizeGlob).filter(isLiteralPattern).map(stripTrailingSlash),
|
|
915
|
+
)
|
|
667
916
|
// First pass: glob-filter to candidate absolute paths (no I/O).
|
|
668
917
|
const candidates: string[] = []
|
|
669
918
|
for (const rel of gitFiles) {
|
|
670
|
-
if (unmatchedLiterals.size > 0) unmatchedLiterals
|
|
919
|
+
if (unmatchedLiterals.size > 0) settleLiterals(unmatchedLiterals, rel)
|
|
671
920
|
let matched = false
|
|
672
921
|
for (const g of positiveGlobs) {
|
|
673
922
|
if (g.match(rel)) {
|
|
@@ -682,528 +931,94 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
682
931
|
if (unmatchedLiterals.size > 0) {
|
|
683
932
|
await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.projectDir, 'files')
|
|
684
933
|
}
|
|
685
|
-
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
// ENOENT.
|
|
934
|
+
refuseUndecodable(candidates, undecodable, args.projectDir, 'files')
|
|
935
|
+
// Second pass: existence check — but ONLY for paths without a
|
|
936
|
+
// trusted index OID. A clean-per-status tracked file necessarily
|
|
937
|
+
// exists on disk, so skipping its probe keeps the warm path free of
|
|
938
|
+
// per-file syscalls. Paths without an OID keep the probe:
|
|
939
|
+
// `git ls-files -s` can surface staged entries whose working-tree
|
|
940
|
+
// file is gone; the hasher would otherwise throw ENOENT.
|
|
692
941
|
const oids = args.gitFilesCache?.oidsFor(args.projectDir)
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
if (exists[i]) matches.push(candidates[i]!)
|
|
699
|
-
}
|
|
700
|
-
return matches.sort()
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
interface GitLsResult {
|
|
704
|
-
/** cwd-relative paths — same visibility set as `--cached --others`. */
|
|
705
|
-
files: string[]
|
|
706
|
-
/**
|
|
707
|
-
* cwd-relative path → index blob OID, for tracked REGULAR files
|
|
708
|
-
* (mode 100644 / 100755) at stage 0 only. Symlinks are excluded
|
|
709
|
-
* (their OID hashes the link-target string, not the dereferenced
|
|
710
|
-
* content our fallback hasher would read); merge-conflict stages
|
|
711
|
-
* and gitlinks are excluded too. NOT yet filtered by working-tree
|
|
712
|
-
* dirtiness — callers intersect with `git status` before trusting.
|
|
713
|
-
*/
|
|
714
|
-
oids: Map<string, string>
|
|
715
|
-
/** Paths flagged skip-worktree / assume-unchanged (only when `-v` was passed). */
|
|
716
|
-
flagged: Set<string>
|
|
942
|
+
const resolved = candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
|
|
943
|
+
// Stored only on the way out: a declaration whose literal named an
|
|
944
|
+
// invisible file threw above, and every task sharing it must throw too.
|
|
945
|
+
args.projectFilesCache?.set(memoKey, { snapshot: gitFiles, result: resolved })
|
|
946
|
+
return [...resolved]
|
|
717
947
|
}
|
|
718
948
|
|
|
719
|
-
// `<mode> <oid> <stage>\t<path>` — the staged-entry form of
|
|
720
|
-
// `ls-files -s`. `--others` paths print bare; with `-z`,
|
|
721
|
-
// core.quotePath quoting is off, so a bare path containing a literal
|
|
722
|
-
// tab still can't match this fixed-form prefix.
|
|
723
|
-
// `ls-files -s -v`: an optional cache-state flag (`H`, `S`, `h`, …) then the
|
|
724
|
-
// stage record. Both answers come from ONE spawn.
|
|
725
|
-
const LS_FILES_STAGE_RE = /^(?:([A-Za-z]) )?([0-7]{6}) ([0-9a-f]{40,64}) ([0-3])\t/
|
|
726
|
-
|
|
727
949
|
/**
|
|
728
|
-
*
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
* Turbo-parity "hashes come from git's index" fast path. Throws a
|
|
734
|
-
* `UserError` when git is unavailable or `cwd` isn't a git work
|
|
735
|
-
* tree; vx requires git. `-z` survives filenames with newlines /
|
|
736
|
-
* spaces.
|
|
737
|
-
*/
|
|
738
|
-
function runGitLsFiles(cwd: string): GitLsResult {
|
|
739
|
-
let proc
|
|
740
|
-
try {
|
|
741
|
-
proc = Bun.spawnSync({
|
|
742
|
-
cmd: ['git', 'ls-files', '-s', '--others', '--exclude-standard', '-z', '.'],
|
|
743
|
-
cwd,
|
|
744
|
-
stdout: 'pipe',
|
|
745
|
-
stderr: 'pipe',
|
|
746
|
-
})
|
|
747
|
-
} catch {
|
|
748
|
-
throw new UserError(
|
|
749
|
-
`vx requires git: failed to spawn 'git' (working dir: ${cwd}). Install git and re-run.`,
|
|
750
|
-
)
|
|
751
|
-
}
|
|
752
|
-
if (proc.exitCode !== 0) {
|
|
753
|
-
// Exit 128 = not a git work tree; other non-zero = git failure.
|
|
754
|
-
// Either way we can't enumerate inputs reliably.
|
|
755
|
-
const stderr = new TextDecoder().decode(proc.stderr).trim()
|
|
756
|
-
throw new UserError(
|
|
757
|
-
`vx requires git: ${cwd} is not inside a git work tree. ` +
|
|
758
|
-
`Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
|
|
759
|
-
)
|
|
760
|
-
}
|
|
761
|
-
return parseLsFilesOutput(new TextDecoder().decode(proc.stdout))
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
function parseLsFilesOutput(out: string): GitLsResult {
|
|
765
|
-
const files: string[] = []
|
|
766
|
-
const oids = new Map<string, string>()
|
|
767
|
-
const flagged = new Set<string>()
|
|
768
|
-
if (out.length === 0) return { files, oids, flagged }
|
|
769
|
-
// NUL-separated; trailing NUL produces an empty segment we skip.
|
|
770
|
-
for (const record of out.split('\0')) {
|
|
771
|
-
if (record.length === 0) continue
|
|
772
|
-
const m = LS_FILES_STAGE_RE.exec(record)
|
|
773
|
-
if (m === null) {
|
|
774
|
-
files.push(record) // --others entry: bare path
|
|
775
|
-
continue
|
|
776
|
-
}
|
|
777
|
-
const filePath = record.slice(m[0].length)
|
|
778
|
-
files.push(filePath)
|
|
779
|
-
const mode = m[2]!
|
|
780
|
-
const stage = m[4]!
|
|
781
|
-
if ((mode === '100644' || mode === '100755') && stage === '0') {
|
|
782
|
-
oids.set(filePath, m[3]!)
|
|
783
|
-
}
|
|
784
|
-
// A LOWERCASE letter means skip-worktree or assume-unchanged (`S` is the
|
|
785
|
-
// older spelling of skip-worktree): git has been told to stop looking at
|
|
786
|
-
// the worktree file, so its index OID says nothing about the disk.
|
|
787
|
-
const flag = m[1]
|
|
788
|
-
if (flag !== undefined && (flag === 'S' || (flag >= 'a' && flag <= 'z'))) flagged.add(filePath)
|
|
789
|
-
}
|
|
790
|
-
return { files, oids, flagged }
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
/** One completed `git` invocation. */
|
|
794
|
-
interface GitRun {
|
|
795
|
-
exitCode: number
|
|
796
|
-
stdout: string
|
|
797
|
-
stderr: string
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
/**
|
|
801
|
-
* Parse `git check-attr -z text eol ident` output — a flat stream of
|
|
802
|
-
* `<path>\0<attr>\0<value>\0` triples — into the set of paths where a clean
|
|
803
|
-
* filter can rewrite bytes. `unspecified` means no rule matched and
|
|
804
|
-
* `unset` (`-text`) explicitly disables conversion; both leave the index blob
|
|
805
|
-
* byte-identical to the worktree file, so those OIDs stay trusted.
|
|
806
|
-
*/
|
|
807
|
-
export function parseCheckAttrOutput(out: string): Set<string> {
|
|
808
|
-
const affected = new Set<string>()
|
|
809
|
-
const fields = out.split('\0')
|
|
810
|
-
for (let i = 0; i + 2 < fields.length; i += 3) {
|
|
811
|
-
const value = fields[i + 2]!
|
|
812
|
-
if (value !== 'unspecified' && value !== 'unset') affected.add(fields[i]!)
|
|
813
|
-
}
|
|
814
|
-
return affected
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
/**
|
|
818
|
-
* Remove from `trusted` every path whose index blob may differ from its
|
|
819
|
-
* worktree bytes because a clean filter applies.
|
|
820
|
-
*
|
|
821
|
-
* Gated in two steps so the common case pays NOTHING. The precise answer —
|
|
822
|
-
* `git ls-files --eol`, comparing `i/` to `w/` — was measured at 240 ms on a
|
|
823
|
-
* 15k-file tree because it must READ every worktree file: 13x the entire
|
|
824
|
-
* enumeration, on a run whose warm total is ~130 ms, and in the common Linux
|
|
825
|
-
* case it finds nothing. So instead:
|
|
826
|
-
*
|
|
827
|
-
* 1. If `core.autocrlf` is true/input, conversion applies to every
|
|
828
|
-
* auto-detected text file with no attribute needed — trust nothing.
|
|
829
|
-
* 2. Else, if no attributes source exists anywhere (no in-tree
|
|
830
|
-
* `.gitattributes`, no `$GIT_DIR/info/attributes`, no
|
|
831
|
-
* `core.attributesFile`), no rule can name a filter — return untouched,
|
|
832
|
-
* zero extra work. This is the default `git init` repo.
|
|
833
|
-
* 3. Otherwise ask `git check-attr` (measured 21 ms; it resolves attributes
|
|
834
|
-
* from the index WITHOUT reading worktree content) and drop only the
|
|
835
|
-
* paths that actually carry `text`/`eol`/`ident`.
|
|
836
|
-
*
|
|
837
|
-
* Never throws: a probe that fails leaves the map as-is, which is exactly the
|
|
838
|
-
* behaviour before this gate existed.
|
|
950
|
+
* `Bun.Glob` decodes a name that is not UTF-8 lossily, so the path it
|
|
951
|
+
* yields names no file and the output dropped out of the artifact without
|
|
952
|
+
* a word: a hit then restored a tree without it (turborepo#9345's output
|
|
953
|
+
* half). A `\ufffd` that does not stat is that case or a file that left
|
|
954
|
+
* mid-scan; `undecodableOnDisk` tells them apart.
|
|
839
955
|
*/
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
coreConfig: string
|
|
846
|
-
spawnGit: (a: string[], stdin?: string) => Promise<GitRun | null>
|
|
847
|
-
},
|
|
848
|
-
): Promise<void> {
|
|
849
|
-
if (trusted.size === 0) return
|
|
850
|
-
if (autocrlfConverts(args.coreConfig)) {
|
|
851
|
-
trusted.clear()
|
|
852
|
-
return
|
|
853
|
-
}
|
|
854
|
-
// Cheap checks first, and nothing is materialized until one of them fires —
|
|
855
|
-
// on a 15k-file repo with no attributes this whole function is a size check,
|
|
856
|
-
// a substring scan of an empty string, one stat, and a key walk that exits
|
|
857
|
-
// on the first `.gitattributes` it does not find.
|
|
858
|
-
let attributesPossible =
|
|
859
|
-
args.coreConfig.toLowerCase().includes('core.attributesfile') ||
|
|
860
|
-
(args.gitDir !== '' &&
|
|
861
|
-
existsSync(path.resolve(args.workspaceRoot, args.gitDir, 'info', 'attributes')))
|
|
862
|
-
if (!attributesPossible) {
|
|
863
|
-
for (const rel of trusted.keys()) {
|
|
864
|
-
if (rel === '.gitattributes' || rel.endsWith('/.gitattributes')) {
|
|
865
|
-
attributesPossible = true
|
|
866
|
-
break
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
if (!attributesPossible) return
|
|
871
|
-
|
|
872
|
-
const res = await args.spawnGit(
|
|
873
|
-
['check-attr', '--stdin', '-z', 'text', 'eol', 'ident'],
|
|
874
|
-
[...trusted.keys()].join('\0'),
|
|
956
|
+
function refuseUndecodableOutput(abs: string): void {
|
|
957
|
+
if (!undecodableOnDisk(abs)) return
|
|
958
|
+
throw new UserError(
|
|
959
|
+
`cache.outputs matched ${JSON.stringify(abs)}: the name is not valid UTF-8 (shown with ` +
|
|
960
|
+
`\ufffd), and vx cannot save a file by it. Rename it, or keep it out of the outputs.`,
|
|
875
961
|
)
|
|
876
|
-
if (res === null || res.exitCode !== 0) return
|
|
877
|
-
for (const rel of parseCheckAttrOutput(res.stdout)) trusted.delete(rel)
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
/** `true` when git may rewrite bytes for EVERY auto-detected text file. */
|
|
881
|
-
export function autocrlfConverts(coreConfig: string): boolean {
|
|
882
|
-
for (const line of coreConfig.split('\n')) {
|
|
883
|
-
const [name, value] = [line.slice(0, line.indexOf(' ')), line.slice(line.indexOf(' ') + 1)]
|
|
884
|
-
if (name.toLowerCase() === 'core.autocrlf') {
|
|
885
|
-
const v = value.trim().toLowerCase()
|
|
886
|
-
return v === 'true' || v === 'input'
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
return false
|
|
890
962
|
}
|
|
891
963
|
|
|
892
964
|
/**
|
|
893
|
-
*
|
|
894
|
-
*
|
|
895
|
-
*
|
|
896
|
-
*
|
|
897
|
-
*
|
|
965
|
+
* Is `abs` (a lossy spelling) something on disk whose name, or a
|
|
966
|
+
* directory's above it, is not UTF-8? From the deepest ancestor a string
|
|
967
|
+
* can reach, read the raw names and look for one that decodes lossily to
|
|
968
|
+
* the next segment and fatally not at all. Only a path that failed to
|
|
969
|
+
* stat reaches here, so the reads are off every warm path.
|
|
898
970
|
*/
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
const
|
|
904
|
-
if (
|
|
971
|
+
function undecodableOnDisk(abs: string): boolean {
|
|
972
|
+
let dir = path.dirname(abs)
|
|
973
|
+
let next = path.basename(abs)
|
|
974
|
+
while (!existsOnDisk(dir)) {
|
|
975
|
+
const parent = path.dirname(dir)
|
|
976
|
+
if (parent === dir) return false
|
|
977
|
+
next = path.basename(dir)
|
|
978
|
+
dir = parent
|
|
905
979
|
}
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
const dirty = new Set<string>()
|
|
912
|
-
const untracked: string[] = []
|
|
913
|
-
for (let i = 0; i < tokens.length; i++) {
|
|
914
|
-
const token = tokens[i]!
|
|
915
|
-
if (token.length < 4) continue
|
|
916
|
-
if (token[0] === '?') {
|
|
917
|
-
untracked.push(token.slice(3))
|
|
918
|
-
continue
|
|
919
|
-
}
|
|
920
|
-
dirty.add(token.slice(3))
|
|
921
|
-
// X = R or C → the next token is the rename/copy source path.
|
|
922
|
-
const x = token[0]
|
|
923
|
-
if ((x === 'R' || x === 'C') && i + 1 < tokens.length && tokens[i + 1]!.length > 0) {
|
|
924
|
-
i++
|
|
925
|
-
dirty.add(tokens[i]!)
|
|
926
|
-
}
|
|
980
|
+
let raw: Buffer[]
|
|
981
|
+
try {
|
|
982
|
+
raw = readdirSync(dir, { encoding: 'buffer' })
|
|
983
|
+
} catch {
|
|
984
|
+
return false
|
|
927
985
|
}
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
/**
|
|
932
|
-
* Strip a repo→workspace `--show-prefix` (e.g. `code/`) from each path, keeping
|
|
933
|
-
* only paths inside the workspace — so a repo-root-relative set (from `git
|
|
934
|
-
* status`/`diff`) is re-keyed workspace-relative to match `git ls-files`. Empty
|
|
935
|
-
* prefix → returns the set unchanged (workspace root IS the git root).
|
|
936
|
-
*/
|
|
937
|
-
function stripPrefixFromSet(paths: Set<string>, prefix: string): Set<string> {
|
|
938
|
-
if (prefix === '') return paths
|
|
939
|
-
const out = new Set<string>()
|
|
940
|
-
for (const p of paths) if (p.startsWith(prefix)) out.add(p.slice(prefix.length))
|
|
941
|
-
return out
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
/**
|
|
945
|
-
* Run `git ls-files` ONCE at the workspace root, then partition the
|
|
946
|
-
* result by project. Populates `cache` for every project in
|
|
947
|
-
* `projectDirs` with project-relative path lists matching what a
|
|
948
|
-
* per-project spawn would have produced. Throws `UserError` if the
|
|
949
|
-
* workspace isn't a git work tree (vx requires git).
|
|
950
|
-
*
|
|
951
|
-
* Why bulk: each spawn costs ~5-10ms (fork+exec). On a 200-project
|
|
952
|
-
* workspace that's 1-2s of pure overhead reclaimed.
|
|
953
|
-
*
|
|
954
|
-
* Files in nested-project subtrees stay in their parent's list — the
|
|
955
|
-
* boundary-ignore globs in `resolveFiles` filter them out the same way
|
|
956
|
-
* they did before. Cheaper to filter once-per-task than to subtract
|
|
957
|
-
* here.
|
|
958
|
-
*
|
|
959
|
-
* v20: the same `ls-files -s` spawn also yields each tracked file's
|
|
960
|
-
* index OID. A second spawn (`git status --porcelain`) prunes paths
|
|
961
|
-
* whose working tree diverges from the index; what survives is
|
|
962
|
-
* stored per project via `cache.setOids` and feeds `Cache.key`
|
|
963
|
-
* directly — clean-tree input hashing costs zero reads/stats/SQLite.
|
|
964
|
-
*
|
|
965
|
-
* `workspaceWide`: set when any loaded task declares
|
|
966
|
-
* `cache.inputs.workspaceFiles`. Disables pathspec scoping (those
|
|
967
|
-
* globs must see every file from the root) and additionally stores a
|
|
968
|
-
* workspace-wide partition keyed by `workspaceRoot` (files + trusted
|
|
969
|
-
* OIDs), which `resolveWorkspaceFiles` consumes. When false, the
|
|
970
|
-
* enumeration behavior — pathspecs, spawn count, stored partitions —
|
|
971
|
-
* is byte-identical to the pre-workspaceFiles code.
|
|
972
|
-
*/
|
|
973
|
-
export async function populateGitFilesCache(
|
|
974
|
-
workspaceRoot: string,
|
|
975
|
-
projectDirs: readonly string[],
|
|
976
|
-
cache: GitFilesCache,
|
|
977
|
-
workspaceWide = false,
|
|
978
|
-
): Promise<void> {
|
|
979
|
-
const enumeration = await startGitEnumeration(
|
|
980
|
-
workspaceRoot,
|
|
981
|
-
gitPathspecs(workspaceRoot, projectDirs, workspaceWide),
|
|
982
|
-
)
|
|
983
|
-
applyGitEnumeration(enumeration, workspaceRoot, projectDirs, cache, workspaceWide)
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
/** What one workspace-wide enumeration learned, before it is partitioned per project. */
|
|
987
|
-
export interface GitEnumeration {
|
|
988
|
-
/** Every enumerated path, workspace-relative, tracked and untracked. */
|
|
989
|
-
all: string[]
|
|
990
|
-
/** Workspace-relative path → index OID, for files whose worktree state matched the index. */
|
|
991
|
-
trusted: Map<string, string>
|
|
992
|
-
/** Whether the worktree had uncommitted changes; null when `git status` failed. */
|
|
993
|
-
dirty: boolean | null
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
/**
|
|
997
|
-
* Pathspec scoping: when the run only needs a handful of projects
|
|
998
|
-
* (scoped config loading), let git scan just those dirs — 75 ms →
|
|
999
|
-
* 11 ms on an 11k-file repo. Above 64 dirs (or when a project IS
|
|
1000
|
-
* the root) the whole-tree scan wins on arg/exec overhead anyway.
|
|
1001
|
-
*/
|
|
1002
|
-
export function gitPathspecs(
|
|
1003
|
-
workspaceRoot: string,
|
|
1004
|
-
projectDirs: readonly string[],
|
|
1005
|
-
workspaceWide: boolean,
|
|
1006
|
-
): string[] {
|
|
1007
|
-
const rels = projectDirs.map((d) => path.relative(workspaceRoot, d).split(path.sep).join('/'))
|
|
1008
|
-
const scoped =
|
|
1009
|
-
!workspaceWide &&
|
|
1010
|
-
rels.length > 0 &&
|
|
1011
|
-
rels.length <= 64 &&
|
|
1012
|
-
rels.every((r) => r !== '' && r !== '.')
|
|
1013
|
-
return scoped ? rels : ['.']
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
/**
|
|
1017
|
-
* The spawn half of `populateGitFilesCache`, separated so an UNSCOPED run
|
|
1018
|
-
* can start it before the configs are evaluated — the enumeration needs
|
|
1019
|
-
* only the pathspecs, and `['.']` is right for every unscoped run — and
|
|
1020
|
-
* overlap ~60 ms of git with ~80 ms of config evaluation on a
|
|
1021
|
-
* 1000-project tree instead of paying them back to back.
|
|
1022
|
-
*/
|
|
1023
|
-
export async function startGitEnumeration(
|
|
1024
|
-
workspaceRoot: string,
|
|
1025
|
-
pathspecs: readonly string[],
|
|
1026
|
-
): Promise<GitEnumeration> {
|
|
1027
|
-
// The spawns are independent — run them concurrently so the
|
|
1028
|
-
// bulk-populate costs max(ls-files, status) wall time, not the sum
|
|
1029
|
-
// (status alone is ~74 ms on a 1000-project tree; serial spawning
|
|
1030
|
-
// was a measurable warm-path regression vs the pre-OID code).
|
|
1031
|
-
const spawnGit = async (args: string[], stdin?: string): Promise<GitRun | null> => {
|
|
986
|
+
const lossy = new TextDecoder()
|
|
987
|
+
for (const name of raw) {
|
|
988
|
+
if (lossy.decode(name) !== next) continue
|
|
1032
989
|
try {
|
|
1033
|
-
|
|
1034
|
-
cmd: ['git', ...args],
|
|
1035
|
-
cwd: workspaceRoot,
|
|
1036
|
-
stdin: stdin === undefined ? 'ignore' : new TextEncoder().encode(stdin),
|
|
1037
|
-
stdout: 'pipe',
|
|
1038
|
-
stderr: 'pipe',
|
|
1039
|
-
})
|
|
1040
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
1041
|
-
new Response(proc.stdout).text(),
|
|
1042
|
-
new Response(proc.stderr).text(),
|
|
1043
|
-
proc.exited,
|
|
1044
|
-
])
|
|
1045
|
-
return { exitCode, stdout, stderr }
|
|
990
|
+
FATAL_UTF8.decode(name)
|
|
1046
991
|
} catch {
|
|
1047
|
-
return
|
|
992
|
+
return true
|
|
1048
993
|
}
|
|
1049
994
|
}
|
|
1050
|
-
|
|
1051
|
-
// (~9 ms on a 1000-project tree) and answers two questions at once: every
|
|
1052
|
-
// tracked path's OID and its cache-state flag. `status -uall` is the one
|
|
1053
|
-
// command that walks the worktree, and it answers two as well: which
|
|
1054
|
-
// tracked paths are dirty AND which files are untracked. Asking
|
|
1055
|
-
// `ls-files --others` for the untracked set walked the same tree a second
|
|
1056
|
-
// time (~50 ms of CPU, concurrent with status but contending with it).
|
|
1057
|
-
const [ls, status, prefixRes, coreCfg] = await Promise.all([
|
|
1058
|
-
spawnGit(['ls-files', '-s', '-v', '-z', '--', ...pathspecs]),
|
|
1059
|
-
spawnGit(['status', '--porcelain', '-z', '-uall', '--', ...pathspecs]),
|
|
1060
|
-
// Two answers from one spawn. `--show-prefix` is the repo→workspace path
|
|
1061
|
-
// (empty when the workspace root IS the git root): `ls-files` prints
|
|
1062
|
-
// cwd(workspace)-relative paths but `status` prints repo-root-relative
|
|
1063
|
-
// ones, so when the workspace root is a SUBDIR of the git repo the two
|
|
1064
|
-
// disagree; this lets us key both the same way below. `--git-dir` locates
|
|
1065
|
-
// `info/attributes` for the filter gate (it is not always `.git/` — a
|
|
1066
|
-
// linked worktree's `.git` is a FILE pointing elsewhere).
|
|
1067
|
-
spawnGit(['rev-parse', '--show-prefix', '--git-dir']),
|
|
1068
|
-
// Reads three config keys, no tree scan — the gate for whether a clean
|
|
1069
|
-
// filter can rewrite bytes between the index and the worktree. Exits 1
|
|
1070
|
-
// when none are set, which is the common case and means "no gate".
|
|
1071
|
-
spawnGit(['config', '--get-regexp', '^core\\.(autocrlf|eol|attributesfile)$']),
|
|
1072
|
-
])
|
|
1073
|
-
if (ls === null) {
|
|
1074
|
-
throw new UserError(
|
|
1075
|
-
`vx requires git: failed to spawn 'git' (working dir: ${workspaceRoot}). Install git and re-run.`,
|
|
1076
|
-
)
|
|
1077
|
-
}
|
|
1078
|
-
if (ls.exitCode !== 0) {
|
|
1079
|
-
const stderr = ls.stderr.trim()
|
|
1080
|
-
throw new UserError(
|
|
1081
|
-
`vx requires git: ${workspaceRoot} is not inside a git work tree. ` +
|
|
1082
|
-
`Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
|
|
1083
|
-
)
|
|
1084
|
-
}
|
|
1085
|
-
const { files: tracked, oids, flagged } = parseLsFilesOutput(ls.stdout)
|
|
1086
|
-
// Normalize `status`'s repo-root-relative paths to workspace-relative (strip
|
|
1087
|
-
// the `--show-prefix`) so the dirty set is keyed identically to the trusted
|
|
1088
|
-
// OID map. Without this, when the workspace root is a git subdir, a modified
|
|
1089
|
-
// tracked file is never pruned from `trusted` and keeps its committed OID —
|
|
1090
|
-
// a STALE cache hit serving old outputs. Empty prefix (workspace == git root,
|
|
1091
|
-
// the common case) is a zero-cost no-op. Paths above the workspace can't be
|
|
1092
|
-
// inputs, so they drop out of the set.
|
|
1093
|
-
// One spawn, two lines: `--show-prefix` then `--git-dir`.
|
|
1094
|
-
const revLines =
|
|
1095
|
-
prefixRes !== null && prefixRes.exitCode === 0 ? prefixRes.stdout.split('\n') : []
|
|
1096
|
-
const gitPrefix = (revLines[0] ?? '').trim()
|
|
1097
|
-
const gitDir = (revLines[1] ?? '').trim()
|
|
1098
|
-
const parsedStatus =
|
|
1099
|
-
status !== null && status.exitCode === 0 ? parseStatusOutput(status.stdout) : null
|
|
1100
|
-
const dirty = parsedStatus === null ? null : stripPrefixFromSet(parsedStatus.dirty, gitPrefix)
|
|
1101
|
-
// Untracked files are inputs too (a new file is the commonest edit there
|
|
1102
|
-
// is). They come from the status walk, repo-root-relative like the dirty
|
|
1103
|
-
// set. Without a status answer the enumeration is the index alone.
|
|
1104
|
-
const untracked =
|
|
1105
|
-
parsedStatus === null ? [] : [...stripPrefixFromSet(new Set(parsedStatus.untracked), gitPrefix)]
|
|
1106
|
-
const all = untracked.length === 0 ? tracked : tracked.concat(untracked)
|
|
1107
|
-
// Aggregate dirtiness for the Tier-3 invocation record — derived from
|
|
1108
|
-
// this same status spawn so `run()` needs no second `git status`.
|
|
1109
|
-
// null when the status spawn failed (non-repo / git error).
|
|
1110
|
-
const worktreeDirty = dirty === null ? null : dirty.size > 0
|
|
1111
|
-
const trusted = dirty === null ? new Map<string, string>() : oids
|
|
1112
|
-
if (dirty !== null) {
|
|
1113
|
-
for (const rel of dirty) trusted.delete(rel)
|
|
1114
|
-
}
|
|
1115
|
-
// A skip-worktree / assume-unchanged path sits at stage 0 and `git status`
|
|
1116
|
-
// reports nothing for it, so it would otherwise keep a trusted OID — and
|
|
1117
|
-
// resolveFiles SKIPS its existence probe for OID-carrying paths. A sparse
|
|
1118
|
-
// checkout would then count a file that is not on disk as an input, so
|
|
1119
|
-
// materializing it later changes no key and the old artifact is replayed.
|
|
1120
|
-
// Dropping the OID sends these back through the probe, where they correctly
|
|
1121
|
-
// fall out of the input set while unmaterialized.
|
|
1122
|
-
for (const rel of flagged) trusted.delete(rel)
|
|
1123
|
-
// An index OID is only the file's content hash when git stores the worktree
|
|
1124
|
-
// bytes VERBATIM. Under a clean filter (`text`/`eol`/`ident`) the blob is a
|
|
1125
|
-
// DIFFERENT sequence of bytes — the LF-normalized form — while the task
|
|
1126
|
-
// reads the CRLF worktree file. `git status` compares AFTER filtering, so
|
|
1127
|
-
// such a file reports clean and keeps its OID: the CRLF and LF states then
|
|
1128
|
-
// fold the SAME key and a real content change is invisible.
|
|
1129
|
-
//
|
|
1130
|
-
// Dropping an OID is not over-invalidation. It routes the path to
|
|
1131
|
-
// `hashFile`, which hashes the worktree bytes — the source that was correct
|
|
1132
|
-
// all along. The only cost is the read, so the gate below is about paying it
|
|
1133
|
-
// ONLY where a filter can actually apply.
|
|
1134
|
-
await dropFilteredOids(trusted, {
|
|
1135
|
-
workspaceRoot,
|
|
1136
|
-
gitDir,
|
|
1137
|
-
coreConfig: coreCfg !== null && coreCfg.exitCode === 0 ? coreCfg.stdout : '',
|
|
1138
|
-
spawnGit,
|
|
1139
|
-
})
|
|
1140
|
-
return { all, trusted, dirty: worktreeDirty }
|
|
995
|
+
return false
|
|
1141
996
|
}
|
|
1142
997
|
|
|
1143
|
-
|
|
1144
|
-
export function applyGitEnumeration(
|
|
1145
|
-
enumeration: GitEnumeration,
|
|
1146
|
-
workspaceRoot: string,
|
|
1147
|
-
projectDirs: readonly string[],
|
|
1148
|
-
cache: GitFilesCache,
|
|
1149
|
-
workspaceWide = false,
|
|
1150
|
-
): void {
|
|
1151
|
-
const { all, trusted } = enumeration
|
|
1152
|
-
cache.setWorktreeDirty(enumeration.dirty)
|
|
1153
|
-
// Sort once, then each project's files are a contiguous range found
|
|
1154
|
-
// by binary search on its `dir/` prefix — O((F+P) log F) instead of
|
|
1155
|
-
// the O(P·F) per-project startsWith scan (54 ms at 1090 projects ×
|
|
1156
|
-
// ~9k files; ~5 ms this way). '/' sorts below most filename chars,
|
|
1157
|
-
// so the range [prefix, prefix+'\xff…') is contiguous in the sorted
|
|
1158
|
-
// array; lowerBound on `prefix` and on `prefix + ''` bracket it.
|
|
1159
|
-
const sorted = [...all].sort()
|
|
1160
|
-
const lowerBound = (key: string): number => {
|
|
1161
|
-
let lo = 0
|
|
1162
|
-
let hi = sorted.length
|
|
1163
|
-
while (lo < hi) {
|
|
1164
|
-
const mid = (lo + hi) >>> 1
|
|
1165
|
-
if (sorted[mid]! < key) lo = mid + 1
|
|
1166
|
-
else hi = mid
|
|
1167
|
-
}
|
|
1168
|
-
return lo
|
|
1169
|
-
}
|
|
1170
|
-
for (const projectDir of projectDirs) {
|
|
1171
|
-
const relPrefix = path.relative(workspaceRoot, projectDir).split(path.sep).join('/')
|
|
1172
|
-
if (relPrefix === '' || relPrefix === '.') {
|
|
1173
|
-
cache.set(projectDir, all)
|
|
1174
|
-
const rootOids = new Map<string, string>()
|
|
1175
|
-
for (const [rel, oid] of trusted) rootOids.set(path.join(workspaceRoot, rel), oid)
|
|
1176
|
-
cache.setOids(projectDir, rootOids)
|
|
1177
|
-
continue
|
|
1178
|
-
}
|
|
1179
|
-
const prefix = `${relPrefix}/`
|
|
1180
|
-
const start = lowerBound(prefix)
|
|
1181
|
-
const end = lowerBound(`${prefix}`)
|
|
1182
|
-
const matches: string[] = []
|
|
1183
|
-
const projOids = new Map<string, string>()
|
|
1184
|
-
for (let i = start; i < end; i++) {
|
|
1185
|
-
const rel = sorted[i]!
|
|
1186
|
-
matches.push(rel.slice(prefix.length))
|
|
1187
|
-
const oid = trusted.get(rel)
|
|
1188
|
-
if (oid !== undefined) projOids.set(path.join(workspaceRoot, rel), oid)
|
|
1189
|
-
}
|
|
1190
|
-
// setOids AFTER set — set() drops the project's OID slot.
|
|
1191
|
-
cache.set(projectDir, matches)
|
|
1192
|
-
cache.setOids(projectDir, projOids)
|
|
1193
|
-
}
|
|
1194
|
-
if (workspaceWide) {
|
|
1195
|
-
const rootOids = new Map<string, string>()
|
|
1196
|
-
for (const [rel, oid] of trusted) rootOids.set(path.join(workspaceRoot, rel), oid)
|
|
1197
|
-
cache.set(workspaceRoot, all)
|
|
1198
|
-
cache.setOids(workspaceRoot, rootOids)
|
|
1199
|
-
cache.setWorkspaceRoot(workspaceRoot)
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
998
|
+
const FATAL_UTF8 = new TextDecoder('utf-8', { fatal: true })
|
|
1202
999
|
|
|
1203
1000
|
/**
|
|
1204
|
-
* Union of files matching any positive pattern in `cwd`, minus
|
|
1205
|
-
* matching any exclude glob (tested by Bun.Glob.match on the relative
|
|
1001
|
+
* Union of the OUTPUT files matching any positive pattern in `cwd`, minus
|
|
1002
|
+
* files matching any exclude glob (tested by Bun.Glob.match on the relative
|
|
1206
1003
|
* path). Bun.Glob takes a single pattern per instance, so we iterate.
|
|
1004
|
+
*
|
|
1005
|
+
* Yields SYMLINKS (never followed, never descended): a task that emits
|
|
1006
|
+
* `dist/out -> ../src/x` has produced an output, so the save must capture it
|
|
1007
|
+
* (as its target's bytes, `planArtifact`) and the clean must remove it — a
|
|
1008
|
+
* link the clean leaves standing blocks the directory a later entry restores
|
|
1009
|
+
* there. Bun's `onlyFiles` walk drops every symlink, so this lists everything
|
|
1010
|
+
* and sorts by `lstat`.
|
|
1011
|
+
*
|
|
1012
|
+
* Sync scan. The async walk was chosen on 2026-09-02, when a warm HIT globbed
|
|
1013
|
+
* its outputs and the thread pool overlapped a thousand of them; the directory
|
|
1014
|
+
* short-circuit took the glob off the hit path, and what is left runs on the
|
|
1015
|
+
* MISS path twice per task (clean, then resolve), where the walk is CPU-bound
|
|
1016
|
+
* and each async chunk costs a main-thread round trip. Measured 2026-09-09 on
|
|
1017
|
+
* a 1,000-task cold run (see STATUS).
|
|
1018
|
+
*
|
|
1019
|
+
* A second `mode: 'files'` branch lived here until item 566, defaulted to and
|
|
1020
|
+
* reached by NOTHING — both call sites pass `'outputs'`. Its `dot: true` could
|
|
1021
|
+
* be deleted with the whole suite green for the plainest reason there is.
|
|
1207
1022
|
*/
|
|
1208
1023
|
async function scanUnion(
|
|
1209
1024
|
positive: readonly string[],
|
|
@@ -1213,13 +1028,12 @@ async function scanUnion(
|
|
|
1213
1028
|
const matches = new Set<string>()
|
|
1214
1029
|
for (const pattern of positive) {
|
|
1215
1030
|
const glob = globFor(pattern)
|
|
1216
|
-
|
|
1217
|
-
// vs 56 µs on a one-file `dist/**`) but SERIALISES on the main thread,
|
|
1218
|
-
// and this runs under the scheduler's concurrency — measured 2026-09-02,
|
|
1219
|
-
// switching it to scanSync made a 1000-hit warm run 40 ms SLOWER.
|
|
1220
|
-
for await (const rel of glob.scan({ cwd, onlyFiles: true, dot: true })) {
|
|
1031
|
+
for (const rel of glob.scanSync({ cwd, onlyFiles: false, followSymlinks: false, dot: true })) {
|
|
1221
1032
|
if (excludeGlobs.some((g) => g.match(rel))) continue
|
|
1222
|
-
|
|
1033
|
+
const abs = path.resolve(cwd, rel)
|
|
1034
|
+
const st = lstatSync(abs, { throwIfNoEntry: false })
|
|
1035
|
+
if (st !== undefined && (st.isFile() || st.isSymbolicLink())) matches.add(abs)
|
|
1036
|
+
else if (st === undefined && rel.includes('\ufffd')) refuseUndecodableOutput(abs)
|
|
1223
1037
|
}
|
|
1224
1038
|
}
|
|
1225
1039
|
return matches
|
|
@@ -1231,7 +1045,7 @@ async function scanUnion(
|
|
|
1231
1045
|
const globMemo = new Map<string, Bun.Glob>()
|
|
1232
1046
|
function globFor(pattern: string): Bun.Glob {
|
|
1233
1047
|
let g = globMemo.get(pattern)
|
|
1234
|
-
if (g === undefined) globMemo.set(pattern, (g =
|
|
1048
|
+
if (g === undefined) globMemo.set(pattern, (g = taskGlob(pattern)))
|
|
1235
1049
|
return g
|
|
1236
1050
|
}
|
|
1237
1051
|
|