@vzn/vx 0.0.642 → 0.0.644

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 (52) hide show
  1. package/package.json +5 -5
  2. package/schemas/info.json +0 -11
  3. package/skills/vx/SKILL.md +1 -1
  4. package/src/cache/archive.ts +153 -2
  5. package/src/cache/cache.ts +417 -183
  6. package/src/cache/file-hashes.ts +2 -14
  7. package/src/cache/fold-memo.ts +7 -3
  8. package/src/cache/index.ts +10 -2
  9. package/src/cache/key-fold.ts +4 -1
  10. package/src/cache/layer.ts +11 -4
  11. package/src/cache/layered-cache.ts +82 -8
  12. package/src/cache/plugin-cache.ts +51 -43
  13. package/src/cache/remote-lane.ts +18 -13
  14. package/src/cache/run-store.ts +4 -1
  15. package/src/cache/scoped-cache.ts +74 -239
  16. package/src/cache/tar-stream.ts +12 -7
  17. package/src/cli/format.ts +1 -2
  18. package/src/cli/help.ts +8 -4
  19. package/src/cli/info.ts +2 -19
  20. package/src/cli/plugin-commands.ts +11 -6
  21. package/src/cli/run.ts +7 -12
  22. package/src/cli/select.ts +7 -9
  23. package/src/cli/upgrade.ts +38 -8
  24. package/src/cli/workspace-config.ts +8 -2
  25. package/src/config.ts +22 -26
  26. package/src/exec/sandbox-paths.ts +2 -2
  27. package/src/graph/scheduler.ts +3 -1
  28. package/src/graph/task-graph.ts +15 -11
  29. package/src/index.ts +5 -1
  30. package/src/orchestrator/affected-tasks.ts +23 -2
  31. package/src/orchestrator/doctor.ts +0 -17
  32. package/src/orchestrator/execute-task.ts +1 -1
  33. package/src/orchestrator/index.ts +4 -0
  34. package/src/orchestrator/options.ts +15 -1
  35. package/src/orchestrator/plugin-host.ts +11 -4
  36. package/src/orchestrator/plugin.ts +23 -2
  37. package/src/orchestrator/prepare.ts +81 -18
  38. package/src/orchestrator/projects.ts +26 -5
  39. package/src/orchestrator/remote-prefetch.ts +11 -4
  40. package/src/orchestrator/run-context.ts +85 -0
  41. package/src/orchestrator/run-settings.ts +3 -0
  42. package/src/orchestrator/run.ts +9 -26
  43. package/src/orchestrator/task-hash.ts +6 -0
  44. package/src/report/logger.ts +0 -4
  45. package/src/report/summary.ts +3 -11
  46. package/src/settings.ts +54 -25
  47. package/src/util/index.ts +1 -0
  48. package/src/util/paths.ts +42 -33
  49. package/src/util/size.ts +4 -5
  50. package/src/workspace/affected.ts +120 -63
  51. package/src/workspace/config-schema.ts +44 -52
  52. package/src/workspace/index.ts +3 -0
package/src/settings.ts CHANGED
@@ -17,7 +17,8 @@ export type SettingKind =
17
17
  | { readonly type: 'concurrency' }
18
18
  // `zero`: 0 is a value ("wait for nothing not already done"), not a typo.
19
19
  | { readonly type: 'duration'; readonly timer?: true; readonly zero?: true }
20
- | { readonly type: 'size' }
20
+ // `off`: `false` is a value, 0 bytes (the thing the size bounds is off).
21
+ | { readonly type: 'size'; readonly off?: true }
21
22
  | { readonly type: 'bool' }
22
23
  | { readonly type: 'enum'; readonly values: readonly string[] }
23
24
  | { readonly type: 'string' }
@@ -43,6 +44,12 @@ export interface Setting {
43
44
  const SCHEDULES = ['most-work', 'critical-path', 'direct-dependents', 'ready-order'] as const
44
45
 
45
46
  export const SETTINGS: readonly Setting[] = [
47
+ {
48
+ path: 'baseBranch',
49
+ kind: { type: 'string' },
50
+ default: "origin/HEAD's branch, then main, then master",
51
+ doc: 'The default branch: a run on it has no cache scope and a bare `--affected` no base; elsewhere both use the merge base with `origin/<baseBranch>`.',
52
+ },
46
53
  {
47
54
  path: 'run.concurrency',
48
55
  kind: { type: 'concurrency' },
@@ -57,12 +64,6 @@ export const SETTINGS: readonly Setting[] = [
57
64
  doc: 'Which ready task starts first when there are more than workers.',
58
65
  flag: { verb: 'run', name: '--schedule' },
59
66
  },
60
- {
61
- path: 'run.affectedBase',
62
- kind: { type: 'string' },
63
- default: 'origin/HEAD, then the trunk, else HEAD~1',
64
- doc: 'The git ref a bare `--affected` compares with.',
65
- },
66
67
  {
67
68
  path: 'run.branch',
68
69
  kind: { type: 'string' },
@@ -115,18 +116,6 @@ export const SETTINGS: readonly Setting[] = [
115
116
  doc: 'Where the cache lives, relative to the workspace root; named, it holds the whole cache.',
116
117
  flag: { verb: 'run', name: '--cache-dir' },
117
118
  },
118
- {
119
- path: 'cache.retention.maxSize',
120
- kind: { type: 'size' },
121
- default: 'none',
122
- doc: "Evict least-recently-used entries at each run's end until the cache is under this (`'10GB'`).",
123
- },
124
- {
125
- path: 'cache.retention.olderThan',
126
- kind: { type: 'duration' },
127
- default: 'none',
128
- doc: "Evict entries unused for this long at each run's end (`'30d'`).",
129
- },
130
119
  {
131
120
  path: 'cache.policy',
132
121
  kind: { type: 'string' },
@@ -137,9 +126,30 @@ export const SETTINGS: readonly Setting[] = [
137
126
  {
138
127
  path: 'cache.scope',
139
128
  kind: { type: 'string' },
140
- default: 'none (the default keys)',
141
- doc: "Where cache writes land (`'pr-123'`): reads try the default key, then the scope's.",
142
- flag: { verb: 'run', name: '--cache-scope' },
129
+ default: "the branch's, `<owner>/<repo>:<branch>`, with a remote cache off `baseBranch`",
130
+ doc: "A branch's remote entries (`'vznjs/vx:feat-x'`): tasks the branch changed read the scope's key, the rest the default key; the local cache never uses it. Empty: no scope.",
131
+ config: false,
132
+ },
133
+ {
134
+ path: 'cache.concurrency.download',
135
+ kind: { type: 'int', min: 1 },
136
+ default: 'min(96, max(cores × 3, 64))',
137
+ doc: 'Remote downloads at once.',
138
+ flag: { verb: 'run', name: '--cache-concurrency-download' },
139
+ },
140
+ {
141
+ path: 'cache.concurrency.upload',
142
+ kind: { type: 'int', min: 1 },
143
+ default: 'min(96, max(cores × 3, 64))',
144
+ doc: 'Uploads at once, per remote.',
145
+ flag: { verb: 'run', name: '--cache-concurrency-upload' },
146
+ },
147
+ {
148
+ path: 'cache.concurrency.restore',
149
+ kind: { type: 'int', min: 1 },
150
+ default: 'twice `run.concurrency`',
151
+ doc: 'Local hits restoring outputs at once.',
152
+ flag: { verb: 'run', name: '--cache-concurrency-restore' },
143
153
  },
144
154
  {
145
155
  path: 'cache.download',
@@ -170,6 +180,24 @@ export const SETTINGS: readonly Setting[] = [
170
180
  task: 'cache.outputs.clean',
171
181
  env: false,
172
182
  },
183
+ {
184
+ path: 'cache.store.embeddings',
185
+ kind: { type: 'size', off: true },
186
+ default: "'32KiB'",
187
+ doc: 'Keep an entry up to this size inside the cache index, one zstd tar, rather than as files; `false` keeps none.',
188
+ },
189
+ {
190
+ path: 'cache.store.decompress',
191
+ kind: { type: 'bool' },
192
+ default: 'true',
193
+ doc: 'Keep a larger entry as its plain files, which a hit copies or clones out; off, as one `.tar.zst`.',
194
+ },
195
+ {
196
+ path: 'cache.store.verify',
197
+ kind: { type: 'enum', values: ['stat', 'hash', 'none'] },
198
+ default: 'stat',
199
+ doc: "What a hit on plain files checks first: each file's size and mtime, every byte, or nothing.",
200
+ },
173
201
  {
174
202
  path: 'cache.upload.failures',
175
203
  kind: { type: 'enum', values: ['ci', 'local', 'always'] },
@@ -220,7 +248,7 @@ export const SETTINGS: readonly Setting[] = [
220
248
  },
221
249
  ]
222
250
 
223
- /** The `.vx/workspace` groups that hold settings; `plugins` is the other key. */
251
+ /** The `.vx/workspace` groups that hold settings; `baseBranch` and `plugins` are the other keys. */
224
252
  export const SETTING_GROUPS = ['run', 'cache', 'lock', 'watch'] as const
225
253
 
226
254
  const BY_PATH = new Map(SETTINGS.map((s) => [s.path, s]))
@@ -229,7 +257,7 @@ export function setting(path: string): Setting {
229
257
  return BY_PATH.get(path)!
230
258
  }
231
259
 
232
- /** `run.affectedBase` → `VX_RUN_AFFECTED_BASE`. */
260
+ /** `run.branch` → `VX_RUN_BRANCH`. */
233
261
  export function envName(path: string): string {
234
262
  return `VX_${path
235
263
  .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
@@ -250,7 +278,7 @@ export function kindHint(kind: SettingKind): string {
250
278
  ? "a duration like '500ms', '30s' or '5m', at most 24 days"
251
279
  : "a duration like '45s', '12h' or '30d'"
252
280
  case 'size':
253
- return "a size like '500MB' or '10GB'"
281
+ return kind.off === true ? "false, or a size like '32KiB'" : "a size like '500MB' or '10GB'"
254
282
  case 'bool':
255
283
  return 'true or false'
256
284
  case 'enum':
@@ -298,6 +326,7 @@ export function parseSettingValue(kind: SettingKind, raw: unknown): unknown {
298
326
  return kind.timer === true && n > MAX_TIMEOUT_MS ? null : n
299
327
  }
300
328
  case 'size':
329
+ if (kind.off === true && (raw === false || raw === 'false')) return 0
301
330
  return typeof raw === 'string' ? parseSize(raw) : null
302
331
  case 'bool':
303
332
  if (typeof raw === 'boolean') return raw
package/src/util/index.ts CHANGED
@@ -55,6 +55,7 @@ export {
55
55
  EXTGLOB,
56
56
  grantPrefix,
57
57
  isLiteralPattern,
58
+ literalPath,
58
59
  GLOB_WILDCARDS,
59
60
  normalizeBunGlob,
60
61
  normalizeGlob,
package/src/util/paths.ts CHANGED
@@ -48,7 +48,15 @@ export function relPosix(from: string, to: string): string {
48
48
  * Every glob reaching here is user-written, so every one takes the rule.
49
49
  */
50
50
  export function staticPrefix(rawGlob: string): string {
51
- return prefixOf(normalizeGlob(rawGlob), GLOB_WILDCARDS)
51
+ // An escaped character is part of the prefix and names itself:
52
+ // `app/\[id\]/**` is rooted at the directory `app/[id]`.
53
+ const g = normalizeGlob(rawGlob)
54
+ if (!g.includes('\\')) return prefixOf(g, GLOB_WILDCARDS)
55
+ const masked = g.replace(/\\./g, '\0\0')
56
+ const prefix = prefixOf(masked, GLOB_WILDCARDS)
57
+ return prefix === '.' || prefix === '/'
58
+ ? prefix
59
+ : g.slice(0, prefix.length).replace(/\\(.)/g, '$1')
52
60
  }
53
61
 
54
62
  /**
@@ -98,10 +106,10 @@ export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null
98
106
  if (globs.length === 0) return null
99
107
  const out: string[] = []
100
108
  for (const g of globs.map(normalizeGlob)) {
101
- const m = /^([^*?{}!]+?)\/\*\*$/.exec(g)
102
- const whole = m?.[1] ?? (isLiteralPattern(g) && !g.startsWith('!') ? g : null)
103
- if (whole === null) return null
104
- const dir = whole.replace(/\/+$/, '')
109
+ const named = /^(.+?)\/\*\*$/.exec(g)?.[1] ?? g
110
+ if (g.startsWith('!') || !namesOnePath(named)) return null
111
+ // An escaped bracket names itself: `app/\[id\]/**` is the tree `app/[id]`.
112
+ const dir = named.replace(/\\(.)/g, '$1').replace(/\/+$/, '')
105
113
  if (dir === '' || dir === '.' || dir.startsWith('/') || dir.split('/').includes('..'))
106
114
  return null
107
115
  out.push(dir)
@@ -136,10 +144,6 @@ function normalizeWith(glob: string, wildcards: RegExp): string {
136
144
  let g = neg ? glob.slice(1) : glob
137
145
  g = g.replace(/\/{2,}/g, '/').replace(/(^|\/)(\.\/)+/g, '$1')
138
146
  if (g === '.') g = ''
139
- // Where a bracket is literal, Turbo's escaped spelling `\[id\]` names the
140
- // same path as `[id]`; one spelling is what lets every literal fast path
141
- // (string compare, `settleLiterals`, `asTrees`) see it as a literal.
142
- if (wildcards === GLOB_WILDCARDS && g.includes('\\')) g = g.replace(/\\([[\]])/g, '$1')
143
147
  if (wildcards.test(g) && g.endsWith('/')) g = `${g.replace(/\/+$/, '')}/**`
144
148
  return neg ? `!${g}` : g
145
149
  }
@@ -172,21 +176,19 @@ export function isLiteralPattern(glob: string): boolean {
172
176
  * class written out nine times — a spelling that drifts is how two guards
173
177
  * come to answer differently for one pattern.
174
178
  *
175
- * `[` and `]` are NOT in it: a bracket is a literal character in a task
176
- * glob, and there are no character classes (item 667). Route directories
177
- * are named `[id]` across Next.js, SvelteKit and Astro, and read as a class
178
- * `app/[id]/**` matched `app/i/…` and `app/d/…` and never the route: the
179
- * file never entered the key (a stale hit) and the output clean deleted an
180
- * unrelated `app/i/page.js`. {@link taskGlob} is how such a glob reaches
181
- * `Bun.Glob`, whose own alphabet still reads the class.
179
+ * A task glob is a standard glob (M, 2026-10-18): `[...]` is a character
180
+ * class and `\[id\]` names the route directory `[id]`. An escape counts as
181
+ * a wildcard too: such a pattern is matched by `Bun.Glob`, which reads the
182
+ * escape, never compared as a string; `asTrees` still reads an escaped-only
183
+ * pattern as one path.
182
184
  */
183
- export const GLOB_WILDCARDS = /[*?{}]/
185
+ export const GLOB_WILDCARDS = /[*?{}[\]\\]/
184
186
 
185
187
  /**
186
- * `Bun.Glob`'s own alphabet, the class included, for the globs vx does not
187
- * own: package-manager workspace members and `--filter` path globs follow
188
- * npm/pnpm semantics, and a sandbox grant's prefix must stop where the scan
189
- * that expands the grant (`Bun.Glob`, `MOUNT_WILDCARDS`) sees a wildcard.
188
+ * `Bun.Glob`'s own alphabet, for the globs vx does not own: package-manager
189
+ * workspace members and `--filter` path globs follow npm/pnpm semantics,
190
+ * and a sandbox grant's prefix must stop where the scan that expands the
191
+ * grant (`Bun.Glob`, `MOUNT_WILDCARDS`) sees a wildcard.
190
192
  */
191
193
  export const BUN_GLOB_WILDCARDS = /[*?[\]{}]/
192
194
 
@@ -200,11 +202,7 @@ export const BUN_GLOB_WILDCARDS = /[*?[\]{}]/
200
202
  */
201
203
  export const EXTGLOB = /[!@+*?]\(/
202
204
 
203
- /**
204
- * Compile a task glob for `Bun.Glob`, with every bare bracket escaped so it
205
- * matches itself (item 667). Every task glob is compiled here and nowhere
206
- * else: one site that forgot is a route directory that keys nothing.
207
- */
205
+ /** Compile a task glob for `Bun.Glob`. Every task glob is compiled here and nowhere else. */
208
206
  export function taskGlob(pattern: string): Bun.Glob {
209
207
  const memo = memoFor(taskGlobs)
210
208
  let glob = memo.get(pattern)
@@ -230,9 +228,7 @@ function memoFor<T>(memos: WeakMap<object, Map<string, T>>): Map<string, T> {
230
228
  }
231
229
 
232
230
  function compileTaskGlob(pattern: string): Bun.Glob {
233
- // A `]` with no `[` before it is already literal to `Bun.Glob`.
234
- const source = pattern.includes('[') ? pattern.replace(/(?<!\\)[[\]]/g, '\\$&') : pattern
235
- const glob = new Bun.Glob(source)
231
+ const glob = new Bun.Glob(pattern)
236
232
  const alternatives = braceAlternatives(pattern)
237
233
  if (alternatives.length > 1) {
238
234
  const any = anyTaskGlob(alternatives)
@@ -288,12 +284,12 @@ const REGEXP_SYNTAX = /[\\^$.*+?()[\]{}|]/g
288
284
  * The RegExp source of a task glob, matching what `Bun.Glob` matches
289
285
  * (`tests/util-paths.test.ts` holds the two to the same verdict on every
290
286
  * shape): `*` any run within a name, `?` one character, a `**` segment any
291
- * number of directories, a bracket itself. Null for the syntax left to
292
- * `Bun.Glob`: a brace, an escape, a leading `!`, an empty, `.` or `..`
287
+ * number of directories. Null for the syntax left to `Bun.Glob`: a brace,
288
+ * a character class, an escape, a leading `!`, an empty, `.` or `..`
293
289
  * segment, a `**` inside a name, or two `**` segments in a row.
294
290
  */
295
291
  function regExpSource(pattern: string): string | null {
296
- if (/[\\{}]/.test(pattern) || pattern.startsWith('!')) return null
292
+ if (/[\\{}[\]]/.test(pattern) || pattern.startsWith('!')) return null
297
293
  const segments = pattern.split('/')
298
294
  let out = ''
299
295
  for (let i = 0; i < segments.length; i++) {
@@ -342,7 +338,7 @@ export function asTrees(patterns: readonly string[]): string[] {
342
338
  const out: string[] = []
343
339
  for (const raw of patterns) {
344
340
  const p = normalizeGlob(raw)
345
- if (!isLiteralPattern(p)) {
341
+ if (!namesOnePath(p)) {
346
342
  out.push(p)
347
343
  continue
348
344
  }
@@ -353,6 +349,19 @@ export function asTrees(patterns: readonly string[]): string[] {
353
349
  return out
354
350
  }
355
351
 
352
+ /**
353
+ * The one path a task glob names, an escaped one included (`app/\[id\]`
354
+ * names `app/[id]`), or null for a pattern.
355
+ */
356
+ export function literalPath(glob: string): string | null {
357
+ if (isLiteralPattern(glob)) return glob
358
+ return isLiteralPattern(glob.replace(/\\./g, '_')) ? glob.replace(/\\(.)/g, '$1') : null
359
+ }
360
+
361
+ function namesOnePath(glob: string): boolean {
362
+ return literalPath(glob) !== null
363
+ }
364
+
356
365
  /**
357
366
  * An output list's positive globs, and what its `!` entries take back
358
367
  * (A-44). A reader that only needs where outputs MIGHT land (an overlap
package/src/util/size.ts CHANGED
@@ -4,13 +4,13 @@ import { parseDecimalInt } from './num.js'
4
4
 
5
5
  /**
6
6
  * Parse a byte-size string: a bare integer (`"1048576"`) or an integer
7
- * with a K/M/G/T suffix, powers of 1024, optional trailing `B`
8
- * (`"512MB"`, `"8G"`). Returns `null` for anything else — including
7
+ * with a K/M/G/T suffix, powers of 1024, optional trailing `B` or `iB`
8
+ * (`"512MB"`, `"8G"`, `"32KiB"`). Returns `null` for anything else — including
9
9
  * fractional sizes (`"1.5GB"`); callers wanting fractions must express
10
10
  * them in a smaller unit.
11
11
  */
12
12
  export function parseSize(input: string): number | null {
13
- const m = input.match(/^(\d+)([KMGT])?B?$/i)
13
+ const m = input.match(/^(\d+)(?:([KMGT])(?:i?B)?|B)?$/i)
14
14
  if (!m) return null
15
15
  // Digits past 2^53 parse to a different number than the user typed.
16
16
  const n = parseDecimalInt(m[1]!)
@@ -49,8 +49,7 @@ export function parseDuration(input: string): number | null {
49
49
 
50
50
  /**
51
51
  * Human-readable byte size with KB/MB/GB/TB/PB suffixes and one
52
- * decimal of precision below 10 of any unit. Used by `vx info`, `vx cache clean` and the
53
- * run's `cacheRetention` line.
52
+ * decimal of precision below 10 of any unit. Used by `vx info` and `vx cache clean`.
54
53
  */
55
54
  export function formatBytes(n: number): string {
56
55
  if (n < 1024) return `${n} B`
@@ -14,6 +14,7 @@ import {
14
14
  gitSpawnRefusal,
15
15
  isExecutableMissing,
16
16
  isInstalledPath,
17
+ isUserError,
17
18
  notAWorkTree,
18
19
  relPosix,
19
20
  UserError,
@@ -73,7 +74,14 @@ export interface AffectedArgs {
73
74
  workspaceRoot: string
74
75
  /** Git ref / commit / branch to compare against. Required. */
75
76
  since: string
76
- projects: readonly ProjectMeta[]
77
+ /** A promise when the caller is still discovering them: the git calls start first. */
78
+ projects: readonly ProjectMeta[] | Promise<readonly ProjectMeta[]>
79
+ /**
80
+ * False skips the scan of what each `vx.config` imports (every config
81
+ * read): the cache scope's split, where a wrong answer costs a miss,
82
+ * never a stale hit, and the scan was most of a 1,000-project diff.
83
+ */
84
+ configImports?: false
77
85
  /**
78
86
  * Which projects declare a `inputs.workspaceFiles` glob matching one of
79
87
  * these workspace-relative paths.
@@ -255,6 +263,8 @@ export async function affectedChanges(
255
263
  gitPaths(args.workspaceRoot, ['ls-files', '--others', '--exclude-standard', '-z']))(),
256
264
  ])
257
265
 
266
+ const projects = await args.projects
267
+
258
268
  // .vx/projects.lock.json (workspace-root metadata) is excluded like a gitignored
259
269
  // file: re-running `vx lock` must not mark every project affected.
260
270
  // An untracked file under `node_modules` is an install, which the input
@@ -291,7 +301,7 @@ export async function affectedChanges(
291
301
  //
292
302
  // Selection is not hashed, so widening it here changes no cache key.
293
303
  const everything = (file: string): AffectedChanges => {
294
- const all = new Set(args.projects.map((p) => p.name))
304
+ const all = new Set(projects.map((p) => p.name))
295
305
  const wholeBy = new Map([...all].map((n) => [n, file]))
296
306
  return { projects: all, changed, paths: new Map(), whole: all, wholeBy }
297
307
  }
@@ -320,7 +330,7 @@ export async function affectedChanges(
320
330
  const rootDir = path.resolve(args.workspaceRoot)
321
331
  const loose = changed.filter((rel) => {
322
332
  const abs = path.join(rootDir, rel)
323
- return !args.projects.some((p) => p.dir !== rootDir && abs.startsWith(p.dir + path.sep))
333
+ return !projects.some((p) => p.dir !== rootDir && abs.startsWith(p.dir + path.sep))
324
334
  })
325
335
  const extraClaims = loose.length > 0 ? await loadClaims() : undefined
326
336
  for (const file of extraClaims?.files ?? []) {
@@ -383,7 +393,7 @@ export async function affectedChanges(
383
393
 
384
394
  const realDirs = new Map(
385
395
  await Promise.all(
386
- args.projects.map(async (p) => [p.dir, await realpath(p.dir).catch(() => p.dir)] as const),
396
+ projects.map(async (p) => [p.dir, await realpath(p.dir).catch(() => p.dir)] as const),
387
397
  ),
388
398
  )
389
399
  const paths = new Map<string, string[]>()
@@ -392,7 +402,7 @@ export async function affectedChanges(
392
402
  args.workspaceRoot,
393
403
  changed,
394
404
  nested,
395
- args.projects,
405
+ projects,
396
406
  realDirs,
397
407
  perTask
398
408
  ? {
@@ -412,7 +422,7 @@ export async function affectedChanges(
412
422
  const configs = changed.filter((rel) => CONFIG_NAMES.has(path.posix.basename(rel)))
413
423
  if (configs.length > 0) {
414
424
  const atBase = await gitBlobsAt(args.workspaceRoot, base, configs)
415
- for (const name of parentsOfNewNested(args.workspaceRoot, atBase, args.projects)) {
425
+ for (const name of parentsOfNewNested(args.workspaceRoot, atBase, projects)) {
416
426
  owned.add(name)
417
427
  whole.add(name)
418
428
  }
@@ -426,12 +436,14 @@ export async function affectedChanges(
426
436
  // (`../../src/index.ts`), whose target is owned.
427
437
  // Per task, a project that owns the changed file still needs to know its
428
438
  // config imported it: that re-keys every task, whatever their inputs.
429
- for (const name of await configImportOwners({
430
- workspaceRoot: args.workspaceRoot,
431
- projects: args.projects,
432
- changed,
433
- skip: perTask ? new Set() : owned,
434
- })) {
439
+ for (const name of args.configImports === false
440
+ ? []
441
+ : await configImportOwners({
442
+ workspaceRoot: args.workspaceRoot,
443
+ projects: projects,
444
+ changed,
445
+ skip: perTask ? new Set() : owned,
446
+ })) {
435
447
  owned.add(name)
436
448
  whole.add(name)
437
449
  }
@@ -679,43 +691,35 @@ async function gitDiffPaths(
679
691
  }
680
692
 
681
693
  /**
682
- * Resolve the default base for `--affected` with no explicit value.
683
- * Tries the remote's HEAD branch first (`origin/main`, `origin/master`,
684
- * etc.), then `origin/main`, `origin/master`, `main`, `master`, then
685
- * falls back to `HEAD~1`. A clone with no `origin/HEAD` and
686
- * no parent commit — a CI checkout at `fetch-depth: 1` — has no base at
687
- * all, and says so here rather than failing on a ref nobody typed.
694
+ * The base a bare `--affected` compares with: the merge base of HEAD and
695
+ * the base branch (`origin/<baseBranch>`, or the local branch in a clone
696
+ * with no remote; its tip when they share no history). On the base branch
697
+ * itself there is none (owner, 2026-10-17): what a push to main changed
698
+ * is the last good commit's to say (`--affected=<sha>`), and `HEAD~1`
699
+ * guessed it wrong after any multi-commit push.
688
700
  */
689
- export async function defaultAffectedBase(workspaceRoot: string): Promise<string> {
690
- const probe = spawnGitSync(
691
- ['symbolic-ref', '--short', '-q', 'refs/remotes/origin/HEAD'],
692
- workspaceRoot,
693
- 'ignore',
694
- )
695
- const out = new TextDecoder().decode(probe.stdout).trim()
696
- // An origin/HEAD naming a branch the remote deleted (a pruned fetch keeps
697
- // the symref) is no base either: it failed as `git ref "origin/master" did
698
- // not resolve`, a ref nobody typed, where an unset one falls back.
699
- if (probe.exitCode === 0 && out.length > 0 && revParse(workspaceRoot, out) !== undefined) {
700
- return out
701
- }
702
- // No origin/HEAD: `git remote add` + fetch, as actions/checkout does, sets
703
- // none, and `HEAD~1` saw only a feature branch's last commit where Turbo
704
- // and Nx compare with `main` (D-93). The usual trunk names, remote first;
705
- // one that IS HEAD (a push to main) falls through to `HEAD~1` as before.
706
- const trunk = trunkBase(workspaceRoot)
707
- if (trunk !== undefined) return trunk
708
- if (revParse(workspaceRoot, 'HEAD~1') === undefined) {
701
+ export async function defaultAffectedBase(
702
+ workspaceRoot: string,
703
+ baseBranch: string | undefined,
704
+ ): Promise<string> {
705
+ const found = await baseBranchRef(workspaceRoot, baseBranch)
706
+ if (found === undefined) {
709
707
  const noHistory = noHistoryRefusal(workspaceRoot)
710
708
  if (noHistory !== undefined) throw noHistory
711
709
  throw new UserError(
712
- '--affected has no base here: origin/HEAD is not set (or names a branch that is gone) and HEAD has no parent to compare ' +
713
- 'with — a shallow clone? Fetch history (actions/checkout: fetch-depth: 0) or name the ' +
714
- 'base: --affected=origin/main',
710
+ `--affected has no base here: ${baseBranch === undefined ? 'no origin/HEAD, main or master' : `no ${baseBranch} or origin/${baseBranch}`} ` +
711
+ 'to compare with — a shallow clone? Fetch history (actions/checkout: fetch-depth: 0), ' +
712
+ 'set `baseBranch`, or name the base: --affected=origin/main',
713
+ 'VX_E_AFFECTED_BASE',
714
+ )
715
+ }
716
+ if (found.onIt) {
717
+ throw new UserError(
718
+ `--affected has no default on the base branch (${found.branch}): name the last good commit, --affected=<sha>`,
715
719
  'VX_E_AFFECTED_BASE',
716
720
  )
717
721
  }
718
- return 'HEAD~1'
722
+ return found.ref
719
723
  }
720
724
 
721
725
  /**
@@ -737,30 +741,83 @@ function noHistoryRefusal(workspaceRoot: string): UserError | undefined {
737
741
  return undefined
738
742
  }
739
743
 
740
- const TRUNKS = ['origin/main', 'origin/master', 'main', 'master']
744
+ /**
745
+ * What a branch changed, for which of its tasks read its cache scope
746
+ * (owner, 2026-10-17): from `base` (a plugin's: a pull request's base
747
+ * commit), else from the merge base with the base branch. No base, or
748
+ * one git cannot resolve (a CI checkout at `fetch-depth: 1`), is no
749
+ * change: every task reads the default key, which costs hits, never
750
+ * correctness, since a key must still match.
751
+ */
752
+ export async function branchChanges(
753
+ workspaceRoot: string,
754
+ base: string | undefined,
755
+ baseBranch: string | undefined,
756
+ projects: Promise<readonly ProjectMeta[]>,
757
+ /** The run's own enumeration, so the diff walks nothing a second time. */
758
+ walked: Pick<AffectedArgs, 'untracked' | 'links'> = {},
759
+ ): Promise<AffectedChanges> {
760
+ const none: AffectedChanges = {
761
+ projects: new Set(),
762
+ changed: [],
763
+ paths: new Map(),
764
+ whole: new Set(),
765
+ }
766
+ const since = base ?? (await baseBranchRef(workspaceRoot, baseBranch))?.ref
767
+ if (since === undefined) return none
768
+ try {
769
+ return await affectedChanges({
770
+ workspaceRoot,
771
+ since,
772
+ projects,
773
+ configImports: false,
774
+ ...walked,
775
+ })
776
+ } catch (err) {
777
+ if (!isUserError(err)) throw err
778
+ return none
779
+ }
780
+ }
741
781
 
742
- /** The first of `TRUNKS` that exists and is not HEAD's commit, in one spawn. */
743
- function trunkBase(workspaceRoot: string): string | undefined {
744
- const proc = spawnGitSync(
745
- [
782
+ /**
783
+ * The base branch as a ref: `origin/<name>`, else the local branch (a
784
+ * clone with no remote), and whether HEAD is on it. The name is the
785
+ * configured one, else `origin/HEAD`'s, else `main`, else `master`. Its
786
+ * spawns run at once, off the event loop.
787
+ */
788
+ async function baseBranchRef(
789
+ workspaceRoot: string,
790
+ configured: string | undefined,
791
+ ): Promise<{ ref: string; branch: string; onIt: boolean } | undefined> {
792
+ const text = async (args: string[]): Promise<string> => {
793
+ const proc = spawnGit(args, workspaceRoot)
794
+ const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
795
+ return exit === 0 ? out.trim() : ''
796
+ }
797
+ const names = configured === undefined ? ['main', 'master'] : [configured]
798
+ const [remoteHead, refs, head] = await Promise.all([
799
+ configured === undefined
800
+ ? text(['symbolic-ref', '--short', '-q', 'refs/remotes/origin/HEAD'])
801
+ : Promise.resolve(''),
802
+ text([
746
803
  'for-each-ref',
747
- '--format=%(refname:short) %(objectname)',
804
+ '--format=%(refname:short)',
748
805
  '--end-of-options',
749
- ...TRUNKS.map((t) => (t.startsWith('origin/') ? `refs/remotes/${t}` : `refs/heads/${t}`)),
750
- ],
751
- workspaceRoot,
752
- 'ignore',
753
- )
754
- if (proc.exitCode !== 0) return undefined
755
- const at = new Map(
756
- new TextDecoder()
757
- .decode(proc.stdout)
758
- .split('\n')
759
- .filter((l) => l !== '')
760
- .map((l) => l.split(' ') as [string, string]),
761
- )
762
- const head = revParse(workspaceRoot, 'HEAD')
763
- return TRUNKS.find((t) => at.has(t) && at.get(t) !== head)
806
+ ...(configured === undefined
807
+ ? ['refs/remotes/origin']
808
+ : [`refs/remotes/origin/${configured}`]),
809
+ ...names.map((n) => `refs/heads/${n}`),
810
+ ]),
811
+ text(['symbolic-ref', '--short', '-q', 'HEAD']),
812
+ ])
813
+ const have = new Set(refs.split('\n'))
814
+ const at = (branch: string, ref: string) => ({ ref, branch, onIt: head === branch })
815
+ if (remoteHead.startsWith('origin/') && have.has(remoteHead)) {
816
+ return at(remoteHead.slice('origin/'.length), remoteHead)
817
+ }
818
+ for (const n of names) if (have.has(`origin/${n}`)) return at(n, `origin/${n}`)
819
+ for (const n of names) if (have.has(n)) return at(n, n)
820
+ return undefined
764
821
  }
765
822
 
766
823
  /**