@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.
Files changed (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -1,20 +1,55 @@
1
1
  // `--affected` — git-relative project selection.
2
2
  //
3
3
  // Resolves the set of project names whose files have changed since a
4
- // given git ref. The diff is `<since>...HEAD` plus working-tree
5
- // changes (uncommitted edits), so it captures everything you've
6
- // touched on the current branch.
7
- //
8
- // We use `git diff --name-only <since>` which by default compares the
9
- // `<since>` commit to the working tree — i.e. commits-from-`<since>`
10
- // + index + unstaged. Matches Turbo's `[<since>]` filter semantics.
4
+ // given git ref: `git diff --name-only <merge-base(since, HEAD)>`, which
5
+ // compares that commit to the working tree — commits on this branch +
6
+ // index + unstaged — so it captures everything you touched and nothing
7
+ // the base branch moved on with. Matches Turbo's `[<since>]` semantics.
11
8
 
9
+ import { realpathSync, statSync } from 'node:fs'
12
10
  import path from 'node:path'
13
- import { UserError } from '../util/index.js'
11
+ import {
12
+ asTrees,
13
+ executablePath,
14
+ UserError,
15
+ gitSpawnRefusal,
16
+ isExecutableMissing,
17
+ taskGlob,
18
+ } from '../util/index.js'
14
19
  import { LOCKFILE_NAME } from './lockfile.js'
15
20
  import { configImportOwners } from './config-imports.js'
21
+ import { configImports } from './config-cache.js'
22
+ import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
16
23
  import { WORKSPACE_FINGERPRINT_FILES } from './fingerprint.js'
17
- import type { ProjectMeta } from './workspace.js'
24
+ import type { PackageJson, ProjectMeta } from './workspace.js'
25
+
26
+ /**
27
+ * Every git call here goes through these two: a git that is not on PATH
28
+ * is one refusal naming the install (util `gitSpawnRefusal`), never the
29
+ * `ENOENT` stack `defaultAffectedBase` showed a minimal image
30
+ * (2026-09-16). A git that ran and failed is each caller's to read.
31
+ */
32
+ function spawnGitSync(
33
+ args: string[],
34
+ cwd: string,
35
+ stderr: 'pipe' | 'ignore' = 'pipe',
36
+ ): ReturnType<typeof Bun.spawnSync> {
37
+ try {
38
+ return Bun.spawnSync({ cmd: [executablePath('git'), ...args], cwd, stdout: 'pipe', stderr })
39
+ } catch (err) {
40
+ if (isExecutableMissing(err)) throw gitSpawnRefusal(cwd)
41
+ throw err
42
+ }
43
+ }
44
+
45
+ function spawnGit(args: string[], cwd: string): Bun.Subprocess<'ignore', 'pipe', 'pipe'> {
46
+ try {
47
+ return Bun.spawn({ cmd: [executablePath('git'), ...args], cwd, stdout: 'pipe', stderr: 'pipe' })
48
+ } catch (err) {
49
+ if (isExecutableMissing(err)) throw gitSpawnRefusal(cwd)
50
+ throw err
51
+ }
52
+ }
18
53
 
19
54
  export interface AffectedArgs {
20
55
  workspaceRoot: string
@@ -25,18 +60,35 @@ export interface AffectedArgs {
25
60
  * Which projects declare a `cache.inputs.workspaceFiles` glob matching one of
26
61
  * these workspace-relative paths.
27
62
  *
28
- * A workspace-anchored glob is the documented escape hatch for shared files
29
- * that belong to NO project, so mapping changed paths to project directories
30
- * structurally cannot see it — the path resolves to no project and the run
31
- * selects nothing for a change that re-keyed the task.
32
- *
33
- * Answering needs the RESOLVED configs, which selection runs before loading;
34
- * that ordering is what left this open. It is resolved by asking only when
35
- * the question can matter: this is called ONLY with paths that belong to no
36
- * project, so a run whose every change is inside a project — the common case
37
- * — never invokes it and pays nothing.
63
+ * A workspace-anchored glob is the documented escape hatch for inputs
64
+ * outside the project, so mapping changed paths to project directories
65
+ * structurally cannot see it. Answering needs the RESOLVED configs, which
66
+ * selection runs before loading, so it is a callback, asked only when
67
+ * something changed. It is asked about EVERY changed path, the ones a
68
+ * project owns included: the glob may name a file inside another project
69
+ * (`schema.md` allows it), and asking only the paths no project owns
70
+ * left the declaring project out of a run its key called stale
71
+ * (item 954). The `--affected` sugar's graph walk has already staged every
72
+ * config, so there it costs nothing more.
38
73
  */
39
- workspaceGlobOwners?: (orphanPaths: readonly string[]) => Promise<Iterable<string>>
74
+ workspaceGlobOwners?: (changedPaths: readonly string[]) => Promise<Iterable<string>>
75
+ /**
76
+ * The fingerprint files a plugin claims (`VxPlugin.fingerprint`) and its
77
+ * answer for a change to one. Resolved lazily: loading the workspace
78
+ * file costs an evaluation, and a diff that touches no fingerprint file
79
+ * — the common one — never needs it.
80
+ */
81
+ fingerprintClaims?: () => Promise<FingerprintClaims>
82
+ }
83
+
84
+ export interface FingerprintClaims {
85
+ readonly files: ReadonlySet<string>
86
+ /** Project names a change to a claimed file affects; `undefined` = all. */
87
+ affected(change: {
88
+ file: string
89
+ before: Uint8Array | null
90
+ after: Uint8Array | null
91
+ }): Promise<ReadonlySet<string> | undefined>
40
92
  }
41
93
 
42
94
  /**
@@ -45,7 +97,36 @@ export interface AffectedArgs {
45
97
  * or `<since>` doesn't resolve to a commit.
46
98
  */
47
99
  export async function affectedProjects(args: AffectedArgs): Promise<Set<string>> {
100
+ // The base reaches git as an argument, never through a shell, so `$(…)`
101
+ // is opaque — but an option-like value is not: `--output=<path>` is a
102
+ // real `git diff` option and an arbitrary file write. This is a security
103
+ // boundary, so it is a check that knows it is one, before any spawn, and
104
+ // every git call below also ends its options (`--end-of-options`) so a
105
+ // second caller cannot lose the guard by accident.
106
+ if (args.since.length === 0 || args.since.startsWith('-')) {
107
+ throw new UserError(
108
+ `git ref "${args.since}" is not a ref: a base cannot be empty or start with "-".`,
109
+ )
110
+ }
111
+ // `A..B` / `A...B` reached `rev-parse --verify`, which refuses a range, and
112
+ // the user read "did not resolve" about refs that both exist. `..` is
113
+ // illegal in a ref name (git-check-ref-format), so this refuses no ref.
114
+ const range = args.since.indexOf('..')
115
+ if (range >= 0) {
116
+ throw new UserError(
117
+ `git ref "${args.since}" is a range: ranges are not supported — pass the base alone ` +
118
+ `("${args.since.slice(0, range) || 'HEAD'}"); vx diffs it against the working tree.`,
119
+ )
120
+ }
48
121
  await verifyRef(args.workspaceRoot, args.since)
122
+ // Diff from the MERGE BASE of `since` and HEAD, not from `since` itself:
123
+ // on a branch whose base has moved on, `git diff <base>` reports every
124
+ // file OTHER people changed on the base (over-selection that defeats a
125
+ // CI `--affected`), and hides your own edit when the base later landed
126
+ // byte-identical content. Turbo and Nx both diff from the merge base;
127
+ // when there is none (unrelated histories, a detached probe) the ref
128
+ // itself is the base, as before.
129
+ const base = await mergeBase(args.workspaceRoot, args.since)
49
130
 
50
131
  const [diffed, untracked] = await Promise.all([
51
132
  // `--no-renames` is crucial for project-affected detection: with
@@ -67,13 +148,20 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
67
148
  // non-ASCII / `"` / `\` character — the quoted string then resolves to no
68
149
  // project, while the cache-input enumeration (which uses `-z`) sees the
69
150
  // real name and re-keys the task. The two surfaces must agree.
151
+ //
152
+ // `--ignore-submodules=none` because a repository may ask git to hide its
153
+ // submodules (`diff.ignoreSubmodules`, `submodule.<name>.ignore`), and
154
+ // then an edit inside one, or a committed bump, was no change here while
155
+ // the task's key moved (item 951).
70
156
  gitPaths(args.workspaceRoot, [
71
157
  'diff',
72
158
  '--no-renames',
159
+ '--ignore-submodules=none',
73
160
  '--relative',
74
161
  '--name-only',
75
162
  '-z',
76
- args.since,
163
+ '--end-of-options',
164
+ base,
77
165
  ]),
78
166
  // `git diff` never reports untracked-but-not-ignored files, but input
79
167
  // enumeration does (`git ls-files --cached --others --exclude-standard`),
@@ -96,18 +184,61 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
96
184
  // principle: "input hashing sees it, so `--affected` must too."
97
185
  //
98
186
  // Selection is not hashed, so widening it here changes no cache key.
99
- if (changed.some((p) => FINGERPRINT_SET.has(p))) {
187
+ //
188
+ // A file a plugin CLAIMS is the exception: the key folds what the plugin
189
+ // says per project, so selection asks the plugin the same question, with
190
+ // the bytes at the base ref and in the working tree. Its answer is
191
+ // unioned with the path-owned projects below; only "cannot tell" widens.
192
+ if (await workspaceConfigChanged(args.workspaceRoot, changed)) {
100
193
  return new Set(args.projects.map((p) => p.name))
101
194
  }
195
+ const fingerprintChanged = changed.filter((p) => FINGERPRINT_SET.has(p))
196
+ const claimedOwned = new Set<string>()
197
+ // A claim may also name a root file core does not fold (`turbo.json`,
198
+ // read by `turbo()`'s stages): it re-keys tasks through their resolved
199
+ // configs and belongs to no project (item 961). Only a changed ROOT name
200
+ // can be one, so a diff inside the projects never loads the claims.
201
+ const rootNamesChanged = changed.filter((p) => !p.includes('/') && !FINGERPRINT_SET.has(p))
202
+ let claims: FingerprintClaims | undefined
203
+ if (
204
+ (fingerprintChanged.length > 0 || rootNamesChanged.length > 0) &&
205
+ args.fingerprintClaims !== undefined
206
+ ) {
207
+ claims = await args.fingerprintClaims()
208
+ for (const file of rootNamesChanged) if (claims.files.has(file)) fingerprintChanged.push(file)
209
+ }
210
+ if (fingerprintChanged.length > 0) {
211
+ for (const file of fingerprintChanged) {
212
+ if (claims === undefined || !claims.files.has(file)) {
213
+ return new Set(args.projects.map((p) => p.name))
214
+ }
215
+ const answer = await claims.affected({
216
+ file,
217
+ before: await gitBytesAt(args.workspaceRoot, base, file),
218
+ after: await bytesOrNull(path.join(args.workspaceRoot, file)),
219
+ })
220
+ if (answer === undefined) return new Set(args.projects.map((p) => p.name))
221
+ for (const name of answer) claimedOwned.add(name)
222
+ }
223
+ }
224
+
225
+ const owned = projectsContaining(args.workspaceRoot, changed, args.projects)
226
+ for (const name of claimedOwned) owned.add(name)
102
227
 
103
- const { owned, orphans } = projectsContaining(args.workspaceRoot, changed, args.projects)
228
+ // A package the change REMOVED is no project now, so containment maps its
229
+ // paths to nothing, and the dependents walk sees only today's graph: a
230
+ // `git rm -r pkgs/lib`, or `lib` dropped from `workspaces`, re-keyed and
231
+ // broke `app#build` while `--affected` said nothing affected (item 959).
232
+ const dependents = await dependentsOfRemoved(args.workspaceRoot, base, changed, args.projects)
233
+ if (dependents === undefined) return new Set(args.projects.map((p) => p.name))
234
+ for (const name of dependents) owned.add(name)
104
235
 
105
236
  // THIRD CHANNEL: a project whose `vx.config.*` IMPORTS a changed file.
106
237
  // Resolved-config hashing folds those values into the key, so the same
107
238
  // sentence above applies — input hashing sees it, so selection must. This
108
- // runs on the FULL changed set, not just `orphans`: the common shape is a
109
- // config reaching into ANOTHER project (`../../src/index.ts`), whose target
110
- // is owned and therefore never an orphan at all.
239
+ // runs on the FULL changed set, not just the paths no project owns: the
240
+ // common shape is a config reaching into ANOTHER project
241
+ // (`../../src/index.ts`), whose target is owned.
111
242
  for (const name of await configImportOwners({
112
243
  workspaceRoot: args.workspaceRoot,
113
244
  projects: args.projects,
@@ -117,11 +248,102 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
117
248
  owned.add(name)
118
249
  }
119
250
 
120
- if (orphans.length === 0 || args.workspaceGlobOwners === undefined) return owned
121
- for (const name of await args.workspaceGlobOwners(orphans)) owned.add(name)
251
+ if (changed.length === 0 || args.workspaceGlobOwners === undefined) return owned
252
+ for (const name of await args.workspaceGlobOwners(changed)) owned.add(name)
122
253
  return owned
123
254
  }
124
255
 
256
+ /**
257
+ * The projects that depend on a package the change removed: a
258
+ * `<dir>/package.json` the base had and the tree does not, read at the base
259
+ * for its name. `undefined` (every project) when the root manifest's
260
+ * `workspaces` moved: which packages left is a discovery at the base, and
261
+ * selection cannot tell cheaply. Only DELETED manifests are read at the
262
+ * base, one `git show` each; an edited one stays a project and is owned.
263
+ */
264
+ async function dependentsOfRemoved(
265
+ workspaceRoot: string,
266
+ base: string,
267
+ changed: readonly string[],
268
+ projects: readonly ProjectMeta[],
269
+ ): Promise<Set<string> | undefined> {
270
+ const out = new Set<string>()
271
+ const removed = new Set<string>()
272
+ for (const rel of changed) {
273
+ if (rel === 'package.json') {
274
+ const workspacesOf = (bytes: Uint8Array | null): string => {
275
+ if (bytes === null) return ''
276
+ try {
277
+ return JSON.stringify(
278
+ (JSON.parse(new TextDecoder().decode(bytes)) as PackageJson).workspaces ?? null,
279
+ )
280
+ } catch {
281
+ return 'unreadable'
282
+ }
283
+ }
284
+ const before = workspacesOf(await gitBytesAt(workspaceRoot, base, rel))
285
+ const after = workspacesOf(await bytesOrNull(path.join(workspaceRoot, rel)))
286
+ if (before !== after) return undefined
287
+ continue
288
+ }
289
+ if (path.posix.basename(rel) !== 'package.json') continue
290
+ if ((await bytesOrNull(path.join(workspaceRoot, rel))) !== null) continue
291
+ const bytes = await gitBytesAt(workspaceRoot, base, rel)
292
+ if (bytes === null) continue
293
+ try {
294
+ const name = (JSON.parse(new TextDecoder().decode(bytes)) as PackageJson).name
295
+ if (typeof name === 'string' && name !== '') removed.add(name)
296
+ } catch {
297
+ // A manifest that did not parse at the base named no package.
298
+ }
299
+ }
300
+ if (removed.size === 0) return out
301
+ for (const p of projects) {
302
+ const pkg = p.packageJson
303
+ for (const deps of [
304
+ pkg.dependencies,
305
+ pkg.devDependencies,
306
+ pkg.peerDependencies,
307
+ pkg.optionalDependencies,
308
+ ]) {
309
+ if (deps !== undefined && Object.keys(deps).some((d) => removed.has(d))) out.add(p.name)
310
+ }
311
+ }
312
+ return out
313
+ }
314
+
315
+ /**
316
+ * Whether `changed` holds the workspace config or a file it imports by
317
+ * relative specifier. Its plugins' `config` and `project` stages shape
318
+ * every project's resolved config, so such an edit can re-key any task,
319
+ * and selection cannot tell which: an edit that changed every key
320
+ * selected nothing (item 953). The fingerprint leaves the file out (its
321
+ * placement plugins must not split a cache between machines); a stage it
322
+ * installs re-keys through the resolved configs instead. Selection is not
323
+ * hashed, so widening here changes no key.
324
+ */
325
+ async function workspaceConfigChanged(
326
+ workspaceRoot: string,
327
+ changed: readonly string[],
328
+ ): Promise<boolean> {
329
+ if (changed.length === 0) return false
330
+ const set = new Set(changed)
331
+ if (WORKSPACE_CONFIG_FILENAMES.some((n) => set.has(n))) return true
332
+ const config = WORKSPACE_CONFIG_FILENAMES.map((n) => path.join(workspaceRoot, n)).find((f) => {
333
+ try {
334
+ return statSync(f).isFile()
335
+ } catch {
336
+ return false
337
+ }
338
+ })
339
+ if (config === undefined) return false
340
+ const root = realpathSync(workspaceRoot)
341
+ for (const file of await configImports(config)) {
342
+ if (set.has(path.relative(root, file).split(path.sep).join('/'))) return true
343
+ }
344
+ return false
345
+ }
346
+
125
347
  /**
126
348
  * Does any `cache.inputs.workspaceFiles` entry in `globs` match `rel`?
127
349
  *
@@ -129,17 +351,27 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
129
351
  * with no positive glob nothing matches. Paths are workspace-root-relative and
130
352
  * POSIX-separated, which is the form both git enumeration and the glob
131
353
  * resolver already speak.
354
+ *
355
+ * `asTrees` on BOTH sides, because the resolver runs it on both and this
356
+ * has to answer the same question the KEY answers. It did not, and the
357
+ * gap decided whether a changed file rebuilt anything: with
358
+ * `workspaceFiles: ['./generated/**']` or the literal `['generated']` —
359
+ * five spellings in all — the resolver folds `generated/x.txt` into the
360
+ * project's key while a raw `Bun.Glob` matched none of them, so
361
+ * `--affected` left the project out of a run its own key says is stale
362
+ * (item 445). A mirror that normalizes differently from what it mirrors
363
+ * is not a mirror.
132
364
  */
133
365
  export function workspaceGlobsMatch(globs: readonly string[], rel: string): boolean {
134
- const positive: Bun.Glob[] = []
135
- const negative: Bun.Glob[] = []
366
+ const positive: string[] = []
367
+ const negative: string[] = []
136
368
  for (const entry of globs) {
137
- if (entry.startsWith('!')) negative.push(new Bun.Glob(entry.slice(1)))
138
- else positive.push(new Bun.Glob(entry))
369
+ if (entry.startsWith('!')) negative.push(entry.slice(1))
370
+ else positive.push(entry)
139
371
  }
140
372
  if (positive.length === 0) return false
141
- if (!positive.some((g) => g.match(rel))) return false
142
- return !negative.some((g) => g.match(rel))
373
+ if (!asTrees(positive).some((g) => taskGlob(g).match(rel))) return false
374
+ return !asTrees(negative).some((g) => taskGlob(g).match(rel))
143
375
  }
144
376
 
145
377
  /**
@@ -149,14 +381,37 @@ export function workspaceGlobsMatch(globs: readonly string[], rel: string): bool
149
381
  */
150
382
  const FINGERPRINT_SET: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FILES)
151
383
 
384
+ /**
385
+ * A root file's bytes at `ref`, or null when the ref has no such file.
386
+ * `./` anchors the path to the cwd (the workspace root) rather than the
387
+ * repository root, for a workspace that is a subdirectory of its repo.
388
+ */
389
+ async function gitBytesAt(
390
+ workspaceRoot: string,
391
+ ref: string,
392
+ file: string,
393
+ ): Promise<Uint8Array | null> {
394
+ const proc = spawnGit(['show', '--end-of-options', `${ref}:./${file}`], workspaceRoot)
395
+ const [bytes, stderr, exit] = await Promise.all([
396
+ new Response(proc.stdout).bytes(),
397
+ new Response(proc.stderr).text(),
398
+ proc.exited,
399
+ ])
400
+ if (exit === 0) return bytes
401
+ // "exists on disk but not in <ref>" and "path does not exist in <ref>"
402
+ // are the file being absent at the ref; anything else is git failing.
403
+ if (/does not exist in|exists on disk, but not in/.test(stderr)) return null
404
+ throw new UserError(`git show ${ref}:./${file} failed (exit ${exit}): ${stderr.trim()}`)
405
+ }
406
+
407
+ async function bytesOrNull(file: string): Promise<Uint8Array | null> {
408
+ const f = Bun.file(file)
409
+ return (await f.exists()) ? await f.bytes() : null
410
+ }
411
+
152
412
  /** Run a NUL-separated path-listing git command from the workspace root. */
153
413
  async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]> {
154
- const proc = Bun.spawn({
155
- cmd: ['git', ...cmd],
156
- cwd: workspaceRoot,
157
- stdout: 'pipe',
158
- stderr: 'pipe',
159
- })
414
+ const proc = spawnGit([...cmd], workspaceRoot)
160
415
  const stdout = await new Response(proc.stdout).text()
161
416
  const stderr = await new Response(proc.stderr).text()
162
417
  const exit = await proc.exited
@@ -169,28 +424,63 @@ async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]>
169
424
  /**
170
425
  * Resolve the default base for `--affected` with no explicit value.
171
426
  * Tries the remote's HEAD branch first (`origin/main`, `origin/master`,
172
- * etc.), then falls back to `HEAD~1` which always exists once there
173
- * are at least two commits.
427
+ * etc.), then falls back to `HEAD~1`. A clone with no `origin/HEAD` and
428
+ * no parent commit — a CI checkout at `fetch-depth: 1` — has no base at
429
+ * all, and says so here rather than failing on a ref nobody typed.
174
430
  */
175
431
  export async function defaultAffectedBase(workspaceRoot: string): Promise<string> {
176
- const probe = Bun.spawnSync({
177
- cmd: ['git', 'symbolic-ref', '--short', '-q', 'refs/remotes/origin/HEAD'],
178
- cwd: workspaceRoot,
179
- stdout: 'pipe',
180
- stderr: 'ignore',
181
- })
432
+ const probe = spawnGitSync(
433
+ ['symbolic-ref', '--short', '-q', 'refs/remotes/origin/HEAD'],
434
+ workspaceRoot,
435
+ 'ignore',
436
+ )
182
437
  const out = new TextDecoder().decode(probe.stdout).trim()
183
438
  if (probe.exitCode === 0 && out.length > 0) return out
439
+ if (revParse(workspaceRoot, 'HEAD~1') === undefined) {
440
+ throw new UserError(
441
+ '--affected has no base here: origin/HEAD is not set and HEAD has no parent to compare ' +
442
+ 'with — a shallow clone? Fetch history (actions/checkout: fetch-depth: 0) or name the ' +
443
+ 'base: --affected=origin/main',
444
+ )
445
+ }
184
446
  return 'HEAD~1'
185
447
  }
186
448
 
449
+ /**
450
+ * Does `ref` name the commit HEAD is on? A base that IS HEAD can never
451
+ * mark anything affected — the shape of a single-branch clone whose
452
+ * `origin/HEAD` is the branch under test.
453
+ */
454
+ export function refIsHead(workspaceRoot: string, ref: string): boolean {
455
+ const head = revParse(workspaceRoot, 'HEAD')
456
+ return head !== undefined && head === revParse(workspaceRoot, ref)
457
+ }
458
+
459
+ /** The commit `ref` names, or undefined when it does not resolve here. */
460
+ function revParse(workspaceRoot: string, ref: string): string | undefined {
461
+ const proc = spawnGitSync(
462
+ ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`],
463
+ workspaceRoot,
464
+ 'ignore',
465
+ )
466
+ const sha = new TextDecoder().decode(proc.stdout).trim()
467
+ return proc.exitCode === 0 && sha.length > 0 ? sha : undefined
468
+ }
469
+
470
+ /** `git merge-base <ref> HEAD`, or `ref` itself when the two share no ancestor. */
471
+ async function mergeBase(workspaceRoot: string, ref: string): Promise<string> {
472
+ const proc = spawnGit(['merge-base', '--end-of-options', ref, 'HEAD'], workspaceRoot)
473
+ const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
474
+ const sha = out.trim()
475
+ return exit === 0 && sha.length > 0 ? sha : ref
476
+ }
477
+
187
478
  async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
188
- const proc = Bun.spawnSync({
189
- cmd: ['git', 'rev-parse', '--verify', '--quiet', ref],
190
- cwd: workspaceRoot,
191
- stdout: 'pipe',
192
- stderr: 'pipe',
193
- })
479
+ const proc = spawnGitSync(
480
+ ['rev-parse', '--verify', '--quiet', '--end-of-options', ref],
481
+ workspaceRoot,
482
+ 'pipe',
483
+ )
194
484
  if (proc.exitCode === 0) return
195
485
  // `--verify --quiet` exits 1 for "that ref does not exist" and 128 for
196
486
  // "git could not run here at all" (not a repository, corrupt objects,
@@ -207,11 +497,19 @@ async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
207
497
  throw new UserError(`git ref "${ref}" did not resolve. Pass a branch or commit you have locally.`)
208
498
  }
209
499
 
500
+ function isDirectory(abs: string): boolean {
501
+ try {
502
+ return statSync(abs).isDirectory()
503
+ } catch {
504
+ return false
505
+ }
506
+ }
507
+
210
508
  function projectsContaining(
211
509
  workspaceRoot: string,
212
510
  changedRelPaths: readonly string[],
213
511
  projects: readonly ProjectMeta[],
214
- ): { owned: Set<string>; orphans: string[] } {
512
+ ): Set<string> {
215
513
  // Index projects by their (canonical) dir, then for each changed path walk
216
514
  // its ancestor dirs bottom-up until one is a project dir. The FIRST hit is
217
515
  // the DEEPEST containing project — so a nested project still wins over its
@@ -222,9 +520,6 @@ function projectsContaining(
222
520
  const dirToName = new Map<string, string>()
223
521
  for (const p of projects) dirToName.set(p.dir, p.name)
224
522
  const owned = new Set<string>()
225
- // Paths that belong to no project. Only these can reach a task through a
226
- // workspace-anchored glob, so they are the exact set worth asking about.
227
- const orphans: string[] = []
228
523
  for (const rel of changedRelPaths) {
229
524
  let dir = path.resolve(workspaceRoot, rel)
230
525
  let hit = false
@@ -239,7 +534,20 @@ function projectsContaining(
239
534
  if (parent === dir) break // reached the filesystem root
240
535
  dir = parent
241
536
  }
242
- if (!hit) orphans.push(rel)
537
+ if (hit) continue
538
+ // A changed path that is a DIRECTORY on disk is a nested repository —
539
+ // a submodule whose checkout moved or is dirty (`vendor/sub`), an
540
+ // embedded repository left untracked (`vendor/nested/`) — because git
541
+ // reports nothing else as a directory. The workspace repository sees
542
+ // the nested one as that single path, so a change inside is a change
543
+ // to it, and every project under it is affected. Its own enumeration
544
+ // already keys those projects on their files (git-inputs.ts); without
545
+ // this, `--affected` after an edit inside selected none of them.
546
+ const abs = path.resolve(workspaceRoot, rel)
547
+ if (isDirectory(abs)) {
548
+ const prefix = abs + path.sep
549
+ for (const [dir, name] of dirToName) if (dir.startsWith(prefix)) owned.add(name)
550
+ }
243
551
  }
244
- return { owned, orphans }
552
+ return owned
245
553
  }