@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.
- package/package.json +5 -5
- package/src/cache/cache.ts +1 -7
- package/src/cache/index.ts +7 -2
- package/src/cache/layer.ts +2 -0
- package/src/cache/layered-cache.ts +28 -6
- package/src/cache/plugin-cache.ts +23 -16
- package/src/cache/remote-lane.ts +6 -6
- package/src/cache/run-store.ts +4 -1
- package/src/cache/scoped-cache.ts +74 -239
- package/src/cli/help.ts +2 -4
- package/src/cli/plugin-commands.ts +11 -6
- package/src/cli/run.ts +4 -12
- package/src/cli/select.ts +7 -9
- package/src/cli/workspace-config.ts +8 -2
- package/src/config.ts +7 -16
- package/src/index.ts +4 -0
- package/src/orchestrator/affected-tasks.ts +23 -2
- package/src/orchestrator/index.ts +4 -0
- package/src/orchestrator/options.ts +9 -1
- package/src/orchestrator/plugin-host.ts +7 -4
- package/src/orchestrator/plugin.ts +20 -2
- package/src/orchestrator/prepare.ts +67 -17
- package/src/orchestrator/projects.ts +19 -5
- package/src/orchestrator/run-context.ts +80 -0
- package/src/orchestrator/run.ts +1 -0
- package/src/orchestrator/task-hash.ts +6 -0
- package/src/report/summary.ts +3 -0
- package/src/settings.ts +11 -11
- package/src/workspace/affected.ts +120 -63
- package/src/workspace/config-schema.ts +44 -16
- package/src/workspace/index.ts +3 -0
|
@@ -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<{
|
|
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
|
-
|
|
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
|
-
|
|
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. */
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -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
|
package/src/report/summary.ts
CHANGED
|
@@ -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: '
|
|
141
|
-
doc: "
|
|
142
|
-
|
|
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`
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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 !
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
*
|
|
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(
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
713
|
-
'with — a shallow clone? Fetch history (actions/checkout: fetch-depth: 0)
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
743
|
-
|
|
744
|
-
|
|
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)
|
|
804
|
+
'--format=%(refname:short)',
|
|
748
805
|
'--end-of-options',
|
|
749
|
-
...
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
)
|
|
762
|
-
const
|
|
763
|
-
return
|
|
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 '
|
|
47
|
+
"must be a scope name of letters, digits and . _ - / @ : (at most 128), like 'vznjs/vx:feat-x'"
|
|
48
48
|
|
|
49
|
-
/**
|
|
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
|
|
51
|
+
return typeof v === 'string' && /^[\w.@/:-]{1,128}$/.test(v)
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/**
|
|
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
|
|
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.
|
|
201
|
-
|
|
202
|
-
|
|
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: '`
|
|
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
|
package/src/workspace/index.ts
CHANGED
|
@@ -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,
|