@vzn/vx 0.0.646 → 0.0.649

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 (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -6,7 +6,8 @@
6
6
  // index + unstaged — so it captures everything you touched and nothing
7
7
  // the base branch moved on with. Matches Turbo's `[<since>]` semantics.
8
8
 
9
- import { realpath, stat } from 'node:fs/promises'
9
+ import { lstatSync } from 'node:fs'
10
+ import { lstat, readlink, realpath, stat } from 'node:fs/promises'
10
11
  import path from 'node:path'
11
12
  import {
12
13
  executablePath,
@@ -18,11 +19,11 @@ import {
18
19
  relPosix,
19
20
  UserError,
20
21
  } from '../util/index.js'
21
- import { LOCKFILE_NAME } from './lockfile.js'
22
+ import { LOCKFILE_NAME, lockKey } from './lockfile.js'
22
23
  import { configImportOwners, realpathOr } from './config-imports.js'
23
24
  import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
24
25
  import { bunPatchFiles } from './fingerprint.js'
25
- import { PROJECT_CONFIG_FILENAMES, type ProjectMeta } from './workspace.js'
26
+ import { PROJECT_CONFIG_FILENAMES, REVIEW_FILE, type ProjectMeta } from './workspace.js'
26
27
 
27
28
  /**
28
29
  * Every git call here goes through these two: a git that is not on PATH
@@ -38,7 +39,9 @@ function spawnGitSync(
38
39
  try {
39
40
  return Bun.spawnSync({
40
41
  cmd: [executablePath('git'), ...args],
41
- env: gitEnv(),
42
+ // A probe never fetches: in a partial clone, `rev-parse` of a commit
43
+ // it lacks fetched that commit and all its history (50k objects).
44
+ env: { ...gitEnv(), GIT_NO_LAZY_FETCH: '1' },
42
45
  cwd,
43
46
  stdout: 'pipe',
44
47
  stderr,
@@ -99,6 +102,7 @@ export interface AffectedArgs {
99
102
  workspaceGlobOwners?: (
100
103
  changedPaths: readonly string[],
101
104
  nested: readonly string[],
105
+ dirLinks: ReadonlySet<string>,
102
106
  ) => Promise<Iterable<string>>
103
107
  /**
104
108
  * The fingerprint files a plugin claims (`VxPlugin.fingerprint`) and its
@@ -120,6 +124,12 @@ export interface AffectedArgs {
120
124
  * -s` here. A changed or untracked link is a changed path already.
121
125
  */
122
126
  links?: () => Promise<readonly string[] | null>
127
+ /**
128
+ * The tracked paths git does not look at on disk (assume-unchanged,
129
+ * skip-worktree), workspace-relative, from the run's enumeration; null or
130
+ * absent asks `git ls-files -v` here.
131
+ */
132
+ flagged?: () => Promise<readonly string[] | null>
123
133
  }
124
134
 
125
135
  export interface FingerprintClaims {
@@ -132,7 +142,29 @@ export interface FingerprintClaims {
132
142
  extras?: ReadonlyMap<string, { before: Uint8Array | null; after: Uint8Array | null }>
133
143
  }): Promise<ReadonlySet<string> | undefined>
134
144
  /** The files a claimed file names that its claimant keys too; absent = it reads none. */
135
- extraFiles?(file: string, bytes: Uint8Array): readonly string[] | undefined
145
+ extraFiles?(file: string, bytes: Uint8Array): Promise<readonly string[] | undefined>
146
+ }
147
+
148
+ /**
149
+ * `extraFiles` on one side of a claimed file. Which side failed to parse is
150
+ * the whole fix: a broken lockfile at the base ref is not the working
151
+ * tree's to regenerate, and the bare refusal named neither.
152
+ */
153
+ async function extraFilesOn(
154
+ claims: FingerprintClaims,
155
+ file: string,
156
+ bytes: Uint8Array,
157
+ side: string,
158
+ ): Promise<readonly string[] | undefined> {
159
+ try {
160
+ return await claims.extraFiles?.(file, bytes)
161
+ } catch (err) {
162
+ if (!(err instanceof Error)) throw err
163
+ throw new UserError(
164
+ `${err.message} (${side})`,
165
+ err instanceof UserError ? err.code : 'VX_E_REFUSED',
166
+ )
167
+ }
136
168
  }
137
169
 
138
170
  /**
@@ -145,7 +177,7 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
145
177
  }
146
178
 
147
179
  /**
148
- * What `--affected` seeds its tasks from (owner, 2026-10-04): the changed
180
+ * What `--affected` seeds its tasks from (2026-10-04): the changed
149
181
  * projects, each one's changed paths relative to it, and the projects a
150
182
  * change reaches as a whole — every task in one is affected whatever its
151
183
  * inputs say, because the reason was no path of its own (a lockfile claim,
@@ -167,8 +199,18 @@ export interface AffectedChanges {
167
199
  * in it, so a `workspaceFiles` glob that reaches under one is reached.
168
200
  */
169
201
  nested?: readonly string[]
202
+ /**
203
+ * The changed paths a glob matches as a directory (a link to one),
204
+ * workspace-relative: `declaresInput`'s `dirLink`.
205
+ */
206
+ dirLinks?: ReadonlySet<string>
207
+ /** Project → its `dirLinks`, relative to the project's directory. */
208
+ dirLinksIn?: ReadonlyMap<string, ReadonlySet<string>>
170
209
  }
171
210
 
211
+ /** `<base>...HEAD` / `<base>..HEAD`: the base alone, read as `affectedChanges` reads it. */
212
+ const HEAD_RANGE = /^(.+?)(\.{2,3})HEAD$/s
213
+
172
214
  export async function affectedChanges(
173
215
  args: AffectedArgs,
174
216
  perTask = true,
@@ -177,7 +219,7 @@ export async function affectedChanges(
177
219
  // vx already diffs from the merge base (three dots' meaning), and the
178
220
  // working tree it diffs to holds HEAD (D-117). Its two-dot `[A..HEAD]`
179
221
  // diffs from A itself (G-145). Any other range is refused.
180
- const headRange = /^(.+?)(\.{2,3})HEAD$/s.exec(args.since)
222
+ const headRange = HEAD_RANGE.exec(args.since)
181
223
  const since = headRange?.[1] ?? args.since
182
224
  const fromMergeBase = headRange?.[2] !== '..'
183
225
  // The base reaches git as an argument, never through a shell, so `$(…)`
@@ -220,9 +262,11 @@ export async function affectedChanges(
220
262
  // when there is none (unrelated histories, a detached probe) the ref
221
263
  // itself is the base, as before. Turbo's two-dot range `A..HEAD` is the
222
264
  // one form that diffs from A itself.
265
+ // Asked beside the base, not after it: the diff's shape waits on it.
266
+ const partial = isPartialClone(args.workspaceRoot).catch(() => false)
223
267
  const base = fromMergeBase
224
268
  ? await mergeBase(args.workspaceRoot, since)
225
- : (await verifyRef(args.workspaceRoot, since), since)
269
+ : await rangeBase(args.workspaceRoot, since)
226
270
 
227
271
  const [diffed, untracked] = await Promise.all([
228
272
  // `--no-renames` is crucial for project-affected detection: with
@@ -252,7 +296,7 @@ export async function affectedChanges(
252
296
  //
253
297
  // `--raw` for each path's mode: a gitlink (160000) is a nested
254
298
  // repository, one path standing for every file in it (`nested` below).
255
- gitDiffPaths(args.workspaceRoot, base),
299
+ gitDiffPaths(args.workspaceRoot, base, partial),
256
300
  // `git diff` never reports untracked-but-not-ignored files, but input
257
301
  // enumeration does (`git ls-files --cached --others --exclude-standard`),
258
302
  // so a brand-new source file changes a task's cache key. Union it in or
@@ -264,22 +308,54 @@ export async function affectedChanges(
264
308
 
265
309
  const projects = await args.projects
266
310
 
267
- // .vx/projects.lock.json (workspace-root metadata) is excluded like a gitignored
268
- // file: re-running `vx lock` must not mark every project affected.
311
+ // What vx writes under `.vx` (the lock, its git files, the cache) is
312
+ // excluded like a gitignored file: re-running `vx lock` must not mark
313
+ // every project affected, nor the root project whole. The lock's
314
+ // entries are read below.
269
315
  // An untracked file under `node_modules` is an install, which the input
270
316
  // enumeration drops too (`isInstalledPath`); a tracked one is a source.
317
+ // So is each project's `vx.lock.json`, the review copy `vx lock` writes
318
+ // beside its config and nothing reads: a re-lock rewriting them all
319
+ // selected every project while no key moved.
320
+ const reviewCopies = new Set(projects.map((p) => path.join(p.dir, REVIEW_FILE)))
321
+ const rootDir = path.resolve(args.workspaceRoot)
271
322
  const changed = [...diffed.paths, ...untracked.filter((s) => !isInstalledPath(s))].filter(
272
- (s) => s !== LOCKFILE_NAME,
323
+ (s) =>
324
+ !VX_OWN.has(s) &&
325
+ !s.startsWith('.vx/cache/') &&
326
+ !s.startsWith('.vx/tmp/') &&
327
+ (path.posix.basename(s) !== REVIEW_FILE || !reviewCopies.has(path.join(rootDir, s))),
273
328
  )
274
329
  // A symlinked input keys the content behind it (cache/linked-inputs.ts),
275
330
  // and the diff names that content's path, never the link's: a link whose
276
331
  // target changed is a changed path itself.
277
- changed.push(
278
- ...(await linkedChanges(
279
- args.workspaceRoot,
280
- (await args.links?.().catch(() => null)) ?? (await trackedLinks(args.workspaceRoot)),
281
- changed,
282
- )),
332
+ // One index listing serves both when the run's enumeration did not.
333
+ let index: Promise<IndexEntry[]> | undefined
334
+ const listIndex = () => (index ??= indexEntries(args.workspaceRoot))
335
+ const [links, flagged] = await Promise.all([
336
+ (async () =>
337
+ (await args.links?.().catch(() => null)) ??
338
+ (await listIndex()).filter((e) => e.mode === '120000').map((e) => e.path))(),
339
+ (async () =>
340
+ (await args.flagged?.().catch(() => null)) ??
341
+ (await listIndex()).filter((e) => e.flagged).map((e) => e.path))(),
342
+ ])
343
+ const dirLinkSet = new Set<string>()
344
+ changed.push(...(await linkedChanges(args.workspaceRoot, links, changed, dirLinkSet)))
345
+ // A path git was told not to look at (`update-index --assume-unchanged`,
346
+ // `--skip-worktree`): `git diff` reports no edit to it, and the key
347
+ // hashes it from disk, so the task missed and selection left it out.
348
+ if (flagged.length > 0) {
349
+ changed.push(...(await flaggedChanges(args.workspaceRoot, flagged, await listIndex(), changed)))
350
+ }
351
+ // A changed path that is a link to a directory, or was a link and is
352
+ // no link to a file now: a glob reaching below it keys what is behind
353
+ // it (cache/inputs.ts `mayHoldMatch`), so it is matched as a directory.
354
+ const dirLinks = await changedDirLinks(
355
+ args.workspaceRoot,
356
+ changed,
357
+ dirLinkSet,
358
+ new Set([...diffed.links, ...untracked]),
283
359
  )
284
360
  // The nested repositories git reports as one path: a gitlink, or an
285
361
  // untracked embedded repository (`dir/`, the only path git prints so).
@@ -310,8 +386,8 @@ export async function affectedChanges(
310
386
  // changes no key.
311
387
  const wsConfig = WORKSPACE_CONFIG_FILENAMES.find((n) => changed.includes(n))
312
388
  if (wsConfig !== undefined) return everything(wsConfig)
313
- // Loading the workspace file costs an evaluation; only a changed ROOT
314
- // name can be claimed, so a diff inside the projects never loads it.
389
+ // The workspace file is loaded once, for the extra files below and the
390
+ // changed root names after them.
315
391
  let claims: FingerprintClaims | undefined
316
392
  const loadClaims = async (): Promise<FingerprintClaims | undefined> =>
317
393
  (claims ??= await args.fingerprintClaims?.())
@@ -320,30 +396,33 @@ export async function affectedChanges(
320
396
  // patches, a `file:`, `link:` or `portal:` directory
321
397
  // a manager copies or links) is part of
322
398
  // that lockfile: a change to one, or under one, is a lockfile change the
323
- // claimant answers with it. Only a path no project below the root owns
324
- // can be one, so a diff inside the projects never loads the claims.
399
+ // claimant answers with it. It may sit inside another project's
400
+ // directory, so every changed path is asked: asking only the paths no
401
+ // project owned selected the owner and never the project the lockfile
402
+ // says reaches it.
325
403
  const extrasBy = new Map<
326
404
  string,
327
405
  Map<string, { before: Uint8Array | null; after: Uint8Array | null }>
328
406
  >()
329
- const rootDir = path.resolve(args.workspaceRoot)
330
- const loose = changed.filter((rel) => {
331
- const abs = path.join(rootDir, rel)
332
- return !projects.some((p) => p.dir !== rootDir && abs.startsWith(p.dir + path.sep))
333
- })
334
- const extraClaims = loose.length > 0 ? await loadClaims() : undefined
407
+ const extraClaims = changed.length > 0 ? await loadClaims() : undefined
335
408
  for (const file of extraClaims?.files ?? []) {
336
409
  const after = await bytesOrNull(path.join(args.workspaceRoot, file))
337
- const before = await gitBytesAt(args.workspaceRoot, base, file)
338
- const namedAfter = after === null ? [] : extraClaims!.extraFiles?.(file, after)
410
+ // An unchanged claimed file is the same bytes at the base: one read,
411
+ // one parse, no git.
412
+ const same = !changed.includes(file)
413
+ const before = same ? after : await gitBytesAt(args.workspaceRoot, base, file)
414
+ const namedAfter =
415
+ after === null ? [] : await extraFilesOn(extraClaims!, file, after, 'in the working tree')
339
416
  const named = new Set([
340
- ...(before === null ? [] : (extraClaims!.extraFiles?.(file, before) ?? [])),
417
+ ...(before === null || same
418
+ ? []
419
+ : ((await extraFilesOn(extraClaims!, file, before, 'as of the base ref')) ?? [])),
341
420
  ...(namedAfter ?? []),
342
421
  ])
343
422
  const extras = new Map<string, { before: Uint8Array | null; after: Uint8Array | null }>()
344
423
  for (const rel of named) {
345
424
  const at = path.posix.normalize(rel).replace(/\/+$/, '')
346
- if (loose.includes(at)) {
425
+ if (changed.includes(at)) {
347
426
  extras.set(rel, {
348
427
  before: await gitBytesAt(args.workspaceRoot, base, at),
349
428
  after: await bytesOrNull(path.join(args.workspaceRoot, at)),
@@ -352,7 +431,7 @@ export async function affectedChanges(
352
431
  }
353
432
  // A directory's sides are not its bytes: a file under it changed,
354
433
  // so they differ, and only the projects reaching it move.
355
- const under = loose.filter((p) => p.startsWith(`${at}/`))
434
+ const under = changed.filter((p) => p.startsWith(`${at}/`))
356
435
  if (under.length > 0) {
357
436
  extras.set(rel, { before: null, after: new TextEncoder().encode(under.join('\0')) })
358
437
  }
@@ -363,7 +442,7 @@ export async function affectedChanges(
363
442
  // bun.lock names (the lockfile records no hash of them): it cannot
364
443
  // say which projects one reaches, so its edit widens.
365
444
  const patches = new Set(bunPatchFiles(after))
366
- const patch = loose.find((p) => patches.has(p))
445
+ const patch = changed.find((p) => patches.has(p))
367
446
  if (patch !== undefined) return everything(patch)
368
447
  }
369
448
  }
@@ -396,6 +475,7 @@ export async function affectedChanges(
396
475
  ),
397
476
  )
398
477
  const paths = new Map<string, string[]>()
478
+ const dirLinksIn = new Map<string, Set<string>>()
399
479
  const whole = new Set<string>(claimedOwned.keys())
400
480
  const owned = projectsContaining(
401
481
  args.workspaceRoot,
@@ -405,22 +485,62 @@ export async function affectedChanges(
405
485
  realDirs,
406
486
  perTask
407
487
  ? {
408
- path: (name, rel) => {
488
+ path: (name, rel, wsRel) => {
409
489
  const list = paths.get(name)
410
490
  if (list) list.push(rel)
411
491
  else paths.set(name, [rel])
492
+ if (!dirLinks.has(wsRel)) return
493
+ const links = dirLinksIn.get(name)
494
+ if (links) links.add(rel)
495
+ else dirLinksIn.set(name, new Set([rel]))
412
496
  },
413
497
  repo: (name) => whole.add(name),
414
498
  }
415
499
  : undefined,
416
500
  )
417
501
  for (const name of claimedOwned.keys()) owned.add(name)
502
+ // A run reads each config from the lock, so an entry that moved moved
503
+ // its project's keys, though no config or import did (one evaluated
504
+ // under another env, a preset imported by package name).
505
+ if (diffed.paths.includes(LOCKFILE_NAME) || untracked.includes(LOCKFILE_NAME)) {
506
+ // A side with no lock (or one that will not parse) ran nothing from
507
+ // it: the paths of the configs say what changed, as before the lock.
508
+ const entries = (bytes: Uint8Array | null): Record<string, unknown> | null => {
509
+ if (bytes === null) return null
510
+ try {
511
+ const locked = (JSON.parse(new TextDecoder().decode(bytes)) as { projects?: unknown })
512
+ .projects
513
+ return typeof locked === 'object' && locked !== null
514
+ ? (locked as Record<string, unknown>)
515
+ : null
516
+ } catch {
517
+ return null
518
+ }
519
+ }
520
+ const before = entries(await gitBytesAt(args.workspaceRoot, base, LOCKFILE_NAME))
521
+ const after = entries(await bytesOrNull(path.join(args.workspaceRoot, LOCKFILE_NAME)))
522
+ const same = (dir: string): boolean =>
523
+ before === null ||
524
+ after === null ||
525
+ (Object.hasOwn(before, dir) === Object.hasOwn(after, dir) &&
526
+ JSON.stringify(before[dir]) === JSON.stringify(after[dir]))
527
+ for (const p of projects) {
528
+ if (same(lockKey(rootDir, p.dir))) continue
529
+ owned.add(p.name)
530
+ whole.add(p.name)
531
+ }
532
+ }
418
533
 
419
- // A config that makes an existing directory a project took files from
420
- // the project above it.
421
- const configs = changed.filter((rel) => CONFIG_NAMES.has(path.posix.basename(rel)))
422
- if (configs.length > 0) {
423
- const atBase = await gitBlobsAt(args.workspaceRoot, base, configs)
534
+ // A config or manifest that makes an existing directory a project took
535
+ // files from the project above it.
536
+ const markers = changed.filter((rel) => PROJECT_MARKERS.has(path.posix.basename(rel)))
537
+ if (markers.length > 0) {
538
+ const atBase = await gitBlobsAt(args.workspaceRoot, base, markers)
539
+ // A config the base had in a directory that is no project now: the
540
+ // project is gone, and with it the edge naming who depended on it,
541
+ // while their keys lost an upstream. Today's graph cannot say who.
542
+ const gone = await removedProjectConfig(args.workspaceRoot, atBase, projects)
543
+ if (gone !== undefined) return everything(gone)
424
544
  for (const name of parentsOfNewNested(args.workspaceRoot, atBase, projects)) {
425
545
  owned.add(name)
426
546
  whole.add(name)
@@ -435,16 +555,20 @@ export async function affectedChanges(
435
555
  // (`../../src/index.ts`), whose target is owned.
436
556
  // Per task, a project that owns the changed file still needs to know its
437
557
  // config imported it: that re-keys every task, whatever their inputs.
438
- for (const name of args.configImports === false
439
- ? []
440
- : await configImportOwners({
441
- workspaceRoot: args.workspaceRoot,
442
- projects: projects,
443
- changed,
444
- skip: perTask ? new Set() : owned,
445
- })) {
446
- owned.add(name)
447
- whole.add(name)
558
+ // A file the workspace config imports widens as the config itself does.
559
+ if (args.configImports !== false) {
560
+ const imports = await configImportOwners({
561
+ workspaceRoot: args.workspaceRoot,
562
+ projects: projects,
563
+ changed,
564
+ skip: perTask ? new Set() : owned,
565
+ workspaceConfigs: WORKSPACE_CONFIG_FILENAMES.map((n) => path.join(rootDir, n)),
566
+ })
567
+ if (imports.workspace !== undefined) return everything(imports.workspace)
568
+ for (const name of imports.owners) {
569
+ owned.add(name)
570
+ whole.add(name)
571
+ }
448
572
  }
449
573
 
450
574
  const nestedDirs = [...nested].map((rel) => rel.replace(/\/$/, ''))
@@ -455,22 +579,32 @@ export async function affectedChanges(
455
579
  whole,
456
580
  ...(claimedOwned.size > 0 ? { wholeBy: claimedOwned } : {}),
457
581
  ...(nestedDirs.length > 0 ? { nested: nestedDirs } : {}),
582
+ ...(dirLinks.size > 0 ? { dirLinks, dirLinksIn } : {}),
458
583
  })
459
584
  if (changed.length === 0 || args.workspaceGlobOwners === undefined) return done()
460
- for (const name of await args.workspaceGlobOwners(changed, nestedDirs)) owned.add(name)
585
+ for (const name of await args.workspaceGlobOwners(changed, nestedDirs, dirLinks)) owned.add(name)
461
586
  return done()
462
587
  }
463
588
 
589
+ /** What vx writes under `.vx`, which no key reads (cache/inputs.ts `ALWAYS_IGNORE`). */
590
+ const VX_OWN: ReadonlySet<string> = new Set([LOCKFILE_NAME, '.vx/.gitattributes', '.vx/.gitignore'])
591
+
464
592
  const CONFIG_NAMES: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
593
+ /**
594
+ * The files that make a directory a project: core's configs, and the
595
+ * `package.json` a `discover` plugin reads (core keys every project's own).
596
+ * Discovery names no file it read, so these stand for the base's project set.
597
+ */
598
+ const PROJECT_MARKERS: ReadonlySet<string> = new Set([...PROJECT_CONFIG_FILENAMES, 'package.json'])
465
599
 
466
600
  /**
467
601
  * The projects a NEW nested project took files from. A project's inputs
468
- * stop at every project below it, so a config that makes an existing
602
+ * stop at every project below it, so a marker that makes an existing
469
603
  * directory a project re-keys the project above it — its `**` lost the
470
604
  * directory's files — while containment maps the change to the new
471
605
  * project alone, and `--affected` left the re-keyed parent out. "New" is
472
- * judged at the base: none of the changed configs in its directory was
473
- * there. `atBase` holds each changed config there.
606
+ * judged at the base: none of the changed markers in its directory was
607
+ * there. `atBase` holds each changed marker there.
474
608
  */
475
609
  function parentsOfNewNested(
476
610
  workspaceRoot: string,
@@ -498,6 +632,24 @@ function parentsOfNewNested(
498
632
  return out
499
633
  }
500
634
 
635
+ /** The first config in `atBase` the base had whose file and project are both gone now. */
636
+ async function removedProjectConfig(
637
+ workspaceRoot: string,
638
+ atBase: ReadonlyMap<string, Uint8Array | null>,
639
+ projects: readonly ProjectMeta[],
640
+ ): Promise<string | undefined> {
641
+ const dirs = new Set(projects.map((p) => p.dir.normalize('NFC')))
642
+ for (const [rel, bytes] of atBase) {
643
+ // A manifest's directory may never have been a project; a config's was.
644
+ if (bytes === null || !CONFIG_NAMES.has(path.posix.basename(rel))) continue
645
+ const dir = path.resolve(workspaceRoot, path.posix.dirname(rel)).normalize('NFC')
646
+ if (dirs.has(dir)) continue
647
+ if (await Bun.file(path.join(workspaceRoot, rel)).exists()) continue
648
+ return rel
649
+ }
650
+ return undefined
651
+ }
652
+
501
653
  /**
502
654
  * Each of `files` (workspace-root-relative) as `ref` has it, in one `git
503
655
  * cat-file --batch`: null for a file the ref does not have. `./` anchors
@@ -580,12 +732,82 @@ async function bytesOrNull(file: string): Promise<Uint8Array | null> {
580
732
  return (await f.exists()) ? await f.bytes() : null
581
733
  }
582
734
 
583
- /** The symlinks the index lists, workspace-relative. */
584
- async function trackedLinks(workspaceRoot: string): Promise<string[]> {
735
+ interface IndexEntry {
736
+ path: string
737
+ mode: string
738
+ oid: string
739
+ /** Assume-unchanged or skip-worktree: git does not look at it on disk. */
740
+ flagged: boolean
741
+ skipWorktree: boolean
742
+ }
743
+
744
+ /**
745
+ * The index's stage-0 entries, workspace-relative. `ls-files -v` prefixes
746
+ * a tag: `S` skip-worktree, a lowercase letter assume-unchanged (`s` both).
747
+ */
748
+ async function indexEntries(workspaceRoot: string): Promise<IndexEntry[]> {
749
+ const out: IndexEntry[] = []
750
+ for (const record of await gitPaths(workspaceRoot, ['ls-files', '-s', '-v', '-z'])) {
751
+ const m = /^(\S) (\d+) ([0-9a-f]+) 0\t(.*)$/s.exec(record)
752
+ if (m === null) continue
753
+ const tag = m[1]!
754
+ out.push({
755
+ path: m[4]!,
756
+ mode: m[2]!,
757
+ oid: m[3]!,
758
+ flagged: tag === 'S' || (tag >= 'a' && tag <= 'z'),
759
+ skipWorktree: tag === 'S' || tag === 's',
760
+ })
761
+ }
762
+ return out
763
+ }
764
+
765
+ /**
766
+ * The flagged paths whose disk side moved from the index (`git diff` covers
767
+ * the index against the base): a file whose bytes hash to another blob, a
768
+ * link, or an assume-unchanged file deleted. An absent skip-worktree file
769
+ * is a sparse checkout's: the key leaves it out, and so does this.
770
+ */
771
+ async function flaggedChanges(
772
+ workspaceRoot: string,
773
+ flagged: readonly string[],
774
+ index: readonly IndexEntry[],
775
+ changed: readonly string[],
776
+ ): Promise<string[]> {
777
+ const byPath = new Map(index.map((e) => [e.path, e]))
778
+ const seen = new Set(changed)
585
779
  const out: string[] = []
586
- for (const record of await gitPaths(workspaceRoot, ['ls-files', '-s', '-z'])) {
587
- if (record.startsWith('120000 ')) out.push(record.slice(record.indexOf('\t') + 1))
780
+ const hash: IndexEntry[] = []
781
+ for (const rel of flagged) {
782
+ const entry = byPath.get(rel)
783
+ if (entry === undefined || seen.has(rel)) continue
784
+ // Synchronous: a sparse checkout flags its every absent file, and one
785
+ // awaited stat each was 7 s per 100,000 (a loop of these, 0.15 s).
786
+ if (lstatSync(path.join(workspaceRoot, rel), { throwIfNoEntry: false }) === undefined) {
787
+ if (!entry.skipWorktree) out.push(rel)
788
+ } else if (entry.mode === '120000' || rel.includes('\n')) out.push(rel)
789
+ else hash.push(entry)
588
790
  }
791
+ if (hash.length === 0) return out
792
+ // `--stdin-paths` hashes each file as `git add` would (its clean
793
+ // filters), so an unchanged one names the index's blob.
794
+ const proc = spawnGit(
795
+ ['hash-object', '--stdin-paths'],
796
+ workspaceRoot,
797
+ new TextEncoder().encode(hash.map((e) => `${e.path}\n`).join('')),
798
+ )
799
+ const [stdout, stderr, exit] = await Promise.all([
800
+ new Response(proc.stdout).text(),
801
+ new Response(proc.stderr).text(),
802
+ proc.exited,
803
+ ])
804
+ if (exit !== 0) {
805
+ throw new UserError(`git hash-object failed (exit ${exit}): ${stderr.trim()}`, 'VX_E_GIT')
806
+ }
807
+ const oids = stdout.split('\n')
808
+ hash.forEach((e, i) => {
809
+ if (oids[i] !== e.oid) out.push(e.path)
810
+ })
589
811
  return out
590
812
  }
591
813
 
@@ -600,6 +822,8 @@ async function linkedChanges(
600
822
  workspaceRoot: string,
601
823
  links: readonly string[],
602
824
  changed: readonly string[],
825
+ /** Given each link to a directory. */
826
+ dirs: Set<string>,
603
827
  ): Promise<string[]> {
604
828
  if (links.length === 0) return []
605
829
  const realRoot = await realpath(workspaceRoot).catch(() => workspaceRoot)
@@ -614,6 +838,7 @@ async function linkedChanges(
614
838
  } catch {
615
839
  continue
616
840
  }
841
+ if (dir) dirs.add(link)
617
842
  const rel = path.relative(realRoot, real)
618
843
  if (rel.startsWith('..') || path.isAbsolute(rel)) out.push(link)
619
844
  else inside.push({ link, target: rel.split(path.sep).join('/'), dir })
@@ -628,7 +853,8 @@ async function linkedChanges(
628
853
  '--directory',
629
854
  '-z',
630
855
  '--',
631
- ...(named.length === inside.length ? named : ['.']),
856
+ // A name is a path, never pathspec magic (`:x`, `*`).
857
+ ...(named.length === inside.length ? named.map((n) => `:(literal)${n}`) : ['.']),
632
858
  ])
633
859
  const changedSet = new Set(changed)
634
860
  const under = (p: string, dir: string): boolean => dir === '' || p.startsWith(`${dir}/`)
@@ -643,6 +869,45 @@ async function linkedChanges(
643
869
  return out
644
870
  }
645
871
 
872
+ /**
873
+ * The changed paths a glob must match as a directory: a tracked link to
874
+ * one (`dirs`), and each of `maybe` (a link on either side of the diff,
875
+ * an untracked path) that is a link now to no file.
876
+ */
877
+ async function changedDirLinks(
878
+ workspaceRoot: string,
879
+ changed: readonly string[],
880
+ dirs: ReadonlySet<string>,
881
+ maybe: ReadonlySet<string>,
882
+ ): Promise<Set<string>> {
883
+ const out = new Set<string>()
884
+ await Promise.all(
885
+ changed.map(async (rel) => {
886
+ if (dirs.has(rel)) out.add(rel)
887
+ else if (
888
+ maybe.has(rel) &&
889
+ !rel.endsWith('/') &&
890
+ (await noFileLink(path.join(workspaceRoot, rel)))
891
+ ) {
892
+ out.add(rel)
893
+ }
894
+ }),
895
+ )
896
+ return out
897
+ }
898
+
899
+ /** A link now to anything but a file, or a path that is gone (a deleted link). */
900
+ async function noFileLink(abs: string): Promise<boolean> {
901
+ try {
902
+ if (!(await lstat(abs)).isSymbolicLink()) return false
903
+ } catch {
904
+ return true
905
+ }
906
+ return !(await stat(abs)
907
+ .then((s) => s.isFile())
908
+ .catch(() => false))
909
+ }
910
+
646
911
  /** Run a NUL-separated path-listing git command from the workspace root. */
647
912
  async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]> {
648
913
  const proc = spawnGit([...cmd], workspaceRoot)
@@ -653,68 +918,168 @@ async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]>
653
918
  const stderr = await new Response(proc.stderr).text()
654
919
  const exit = await proc.exited
655
920
  if (exit !== 0) {
656
- throw new UserError(`git ${cmd[0]} failed (exit ${exit}): ${stderr.trim()}`, 'VX_E_GIT')
921
+ throw new UserError(
922
+ `git ${cmd[cmd[0] === '-c' ? 2 : 0]} failed (exit ${exit}): ${stderr.trim()}`,
923
+ 'VX_E_GIT',
924
+ )
657
925
  }
658
926
  return stdout.split('\0').filter((s) => s.length > 0)
659
927
  }
660
928
 
661
929
  /**
662
- * `git diff --raw -z <base>`'s paths, and those that are a gitlink on
663
- * either side: one the change removed took its files with it. Each record
930
+ * `git diff --raw -z <base>`'s paths, those that are a symlink on either
931
+ * side, and those that are a gitlink on either side: one the change
932
+ * removed took its files with it. Each record
664
933
  * is `:<mode> <mode> <oid> <oid> <status>` then its path (one path:
665
934
  * `--no-renames`).
935
+ *
936
+ * `diff.autoRefreshIndex=false`: a porcelain `diff` that meets a
937
+ * stat-dirty file rewrites the index under `index.lock`, and ignores
938
+ * `--no-optional-locks`, so a user's `git commit` beside a run failed on
939
+ * that lock. Without the refresh a stat-only change is listed as a
940
+ * modification (`statOnly` drops it).
666
941
  */
667
942
  async function gitDiffPaths(
668
943
  workspaceRoot: string,
669
944
  base: string,
670
- ): Promise<{ paths: string[]; gitlinks: string[] }> {
671
- const fields = await gitPaths(workspaceRoot, [
672
- 'diff',
673
- '--no-renames',
674
- '--ignore-submodules=none',
675
- '--relative',
676
- '--raw',
677
- '-z',
678
- '--end-of-options',
679
- base,
680
- ])
945
+ partial: Promise<boolean>,
946
+ ): Promise<{ paths: string[]; gitlinks: string[]; links: string[] }> {
947
+ const diff = (...revs: string[]) =>
948
+ gitPaths(workspaceRoot, [
949
+ '-c',
950
+ 'diff.autoRefreshIndex=false',
951
+ 'diff',
952
+ '--no-renames',
953
+ '--ignore-submodules=none',
954
+ '--relative',
955
+ '--raw',
956
+ '--no-abbrev',
957
+ '-z',
958
+ '--end-of-options',
959
+ ...revs,
960
+ ])
961
+ // A partial clone lacks the base's blobs, and a stat-dirty file made
962
+ // `git diff <base>` read them to drop it: one fetch per file, and git
963
+ // 2.43 crashed (exit 139) on the batch. The base's tree against HEAD's
964
+ // reads no blob, HEAD's against the working tree reads only blobs a
965
+ // checkout has; their union over-selects only a file the working tree
966
+ // put back as the base had it.
967
+ const lists =
968
+ (await partial) || fetchedInto.has(workspaceRoot)
969
+ ? await Promise.all([diff(base, 'HEAD'), diff('HEAD')])
970
+ : [await diff(base)]
681
971
  const paths: string[] = []
682
972
  const gitlinks: string[] = []
683
- for (let i = 0; i + 1 < fields.length; i += 2) {
684
- const rel = fields[i + 1]!
685
- paths.push(rel)
686
- const head = fields[i]!
687
- if (head.startsWith('160000 ', 1) || head.startsWith('160000 ', 8)) gitlinks.push(rel)
973
+ const links: string[] = []
974
+ const seen = new Set<string>()
975
+ const unread: { rel: string; oid: string; link: boolean }[] = []
976
+ for (const fields of lists) {
977
+ for (let i = 0; i + 1 < fields.length; i += 2) {
978
+ const rel = fields[i + 1]!
979
+ if (seen.has(rel)) continue
980
+ seen.add(rel)
981
+ paths.push(rel)
982
+ const head = fields[i]!
983
+ if (head.startsWith('160000 ', 1) || head.startsWith('160000 ', 8)) gitlinks.push(rel)
984
+ if (head.startsWith('120000 ', 1) || head.startsWith('120000 ', 8)) links.push(rel)
985
+ const [src, dst, oid, worktree, status] = head.slice(1).split(' ')
986
+ if (status === 'M' && src === dst && /^0+$/.test(worktree!) && src !== '160000')
987
+ unread.push({ rel, oid: oid!, link: src === '120000' })
988
+ }
989
+ }
990
+ if (unread.length === 0) return { paths, gitlinks, links }
991
+ const same = await statOnly(workspaceRoot, unread)
992
+ return { paths: paths.filter((p) => !same.has(p)), gitlinks, links }
993
+ }
994
+
995
+ /**
996
+ * Of the paths the diff listed as modified in the working tree, those
997
+ * whose content is still the base's blob: the working-tree side is hashed
998
+ * as `git add` would (filters, a link's target), so only a stat changed.
999
+ */
1000
+ async function statOnly(
1001
+ workspaceRoot: string,
1002
+ unread: readonly { rel: string; oid: string; link: boolean }[],
1003
+ ): Promise<Set<string>> {
1004
+ const same = new Set<string>()
1005
+ const files: { rel: string; oid: string }[] = []
1006
+ for (const u of unread) {
1007
+ if (!u.link) {
1008
+ // `--stdin-paths` reads a line and unquotes a leading `"`; the
1009
+ // decoder replaced a byte that was not UTF-8. Such a path stays listed.
1010
+ if (!/[\n\uFFFD]/.test(u.rel) && !u.rel.startsWith('"')) files.push(u)
1011
+ continue
1012
+ }
1013
+ try {
1014
+ const target = await readlink(path.join(workspaceRoot, u.rel), 'buffer')
1015
+ const hasher = new Bun.CryptoHasher(u.oid.length === 64 ? 'sha256' : 'sha1')
1016
+ hasher.update(`blob ${target.length}\0`)
1017
+ hasher.update(target)
1018
+ if (hasher.digest('hex') === u.oid) same.add(u.rel)
1019
+ } catch {
1020
+ // not a link any more: a change
1021
+ }
688
1022
  }
689
- return { paths, gitlinks }
1023
+ if (files.length === 0) return same
1024
+ const proc = spawnGit(
1025
+ ['hash-object', '--stdin-paths'],
1026
+ workspaceRoot,
1027
+ new TextEncoder().encode(files.map((f) => `${f.rel}\n`).join('')),
1028
+ )
1029
+ const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
1030
+ if (exit !== 0) return same
1031
+ const oids = out.split('\n')
1032
+ files.forEach((f, i) => {
1033
+ if (oids[i] === f.oid) same.add(f.rel)
1034
+ })
1035
+ return same
1036
+ }
1037
+
1038
+ /** A remote that serves missing objects on demand: `--filter` on a clone or a fetch. */
1039
+ async function isPartialClone(workspaceRoot: string): Promise<boolean> {
1040
+ const proc = spawnGit(
1041
+ ['config', '--get-regexp', String.raw`^(remote\..*\.promisor|extensions\.partialclone)$`],
1042
+ workspaceRoot,
1043
+ )
1044
+ const [out] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
1045
+ return out.split('\n').some((l) => l.startsWith('extensions.') || l.endsWith(' true'))
690
1046
  }
691
1047
 
692
1048
  /**
693
1049
  * The base a bare `--affected` compares with: the merge base of HEAD and
694
1050
  * the base branch (`origin/<baseBranch>`, or the local branch in a clone
695
1051
  * with no remote; its tip when they share no history). On the base branch
696
- * itself there is none (owner, 2026-10-17): what a push to main changed
1052
+ * itself there is none (2026-10-17): what a push to main changed
697
1053
  * is the last good commit's to say (`--affected=<sha>`), and `HEAD~1`
698
1054
  * guessed it wrong after any multi-commit push.
699
1055
  */
700
1056
  export async function defaultAffectedBase(
701
1057
  workspaceRoot: string,
702
1058
  baseBranch: string | undefined,
1059
+ /** The verb's way to take every project instead: `vx show` has no `--all`. */
1060
+ instead = 'run --all (the cache skips what did not change)',
703
1061
  ): Promise<string> {
704
- const found = await baseBranchRef(workspaceRoot, baseBranch)
1062
+ const found =
1063
+ (await baseBranchRef(workspaceRoot, baseBranch)) ??
1064
+ (await fetchBaseBranch(workspaceRoot, baseBranch))
705
1065
  if (found === undefined) {
706
1066
  const noHistory = noHistoryRefusal(workspaceRoot)
707
1067
  if (noHistory !== undefined) throw noHistory
708
1068
  throw new UserError(
709
1069
  `--affected has no base here: ${baseBranch === undefined ? 'no origin/HEAD, main or master' : `no ${baseBranch} or origin/${baseBranch}`} ` +
710
- 'to compare with — a shallow clone? Fetch history (actions/checkout: fetch-depth: 0), ' +
711
- 'set `baseBranch`, or name the base: --affected=origin/main',
1070
+ 'to compare with: set `baseBranch`, or name the base: --affected=origin/main',
712
1071
  'VX_E_AFFECTED_BASE',
713
1072
  )
714
1073
  }
1074
+ // A detached HEAD (a CI checkout of a pushed commit) is on the base
1075
+ // branch when the branch holds it: the base was HEAD, or a commit after
1076
+ // it, and nothing ran, green.
1077
+ if (!found.onIt && found.detached && (await isAncestor(workspaceRoot, 'HEAD', found.ref))) {
1078
+ found.onIt = true
1079
+ }
715
1080
  if (found.onIt) {
716
1081
  throw new UserError(
717
- `--affected has no default on the base branch (${found.branch}): name the last good commit, --affected=<sha>`,
1082
+ `--affected has no default on the base branch (${found.branch}): ${instead}, or name the last good commit, --affected=<sha>`,
718
1083
  'VX_E_AFFECTED_BASE',
719
1084
  )
720
1085
  }
@@ -749,7 +1114,7 @@ function noHistoryRefusal(workspaceRoot: string): UserError | undefined {
749
1114
  async function baseBranchRef(
750
1115
  workspaceRoot: string,
751
1116
  configured: string | undefined,
752
- ): Promise<{ ref: string; branch: string; onIt: boolean } | undefined> {
1117
+ ): Promise<{ ref: string; branch: string; onIt: boolean; detached: boolean } | undefined> {
753
1118
  const text = async (args: string[]): Promise<string> => {
754
1119
  const proc = spawnGit(args, workspaceRoot)
755
1120
  const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
@@ -772,7 +1137,12 @@ async function baseBranchRef(
772
1137
  text(['symbolic-ref', '--short', '-q', 'HEAD']),
773
1138
  ])
774
1139
  const have = new Set(refs.split('\n'))
775
- const at = (branch: string, ref: string) => ({ ref, branch, onIt: head === branch })
1140
+ const at = (branch: string, ref: string) => ({
1141
+ ref,
1142
+ branch,
1143
+ onIt: head === branch,
1144
+ detached: head === '',
1145
+ })
776
1146
  if (remoteHead.startsWith('origin/') && have.has(remoteHead)) {
777
1147
  return at(remoteHead.slice('origin/'.length), remoteHead)
778
1148
  }
@@ -782,13 +1152,15 @@ async function baseBranchRef(
782
1152
  }
783
1153
 
784
1154
  /**
785
- * Does `ref` name the commit HEAD is on? A base that IS HEAD can never
786
- * mark anything affected — the shape of a single-branch clone whose
787
- * `origin/HEAD` is the branch under test.
1155
+ * The base `ref` names (`<base>...HEAD` reads as `<base>`, as
1156
+ * `affectedChanges` reads it) when it is the commit HEAD is on, else
1157
+ * undefined. A base that IS HEAD can never mark anything affected — the
1158
+ * shape of a single-branch clone whose `origin/HEAD` is the branch under test.
788
1159
  */
789
- export function refIsHead(workspaceRoot: string, ref: string): boolean {
1160
+ export function headBase(workspaceRoot: string, ref: string): string | undefined {
790
1161
  const head = revParse(workspaceRoot, 'HEAD')
791
- return head !== undefined && head === revParse(workspaceRoot, ref)
1162
+ const base = HEAD_RANGE.exec(ref)?.[1] ?? ref
1163
+ return head !== undefined && head === revParse(workspaceRoot, base) ? base : undefined
792
1164
  }
793
1165
 
794
1166
  /** The commit `ref` names, or undefined when it does not resolve here. */
@@ -826,15 +1198,37 @@ async function mergeBase(workspaceRoot: string, ref: string): Promise<string> {
826
1198
  // which the default base's search already resolved (`HEAD~1`).
827
1199
  const known = HEAD_ANCESTOR.test(ref) ? resolvedRefs.get(`${workspaceRoot}\0${ref}`) : undefined
828
1200
  if (known !== undefined) return known
829
- const proc = spawnGit(['merge-base', '--end-of-options', ref, 'HEAD'], workspaceRoot)
830
- const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
831
- const sha = out.trim()
832
- if (exit === 0 && sha.length > 0) return sha
1201
+ const sha = await gitMergeBase(workspaceRoot, ref)
1202
+ if (sha !== undefined) return sha
1203
+ const reached = await reachMergeBase(workspaceRoot, ref)
1204
+ if (reached !== undefined) return reached
1205
+ await verifyTreeRef(workspaceRoot, ref)
1206
+ // The ref itself is the base only for histories that share nothing. A
1207
+ // shallow clone may just lack the commits between them (origin gone, a
1208
+ // fetch failed, past MAX_DEEPEN), and its tip selected what the base
1209
+ // branch changed.
1210
+ if (
1211
+ revParse(workspaceRoot, ref) !== undefined &&
1212
+ revParse(workspaceRoot, 'HEAD') !== undefined &&
1213
+ isShallow(workspaceRoot)
1214
+ ) {
1215
+ throw new UserError(
1216
+ `git ref "${ref}" has no merge base with HEAD in this shallow clone, and origin did not ` +
1217
+ 'give the history between them: fetch it (`git fetch --unshallow origin`).',
1218
+ 'VX_E_AFFECTED_BASE',
1219
+ )
1220
+ }
1221
+ return ref
1222
+ }
1223
+
1224
+ /**
1225
+ * `verifyRef`, then refuse `<rev>:<path>`: it names the tree (or blob) at
1226
+ * <path>, and diffed against the working tree its paths miss the <path>/
1227
+ * prefix, so `develop:pkgs` selected projects nothing had changed, green.
1228
+ * A root tree (`develop^{tree}`, the empty tree) diffs right and stays a base.
1229
+ */
1230
+ async function verifyTreeRef(workspaceRoot: string, ref: string): Promise<void> {
833
1231
  await verifyRef(workspaceRoot, ref)
834
- // `<rev>:<path>` names the tree (or blob) at <path>: diffed against the
835
- // working tree, its paths miss the <path>/ prefix, and `develop:pkgs`
836
- // selected projects nothing had changed, green. A root tree
837
- // (`develop^{tree}`, the empty tree) diffs right and stays a base.
838
1232
  const sub = /^([^:]*):(?!\/)(.+)$/s.exec(ref)
839
1233
  if (sub !== null) {
840
1234
  throw new UserError(
@@ -843,9 +1237,201 @@ async function mergeBase(workspaceRoot: string, ref: string): Promise<string> {
843
1237
  'VX_E_AFFECTED_BASE',
844
1238
  )
845
1239
  }
1240
+ }
1241
+
1242
+ /**
1243
+ * The merge base of `ref` and HEAD, or undefined when there is none. A
1244
+ * shallow clone fetches what it needs from origin (`reachMergeBase`):
1245
+ * a plugin reading the base branch's history (shared timings) asks this,
1246
+ * not `git merge-base`, so a one-commit checkout serves it too.
1247
+ */
1248
+ export async function mergeBaseWith(
1249
+ workspaceRoot: string,
1250
+ ref: string,
1251
+ ): Promise<string | undefined> {
1252
+ return (await gitMergeBase(workspaceRoot, ref)) ?? (await reachMergeBase(workspaceRoot, ref))
1253
+ }
1254
+
1255
+ /** Whether `commit` is `ref` or one of its ancestors. */
1256
+ async function isAncestor(workspaceRoot: string, commit: string, ref: string): Promise<boolean> {
1257
+ const proc = spawnGit(
1258
+ ['merge-base', '--is-ancestor', '--end-of-options', commit, ref],
1259
+ workspaceRoot,
1260
+ )
1261
+ return (await proc.exited) === 0
1262
+ }
1263
+
1264
+ async function gitMergeBase(workspaceRoot: string, ref: string): Promise<string | undefined> {
1265
+ const proc = spawnGit(['merge-base', '--end-of-options', ref, 'HEAD'], workspaceRoot)
1266
+ const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
1267
+ const sha = out.trim()
1268
+ return exit === 0 && sha.length > 0 ? sha : undefined
1269
+ }
1270
+
1271
+ /**
1272
+ * A shallow clone (CI's checkout fetches one commit) lacks the base, or
1273
+ * the history between it and HEAD: this fetches from origin what the
1274
+ * merge base needs and nothing more (2026-10-24: "we only need
1275
+ * current commit and branch out commit"). The base's tip at depth 1, then
1276
+ * both tips deepened 1, 2, 4… commits until git finds the merge base;
1277
+ * commits and trees only, as `--name-only` reads no blob. A pull request's
1278
+ * merge checkout has its base as HEAD's parent, so it needs one fetch.
1279
+ * Undefined when the clone is whole, has no origin, or the fetch failed.
1280
+ */
1281
+ async function reachMergeBase(workspaceRoot: string, ref: string): Promise<string | undefined> {
1282
+ if (!isShallow(workspaceRoot)) return undefined
1283
+ const head = revParse(workspaceRoot, 'HEAD')
1284
+ if (head === undefined) return undefined
1285
+ const ancestor = HEAD_ANCESTOR.test(ref)
1286
+ let tip = ancestor ? undefined : revParse(workspaceRoot, ref)
1287
+ if (tip === undefined && !ancestor) {
1288
+ tip = await fetchTip(workspaceRoot, ref)
1289
+ if (tip === undefined) return undefined
1290
+ }
1291
+ if (tip !== undefined && parentsOf(workspaceRoot, head).includes(tip)) return tip
1292
+ for (let depth = 1; depth <= MAX_DEEPEN; depth *= 2) {
1293
+ const tips = tip === undefined ? [head] : [head, tip]
1294
+ if (!(await fetchOrigin(workspaceRoot, [`--deepen=${depth}`, ...tips]))) return undefined
1295
+ // `HEAD~2` is its own merge base with HEAD, once the history has it.
1296
+ if (ancestor) {
1297
+ const at = revParse(workspaceRoot, ref)
1298
+ if (at !== undefined) return at
1299
+ } else {
1300
+ const found = await gitMergeBase(workspaceRoot, tip!)
1301
+ if (found !== undefined) return found
1302
+ }
1303
+ if (!isShallow(workspaceRoot)) return undefined
1304
+ }
1305
+ return undefined
1306
+ }
1307
+
1308
+ /** `ref`'s commit fetched from origin at depth 1, or undefined when origin cannot name it. */
1309
+ async function fetchTip(workspaceRoot: string, ref: string): Promise<string | undefined> {
1310
+ const spec = originSpec(ref)
1311
+ if (spec === undefined || !(await fetchOrigin(workspaceRoot, ['--depth=1', spec]))) {
1312
+ return undefined
1313
+ }
1314
+ return revParse(workspaceRoot, ref)
1315
+ }
1316
+
1317
+ /**
1318
+ * The base of a two-dot `<ref>..HEAD`: `ref` itself, which a shallow clone
1319
+ * fetches as the three-dot form fetches its tip (CI's depth-1 checkout
1320
+ * refused `origin/main..HEAD` while `origin/main...HEAD` worked). HEAD's
1321
+ * own ancestor is its merge base with HEAD, reached by deepening.
1322
+ */
1323
+ async function rangeBase(workspaceRoot: string, ref: string): Promise<string> {
1324
+ if (HEAD_ANCESTOR.test(ref)) return mergeBase(workspaceRoot, ref)
1325
+ if (revParse(workspaceRoot, ref) === undefined && isShallow(workspaceRoot)) {
1326
+ await fetchTip(workspaceRoot, ref)
1327
+ }
1328
+ await verifyTreeRef(workspaceRoot, ref)
846
1329
  return ref
847
1330
  }
848
1331
 
1332
+ /** The clones `fetchOrigin` fetched into, so made partial: `isPartialClone` may have answered first. */
1333
+ const fetchedInto = new Set<string>()
1334
+
1335
+ /** Deepening stops here (2,047 commits on each side): past it, the base is the user's to fetch. */
1336
+ const MAX_DEEPEN = 1024
1337
+
1338
+ function isShallow(workspaceRoot: string): boolean {
1339
+ const proc = spawnGitSync(['rev-parse', '--is-shallow-repository'], workspaceRoot, 'ignore')
1340
+ return new TextDecoder().decode(proc.stdout).trim() === 'true'
1341
+ }
1342
+
1343
+ /** The parents a commit names, even where a shallow clone cut them off. */
1344
+ function parentsOf(workspaceRoot: string, sha: string): string[] {
1345
+ const proc = spawnGitSync(
1346
+ ['cat-file', '--end-of-options', 'commit', sha],
1347
+ workspaceRoot,
1348
+ 'ignore',
1349
+ )
1350
+ const out: string[] = []
1351
+ for (const line of new TextDecoder().decode(proc.stdout).split('\n')) {
1352
+ if (line === '') break
1353
+ if (line.startsWith('parent ')) out.push(line.slice('parent '.length))
1354
+ }
1355
+ return out
1356
+ }
1357
+
1358
+ /**
1359
+ * What to ask origin for a base: a commit id as it is (GitHub serves any
1360
+ * commit a ref reaches), `origin/<branch>` as that branch into its
1361
+ * remote-tracking ref, any other plain name as origin's tag of that name
1362
+ * (a release base, `v1`; `--no-tags` fetched none). An expression (`v1~2`)
1363
+ * is a name only this clone knows.
1364
+ */
1365
+ function originSpec(ref: string): string | undefined {
1366
+ if (/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(ref)) return ref
1367
+ const branch = /^(?:refs\/remotes\/)?origin\/(.+)$/s.exec(ref)?.[1]
1368
+ if (branch !== undefined) {
1369
+ return branch === 'HEAD' ? undefined : `+refs/heads/${branch}:refs/remotes/origin/${branch}`
1370
+ }
1371
+ const tag = /^(?:refs\/tags\/)?([^\s~^:?*[\\@{]+)$/.exec(ref)?.[1]
1372
+ if (tag === undefined || tag === 'HEAD' || tag.startsWith('refs/')) return undefined
1373
+ return `+refs/tags/${tag}:refs/tags/${tag}`
1374
+ }
1375
+
1376
+ /**
1377
+ * A shallow clone with no base branch at all (a CI checkout of one
1378
+ * commit): fetch it from origin at depth 1, the configured one or
1379
+ * origin's default.
1380
+ */
1381
+ async function fetchBaseBranch(
1382
+ workspaceRoot: string,
1383
+ configured: string | undefined,
1384
+ ): Promise<{ ref: string; branch: string; onIt: boolean; detached: boolean } | undefined> {
1385
+ if (!isShallow(workspaceRoot)) return undefined
1386
+ const branch = configured ?? (await originDefaultBranch(workspaceRoot))
1387
+ if (branch === undefined) return undefined
1388
+ const spec = `+refs/heads/${branch}:refs/remotes/origin/${branch}`
1389
+ if (!(await fetchOrigin(workspaceRoot, ['--depth=1', spec]))) return undefined
1390
+ const proc = spawnGitSync(['symbolic-ref', '--short', '-q', 'HEAD'], workspaceRoot, 'ignore')
1391
+ const head = new TextDecoder().decode(proc.stdout).trim()
1392
+ return { ref: `origin/${branch}`, branch, onIt: head === branch, detached: head === '' }
1393
+ }
1394
+
1395
+ /** The branch origin's HEAD names, asked of origin (a one-commit checkout has no `origin/HEAD`). */
1396
+ async function originDefaultBranch(workspaceRoot: string): Promise<string | undefined> {
1397
+ const proc = spawnGit(['ls-remote', '--symref', 'origin', 'HEAD'], workspaceRoot)
1398
+ const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
1399
+ if (exit !== 0) return undefined
1400
+ return /^ref: refs\/heads\/(\S+)\tHEAD$/m.exec(out)?.[1]
1401
+ }
1402
+
1403
+ /**
1404
+ * `git fetch` from origin into a shallow clone, without blobs: the diff
1405
+ * names paths, and the lockfile a claim reads at the base is fetched on
1406
+ * demand. Never prompts for credentials; false when it failed.
1407
+ */
1408
+ async function fetchOrigin(workspaceRoot: string, args: string[]): Promise<boolean> {
1409
+ try {
1410
+ const proc = Bun.spawn({
1411
+ cmd: [
1412
+ executablePath('git'),
1413
+ 'fetch',
1414
+ '--quiet',
1415
+ '--no-tags',
1416
+ '--no-recurse-submodules',
1417
+ '--filter=blob:none',
1418
+ 'origin',
1419
+ ...args,
1420
+ ],
1421
+ env: { ...gitEnv(), GIT_TERMINAL_PROMPT: '0' },
1422
+ cwd: workspaceRoot,
1423
+ stdin: 'ignore',
1424
+ stdout: 'ignore',
1425
+ stderr: 'ignore',
1426
+ })
1427
+ fetchedInto.add(workspaceRoot)
1428
+ return (await proc.exited) === 0
1429
+ } catch (err) {
1430
+ if (isExecutableMissing(err)) throw gitSpawnRefusal(workspaceRoot)
1431
+ throw err
1432
+ }
1433
+ }
1434
+
849
1435
  async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
850
1436
  if (resolvedRefs.has(`${workspaceRoot}\0${ref}`)) return
851
1437
  const proc = spawnGitSync(
@@ -863,20 +1449,37 @@ async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
863
1449
  if (proc.exitCode !== 1) {
864
1450
  const noHistory = noHistoryRefusal(workspaceRoot)
865
1451
  if (noHistory !== undefined) throw noHistory
1452
+ // `--quiet` also silences a ref git refused in its own words (a reflog
1453
+ // entry the log lacks, `main@{9}`: exit 128): ask again to say why.
1454
+ if (stderr.length === 0) {
1455
+ const loud = spawnGitSync(
1456
+ ['rev-parse', '--verify', '--end-of-options', ref],
1457
+ workspaceRoot,
1458
+ 'pipe',
1459
+ )
1460
+ const why = new TextDecoder()
1461
+ .decode(loud.stderr)
1462
+ .trim()
1463
+ .replace(/^fatal: /, '')
1464
+ if (loud.exitCode !== 0 && why.length > 0) {
1465
+ throw new UserError(
1466
+ `git ref "${ref}" did not resolve: ${why.replace(/\.?$/, '.')}`,
1467
+ 'VX_E_AFFECTED_BASE',
1468
+ )
1469
+ }
1470
+ }
866
1471
  throw new UserError(
867
1472
  `git rev-parse failed (exit ${proc.exitCode}) in ${workspaceRoot}` +
868
1473
  (stderr.length > 0 ? `: ${stderr}` : ''),
869
1474
  'VX_E_GIT',
870
1475
  )
871
1476
  }
872
- // A shallow clone (CI's checkout fetches one commit by default) has no
873
- // HEAD~1 and no base branch: the ref exists, the history does not.
874
- const shallow = spawnGitSync(['rev-parse', '--is-shallow-repository'], workspaceRoot, 'pipe')
875
- const isShallow = new TextDecoder().decode(shallow.stdout).trim() === 'true'
1477
+ // A shallow clone whose base origin could not give (`reachMergeBase`):
1478
+ // no origin, no network, a name only another clone has.
876
1479
  throw new UserError(
877
1480
  `git ref "${ref}" did not resolve. Pass a branch or commit you have locally.` +
878
- (isShallow
879
- ? ' This clone is shallow: fetch the history the base needs (`git fetch --unshallow`, or `fetch-depth: 0` on actions/checkout).'
1481
+ (isShallow(workspaceRoot)
1482
+ ? ' This clone is shallow and origin did not give it: fetch the base (`git fetch --depth=1 origin <base>`).'
880
1483
  : ''),
881
1484
  'VX_E_AFFECTED_BASE',
882
1485
  )
@@ -890,7 +1493,7 @@ function projectsContaining(
890
1493
  projects: readonly ProjectMeta[],
891
1494
  realDirs: ReadonlyMap<string, string>,
892
1495
  /** Told each owned path relative to its project, and each nested repository's projects. */
893
- on?: { path(name: string, rel: string): void; repo(name: string): void },
1496
+ on?: { path(name: string, rel: string, wsRel: string): void; repo(name: string): void },
894
1497
  ): Set<string> {
895
1498
  // Index projects by their (canonical) dir, then for each changed path walk
896
1499
  // its ancestor dirs bottom-up until one is a project dir. The FIRST hit is
@@ -924,7 +1527,7 @@ function projectsContaining(
924
1527
  const name = dirToName.get(dir)
925
1528
  if (name !== undefined) {
926
1529
  owned.add(name)
927
- on?.path(name, relPosix(dir, path.resolve(workspaceRoot, rel).normalize('NFC')))
1530
+ on?.path(name, relPosix(dir, path.resolve(workspaceRoot, rel).normalize('NFC')), rel)
928
1531
  // Its files are the project's inputs, and none of them is this
929
1532
  // path: a task's globs cannot say whether they reach inside.
930
1533
  if (repo) on?.repo(name)