@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.
- package/package.json +5 -5
- package/schemas/info.json +0 -11
- package/skills/vx/SKILL.md +1 -1
- package/src/cache/archive.ts +153 -2
- package/src/cache/cache.ts +417 -183
- package/src/cache/file-hashes.ts +2 -14
- package/src/cache/fold-memo.ts +7 -3
- package/src/cache/index.ts +10 -2
- package/src/cache/key-fold.ts +4 -1
- package/src/cache/layer.ts +11 -4
- package/src/cache/layered-cache.ts +82 -8
- package/src/cache/plugin-cache.ts +51 -43
- package/src/cache/remote-lane.ts +18 -13
- package/src/cache/run-store.ts +4 -1
- package/src/cache/scoped-cache.ts +74 -239
- package/src/cache/tar-stream.ts +12 -7
- package/src/cli/format.ts +1 -2
- package/src/cli/help.ts +8 -4
- package/src/cli/info.ts +2 -19
- package/src/cli/plugin-commands.ts +11 -6
- package/src/cli/run.ts +7 -12
- package/src/cli/select.ts +7 -9
- package/src/cli/upgrade.ts +38 -8
- package/src/cli/workspace-config.ts +8 -2
- package/src/config.ts +22 -26
- package/src/exec/sandbox-paths.ts +2 -2
- package/src/graph/scheduler.ts +3 -1
- package/src/graph/task-graph.ts +15 -11
- package/src/index.ts +5 -1
- package/src/orchestrator/affected-tasks.ts +23 -2
- package/src/orchestrator/doctor.ts +0 -17
- package/src/orchestrator/execute-task.ts +1 -1
- package/src/orchestrator/index.ts +4 -0
- package/src/orchestrator/options.ts +15 -1
- package/src/orchestrator/plugin-host.ts +11 -4
- package/src/orchestrator/plugin.ts +23 -2
- package/src/orchestrator/prepare.ts +81 -18
- package/src/orchestrator/projects.ts +26 -5
- package/src/orchestrator/remote-prefetch.ts +11 -4
- package/src/orchestrator/run-context.ts +85 -0
- package/src/orchestrator/run-settings.ts +3 -0
- package/src/orchestrator/run.ts +9 -26
- package/src/orchestrator/task-hash.ts +6 -0
- package/src/report/logger.ts +0 -4
- package/src/report/summary.ts +3 -11
- package/src/settings.ts +54 -25
- package/src/util/index.ts +1 -0
- package/src/util/paths.ts +42 -33
- package/src/util/size.ts +4 -5
- package/src/workspace/affected.ts +120 -63
- package/src/workspace/config-schema.ts +44 -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
|
-
|
|
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: '
|
|
141
|
-
doc: "
|
|
142
|
-
|
|
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`
|
|
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.
|
|
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
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
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const dir =
|
|
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
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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,
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
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
|
-
|
|
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
|
|
292
|
-
*
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
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
|
/**
|