@vzn/vx 0.0.642 → 0.0.643

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.
@@ -25,6 +25,8 @@ import type { WorkspaceConfig } from '../config.js'
25
25
  import { lazyGitEnumeration, type LazyGitEnumeration } from '../cache/index.js'
26
26
  import { relPosix, UserError } from '../util/index.js'
27
27
  import {
28
+ cacheScopeError,
29
+ isCacheScope,
28
30
  loadWorkspaceConfig,
29
31
  discoveredProject,
30
32
  linkDiscoveredEdges,
@@ -33,7 +35,7 @@ import {
33
35
  } from '../workspace/index.js'
34
36
  import { parseDependencySpec } from '../graph/index.js'
35
37
  import { applyConfigHooks, applyDiscoverHooks, applyProjectHooks, hasHook } from './plugin-host.js'
36
- import type { VxPlugin } from './plugin.js'
38
+ import type { BranchFacts, VxPlugin } from './plugin.js'
37
39
 
38
40
  /**
39
41
  * The workspace config with the plugin `config` stage applied — the first
@@ -43,10 +45,16 @@ import type { VxPlugin } from './plugin.js'
43
45
  export async function loadWorkspacePlugins(
44
46
  workspaceRoot: string,
45
47
  warn: (message: string) => void,
46
- ): Promise<{ workspaceConfig: WorkspaceConfig | null; plugins: readonly VxPlugin[] }> {
48
+ ): Promise<{
49
+ workspaceConfig: WorkspaceConfig | null
50
+ plugins: readonly VxPlugin[]
51
+ branch: BranchFacts
52
+ }> {
47
53
  const loaded = await loadWorkspaceConfig(workspaceRoot)
48
54
  const plugins = (loaded?.plugins ?? []) as readonly VxPlugin[]
49
- if (loaded === null || !hasHook(plugins, 'config')) return { workspaceConfig: loaded, plugins }
55
+ const branch: BranchFacts = {}
56
+ if (loaded === null || !hasHook(plugins, 'config'))
57
+ return { workspaceConfig: loaded, plugins, branch }
50
58
  // Bun keeps one module per specifier, so the file's export is one object
51
59
  // per process: the CLI's selection pass, the run and each `vx watch`
52
60
  // cycle handed the hooks what the last load's hooks had edited
@@ -60,10 +68,16 @@ export async function loadWorkspacePlugins(
60
68
  // Re-validated after each plugin, as the `project` stage is: an edit
61
69
  // the loader would refuse from a user (`concurrency: -3`) hung the run,
62
70
  // and `cacheDir: 42` reached `path.resolve` as a TypeError.
63
- await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn }, (plugin) =>
71
+ const setBranch = (facts: BranchFacts): void => {
72
+ if (facts.scope !== undefined && !isCacheScope(facts.scope))
73
+ throw new UserError(`setBranch: ${cacheScopeError('scope')}`)
74
+ if (facts.scope !== undefined) branch.scope = facts.scope
75
+ if (facts.base !== undefined) branch.base = facts.base
76
+ }
77
+ await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn, setBranch }, (plugin) =>
64
78
  validateWorkspace(workspaceConfig, `.vx/workspace (after plugin '${plugin.name}')`),
65
79
  )
66
- return { workspaceConfig, plugins }
80
+ return { workspaceConfig, plugins, branch }
67
81
  }
68
82
 
69
83
  /**
@@ -15,6 +15,7 @@ import os from 'node:os'
15
15
  import path from 'node:path'
16
16
  import { executablePath, gitEnv, relPosix, ulid, xxh3hex } from '../util/index.js'
17
17
  import { resolveSetting } from '../settings.js'
18
+ import { branchCacheScope, cacheScopeEnvError, isCacheScope } from '../workspace/index.js'
18
19
  import { VERSION } from '../version.js'
19
20
  import type { RunContextRecord } from './telemetry.js'
20
21
 
@@ -525,6 +526,85 @@ export function currentBranch(
525
526
  return set ?? captureGitContext(workspaceRoot, null, env).branch
526
527
  }
527
528
 
529
+ /**
530
+ * The cache scope a checkout on a branch reads, from the `.git` files
531
+ * alone (owner, 2026-10-17): `<owner>/<repo>:<branch>`, the branch it
532
+ * tracks (`branch.<name>.merge` on `branch.<name>.remote`), else its own
533
+ * name on `origin`, the repository named by that remote's URL. None on
534
+ * the default branch, on a detached HEAD, or with no remote URL. CI writes
535
+ * that scope for the same branch (`github()`), so a laptop reads what its
536
+ * branch's CI saved.
537
+ */
538
+ export function localBranchScope(
539
+ workspaceRoot: string,
540
+ baseBranch: string | undefined,
541
+ env: NodeJS.ProcessEnv = process.env,
542
+ ): string | undefined {
543
+ const head = readHeadDirect(workspaceRoot)
544
+ if (head === null || head.branch === null) return undefined
545
+ const config = gitConfigSections(workspaceRoot)
546
+ const own = config.get(`branch "${head.branch}"`)
547
+ const name = own?.get('merge')?.replace(/^refs\/heads\//, '') ?? head.branch
548
+ const url = config.get(`remote "${own?.get('remote') ?? 'origin'}"`)?.get('url')
549
+ const repo = url === undefined ? undefined : repoOfUrl(url)
550
+ if (repo === undefined) return undefined
551
+ const trunk = baseBranch ?? captureDefaultBranch(env, workspaceRoot)
552
+ if (trunk === null ? name === 'main' || name === 'master' : name === trunk) return undefined
553
+ return branchCacheScope(repo, name)
554
+ }
555
+
556
+ /** `owner/repo` from a remote URL (its last two path segments); undefined for a local path. */
557
+ function repoOfUrl(url: string): string | undefined {
558
+ const parts = normalizeRemoteUrl(url).split('/')
559
+ return parts.length < 3 ? undefined : parts.slice(-2).join('/')
560
+ }
561
+
562
+ /** `.git/config`'s sections, each a key → value map (last wins); empty when it cannot be read. */
563
+ function gitConfigSections(workspaceRoot: string): Map<string, Map<string, string>> {
564
+ const sections = new Map<string, Map<string, string>>()
565
+ try {
566
+ const dirs = refDirsOf(workspaceRoot)
567
+ if (dirs === null) return sections
568
+ let at: Map<string, string> | undefined
569
+ for (const raw of fs.readFileSync(path.join(dirs.commonDir, 'config'), 'utf8').split('\n')) {
570
+ const line = raw.trim()
571
+ const section = /^\[\s*(\w+)(?:\s+"(.*)")?\s*\]$/.exec(line)
572
+ if (section !== null) {
573
+ const key = section[2] === undefined ? section[1]! : `${section[1]} "${section[2]}"`
574
+ at = sections.get(key) ?? new Map()
575
+ sections.set(key, at)
576
+ continue
577
+ }
578
+ const kv = /^(\w+)\s*=\s*(.*)$/.exec(line)
579
+ if (kv !== null) at?.set(kv[1]!.toLowerCase(), kv[2]!.trim())
580
+ }
581
+ } catch {
582
+ // no config to read: no remote, so no scope.
583
+ }
584
+ return sections
585
+ }
586
+
587
+ /**
588
+ * The run's cache scope (owner, 2026-10-17): `VX_CACHE_SCOPE` (empty: none),
589
+ * else a plugin's (`setBranch`, `github()` on Actions), else, with a cache
590
+ * plugin to share it, the branch's from git. Undefined: the default keys.
591
+ */
592
+ export function resolveCacheScope(
593
+ workspaceRoot: string,
594
+ pluginScope: string | undefined,
595
+ hasRemote: boolean,
596
+ baseBranch: string | undefined,
597
+ env: NodeJS.ProcessEnv = process.env,
598
+ ): string | undefined {
599
+ const named = env['VX_CACHE_SCOPE']
600
+ if (named !== undefined) {
601
+ if (named === '') return undefined
602
+ if (!isCacheScope(named)) throw cacheScopeEnvError()
603
+ return named
604
+ }
605
+ return pluginScope ?? (hasRemote ? localBranchScope(workspaceRoot, baseBranch, env) : undefined)
606
+ }
607
+
528
608
  export interface TelemetryRunContextArgs {
529
609
  runId: string
530
610
  /** The invocation, already masked. */
@@ -799,6 +799,7 @@ async function runOnBus(
799
799
  version: VERSION,
800
800
  packageCount: packagesInScope.size,
801
801
  remoteCacheEnabled,
802
+ cacheScope: prepared.cacheScope,
802
803
  cache: {
803
804
  local: policy.localRead || policy.localWrite,
804
805
  localWrite: policy.localWrite,
@@ -97,6 +97,12 @@ export interface ComputeHashArgs {
97
97
  */
98
98
  export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
99
99
  if (isKeyless(args.node.config)) return keylessHash(args.node.id)
100
+ const key = await taskHash(args)
101
+ args.cache.noteKey?.(args.node.id, key)
102
+ return key
103
+ }
104
+
105
+ async function taskHash(args: ComputeHashArgs): Promise<string> {
100
106
  const end = span('task hash')
101
107
  try {
102
108
  const folds = args.captureInto === undefined ? args.hashCache?.folds : undefined
@@ -188,6 +188,8 @@ export interface RunContext {
188
188
  /** Worker-pool size for this run; shown on the `info` row. */
189
189
  concurrency?: number
190
190
  remoteCacheEnabled: boolean
191
+ /** The run's cache scope, named after the cache mode: `scope vznjs/vx:feat-x`. */
192
+ cacheScope?: string | undefined
191
193
  /**
192
194
  * The cache layers the run used, so the legend reads `local 1= 2↓ 4↑ ·
193
195
  * remote 3↓ 4↑`. Absent: the plain `N miss` legend.
@@ -407,6 +409,7 @@ export function formatSummarySection(
407
409
  if (context.concurrency !== undefined)
408
410
  info.push(`${context.concurrency} worker${context.concurrency === 1 ? '' : 's'}`)
409
411
  info.push(context.remoteCacheEnabled ? 'local + remote cache' : 'local cache')
412
+ if (context.cacheScope !== undefined) info.push(`scope ${context.cacheScope}`)
410
413
  // The plugin's hand on the run: a task a policy held with a worker
411
414
  // free would otherwise have started; without the line nothing says
412
415
  // the policy acted. The duration is the waits summed, task-seconds
package/src/settings.ts CHANGED
@@ -43,6 +43,12 @@ export interface Setting {
43
43
  const SCHEDULES = ['most-work', 'critical-path', 'direct-dependents', 'ready-order'] as const
44
44
 
45
45
  export const SETTINGS: readonly Setting[] = [
46
+ {
47
+ path: 'baseBranch',
48
+ kind: { type: 'string' },
49
+ default: "origin/HEAD's branch, then main, then master",
50
+ 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>`.',
51
+ },
46
52
  {
47
53
  path: 'run.concurrency',
48
54
  kind: { type: 'concurrency' },
@@ -57,12 +63,6 @@ export const SETTINGS: readonly Setting[] = [
57
63
  doc: 'Which ready task starts first when there are more than workers.',
58
64
  flag: { verb: 'run', name: '--schedule' },
59
65
  },
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
66
  {
67
67
  path: 'run.branch',
68
68
  kind: { type: 'string' },
@@ -137,9 +137,9 @@ export const SETTINGS: readonly Setting[] = [
137
137
  {
138
138
  path: 'cache.scope',
139
139
  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' },
140
+ default: "the branch's, `<owner>/<repo>:<branch>`, with a remote cache off `baseBranch`",
141
+ 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.",
142
+ config: false,
143
143
  },
144
144
  {
145
145
  path: 'cache.download',
@@ -220,7 +220,7 @@ export const SETTINGS: readonly Setting[] = [
220
220
  },
221
221
  ]
222
222
 
223
- /** The `.vx/workspace` groups that hold settings; `plugins` is the other key. */
223
+ /** The `.vx/workspace` groups that hold settings; `baseBranch` and `plugins` are the other keys. */
224
224
  export const SETTING_GROUPS = ['run', 'cache', 'lock', 'watch'] as const
225
225
 
226
226
  const BY_PATH = new Map(SETTINGS.map((s) => [s.path, s]))
@@ -229,7 +229,7 @@ export function setting(path: string): Setting {
229
229
  return BY_PATH.get(path)!
230
230
  }
231
231
 
232
- /** `run.affectedBase` → `VX_RUN_AFFECTED_BASE`. */
232
+ /** `run.branch` → `VX_RUN_BRANCH`. */
233
233
  export function envName(path: string): string {
234
234
  return `VX_${path
235
235
  .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
@@ -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
  /**
@@ -44,14 +44,39 @@ import { nonJsonMessage, nonJsonPaths } from './json-data.js'
44
44
  // declared no plugins and ran the workspace bare, `cacheDirectory` left the
45
45
  // cache where it was — a config that loads and quietly does nothing it says.
46
46
  const CACHE_SCOPE_RULE =
47
- "must be a scope name of letters, digits and . _ - / @ (at most 128), like 'pr-123'"
47
+ "must be a scope name of letters, digits and . _ - / @ : (at most 128), like 'vznjs/vx:feat-x'"
48
48
 
49
- /** `cache.scope`'s rule, shared with `VX_CACHE_SCOPE`. */
49
+ /** A cache scope's rule, for `VX_CACHE_SCOPE` and a plugin's `setBranch`. */
50
50
  export function isCacheScope(v: unknown): v is string {
51
- return typeof v === 'string' && /^[\w.@/-]{1,128}$/.test(v)
51
+ return typeof v === 'string' && /^[\w.@/:-]{1,128}$/.test(v)
52
52
  }
53
53
 
54
- /** Why a `VX_CACHE_SCOPE` or `--cache-scope` value is refused, in `cache.scope`'s words. */
54
+ /**
55
+ * A branch's cache scope, `<owner>/<repo>:<branch>`: what CI writes for a
56
+ * branch and a laptop on it reads (owner, 2026-10-17). The repository's
57
+ * identity, not a remote's local name, so a clone whose `origin` is a fork
58
+ * names the fork. A name may hold characters a scope may not; each
59
+ * becomes `_`.
60
+ */
61
+ const BASE_BRANCH_RULE = "must be a branch name like 'main'"
62
+
63
+ /**
64
+ * A branch name git takes after `origin/`: a leading '-' would reach git
65
+ * as an option, and whitespace, `..`, `~^:?*[\\` are no ref's.
66
+ */
67
+ function isBaseBranch(value: unknown): value is string {
68
+ return (
69
+ typeof value === 'string' &&
70
+ /^[^-\s\0~^:?*[\\][^\s\0~^:?*[\\]*$/.test(value) &&
71
+ !value.includes('..')
72
+ )
73
+ }
74
+
75
+ export function branchCacheScope(repo: string, branch: string): string {
76
+ return `${repo.toLowerCase()}:${branch}`.replace(/[^\w.@/:-]/g, '_').slice(0, 128)
77
+ }
78
+
79
+ /** Why a `VX_CACHE_SCOPE` value is refused. */
55
80
  export function cacheScopeError(source = 'VX_CACHE_SCOPE'): string {
56
81
  return `${source} ${CACHE_SCOPE_RULE}`
57
82
  }
@@ -61,6 +86,15 @@ export function cacheScopeEnvError(): UserError {
61
86
  return new UserError(cacheScopeError())
62
87
  }
63
88
 
89
+ /** `baseBranch`: `VX_BASE_BRANCH`, else the workspace's; undefined lets git name it. */
90
+ export function configuredBaseBranch(workspace: WorkspaceConfig | null): string | undefined {
91
+ const env = settingEnv('baseBranch')
92
+ if (env !== undefined && !isBaseBranch(env)) {
93
+ throw new UserError(`VX_BASE_BRANCH ${BASE_BRANCH_RULE}`, 'VX_E_USAGE')
94
+ }
95
+ return (env as string | undefined) ?? workspace?.baseBranch
96
+ }
97
+
64
98
  const STRATEGIES = ['immutable', 'mutable', 'unchecked'] as const
65
99
  const SEVERITIES = ['warn', 'error'] as const
66
100
  const INPUT_STABILITY_FIELDS = new Set(['strategy', 'severity'])
@@ -98,7 +132,7 @@ export function inputStabilityEnv(): InputStability {
98
132
  }
99
133
  }
100
134
 
101
- const WORKSPACE_FIELDS = new Set<string>([...SETTING_GROUPS, 'plugins'])
135
+ const WORKSPACE_FIELDS = new Set<string>([...SETTING_GROUPS, 'baseBranch', 'plugins'])
102
136
 
103
137
  /**
104
138
  * `.vx/workspace`'s settings, as `src/settings.ts` lists them: each group's
@@ -126,7 +160,7 @@ function validateSettingsAt(value: unknown, at: string, configPath: string): voi
126
160
  if (at === 'cache')
127
161
  // Turbo's and Nx's workspace `cache: true` is a task's own `cache` in vx (D-98).
128
162
  throw new UserError(
129
- `${where} must be an object like { dir, policy, scope }; a task caches through its own \`cache\` in its project's vx.config`,
163
+ `${where} must be an object like { dir, policy }; a task caches through its own \`cache\` in its project's vx.config`,
130
164
  )
131
165
  throw new UserError(`${where} must be an object (fields: ${[...fields].join(', ')})`)
132
166
  }
@@ -197,18 +231,12 @@ function validateSettingsExtra(config: WorkspaceConfig, configPath: string): voi
197
231
  // unwritable workspace.
198
232
  throw new UserError(`${configPath}: \`cache.dir\` holds a NUL, which no path can carry`)
199
233
  }
200
- const base = config.run?.affectedBase
201
- // A leading '-' would reach git as an option, not a ref; a NUL fails the
202
- // spawn with an argument error naming nothing in this file.
203
- if (base !== undefined && !/^[^-\s\0][^\s\0]*$/.test(base)) {
204
- throw new UserError(`${configPath}: \`run.affectedBase\` must be a git ref like 'origin/main'`)
234
+ const base = config.baseBranch
235
+ if (base !== undefined && !isBaseBranch(base)) {
236
+ throw new UserError(`${configPath}: \`baseBranch\` ${BASE_BRANCH_RULE}`)
205
237
  }
206
238
  // `policy`'s grammar is the cache module's (`parseCachePolicy`), which
207
239
  // this module may not import: the run parses it before any task (prepare.ts).
208
- const scope = config.cache?.scope
209
- if (scope !== undefined && !isCacheScope(scope)) {
210
- throw new UserError(`${configPath}: \`cache.scope\` ${CACHE_SCOPE_RULE}`)
211
- }
212
240
  if (config.cache?.retention !== undefined) validateRetention(config.cache.retention, configPath)
213
241
  }
214
242
 
@@ -1103,7 +1131,7 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1103
1131
  'a cache plugin in `plugins` (`turboCache()` or `nxCache()` from `@vzn/vx-migrate`)',
1104
1132
  parallel: '`run.concurrency`',
1105
1133
  cacheDirectory: '`cache.dir`',
1106
- defaultBase: '`run.affectedBase`',
1134
+ defaultBase: '`baseBranch`',
1107
1135
  tasksRunnerOptions: 'a cache plugin in `plugins` (`nxCache()` from `@vzn/vx-migrate`)',
1108
1136
  globalPassThroughEnv: '`exec.env.passThrough` on the tasks it passes to',
1109
1137
  // pnpm-workspace.yaml's, Lerna's and the package.json's own keys
@@ -6,6 +6,7 @@ export {
6
6
  type AffectedChanges,
7
7
  affectedProjects,
8
8
  defaultAffectedBase,
9
+ branchChanges,
9
10
  type FingerprintClaims,
10
11
  refIsHead,
11
12
  } from './affected.js'
@@ -35,8 +36,10 @@ export {
35
36
  WORKSPACE_CONFIG_FILENAMES,
36
37
  } from './project-loader.js'
37
38
  export {
39
+ branchCacheScope,
38
40
  cacheScopeEnvError,
39
41
  cacheScopeError,
42
+ configuredBaseBranch,
40
43
  inputStabilityEnv,
41
44
  isCacheScope,
42
45
  taskNameProblem,