@vzn/vx 0.0.21 → 0.0.23
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/README.md +21 -18
- package/package.json +6 -6
- package/src/bin.ts +52 -3
- package/src/cache/archive.ts +170 -21
- package/src/cache/cache.ts +667 -288
- package/src/cache/chained-cache.ts +14 -2
- package/src/cache/config-evals.ts +29 -6
- package/src/cache/file-hashes.ts +63 -35
- package/src/cache/git-inputs.ts +398 -69
- package/src/cache/index.ts +8 -1
- package/src/cache/inputs.ts +403 -102
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +81 -11
- package/src/cache/layered-cache.ts +199 -32
- package/src/cache/output-index.ts +109 -10
- package/src/cache/run-history.ts +8 -3
- package/src/cache/zstd.ts +53 -30
- package/src/cli/cache.ts +51 -21
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +35 -9
- package/src/cli/info.ts +49 -7
- package/src/cli/init.ts +17 -5
- package/src/cli/last.ts +96 -21
- package/src/cli/lock.ts +34 -17
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/plugin-commands.ts +20 -5
- package/src/cli/run.ts +55 -12
- package/src/cli/select.ts +23 -13
- package/src/cli/show.ts +9 -4
- package/src/cli/upgrade.ts +134 -14
- package/src/cli/watch.ts +622 -102
- package/src/cli/why.ts +8 -16
- package/src/cli/workspace-config.ts +21 -9
- package/src/config.ts +46 -9
- package/src/exec/executor.ts +51 -5
- package/src/exec/index.ts +10 -0
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +327 -82
- package/src/exec/sandbox-binds.ts +34 -5
- package/src/exec/sandbox-paths.ts +22 -2
- package/src/exec/sandbox-runtime.ts +627 -102
- package/src/exec/sandbox-violations.ts +36 -23
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +12 -2
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +182 -134
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +16 -2
- package/src/orchestrator/admission.ts +41 -17
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +22 -7
- package/src/orchestrator/doctor.ts +122 -14
- package/src/orchestrator/events.ts +71 -2
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +331 -37
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +81 -14
- package/src/orchestrator/history.ts +6 -7
- package/src/orchestrator/hit-restore.ts +82 -18
- package/src/orchestrator/index.ts +7 -1
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +152 -32
- package/src/orchestrator/logger.ts +116 -20
- package/src/orchestrator/metrics.ts +156 -60
- package/src/orchestrator/miss-save.ts +106 -43
- package/src/orchestrator/options.ts +30 -4
- package/src/orchestrator/persistent.ts +41 -21
- package/src/orchestrator/placement.ts +55 -25
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +17 -23
- package/src/orchestrator/plugin.ts +31 -8
- package/src/orchestrator/prepare.ts +99 -13
- package/src/orchestrator/projects.ts +19 -8
- package/src/orchestrator/remote-prefetch.ts +21 -1
- package/src/orchestrator/run-artifacts.ts +9 -0
- package/src/orchestrator/run-context.ts +19 -4
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +6 -23
- package/src/orchestrator/run-report.ts +4 -2
- package/src/orchestrator/run.ts +264 -57
- package/src/orchestrator/sandbox-request.ts +388 -62
- package/src/orchestrator/save-lane.ts +4 -7
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +86 -30
- package/src/orchestrator/stable-keys.ts +202 -10
- package/src/orchestrator/status-line.ts +33 -1
- package/src/orchestrator/summary.ts +49 -2
- package/src/orchestrator/task-hash.ts +87 -7
- package/src/orchestrator/task-log-buffer.ts +7 -7
- package/src/orchestrator/telemetry-host.ts +16 -7
- package/src/orchestrator/telemetry.ts +69 -28
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +32 -3
- package/src/util/paths.ts +147 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +4 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +272 -64
- package/src/workspace/config-cache.ts +262 -32
- package/src/workspace/config-eval.ts +49 -12
- package/src/workspace/config-imports.ts +126 -5
- package/src/workspace/config-schema.ts +237 -41
- package/src/workspace/filter.ts +33 -18
- package/src/workspace/fingerprint.ts +29 -11
- package/src/workspace/index.ts +7 -0
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +33 -11
- package/src/workspace/migrate-scripts.ts +60 -7
- package/src/workspace/migration.ts +40 -1
- package/src/workspace/package-graph.ts +154 -16
- package/src/workspace/project-loader.ts +227 -77
- package/src/workspace/workspace.ts +173 -23
package/src/util/size.ts
CHANGED
|
@@ -18,3 +18,36 @@ export function parseSize(input: string): number | null {
|
|
|
18
18
|
u === '' ? 1 : u === 'K' ? 1024 : u === 'M' ? 1024 * 1024 : u === 'G' ? 1024 ** 3 : 1024 ** 4
|
|
19
19
|
return n * mult
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse `<n><unit>` where unit is s/m/h/d, case-insensitively (`parseSize`
|
|
24
|
+
* has always been case-insensitive; this matched only lowercase, so `30D`
|
|
25
|
+
* was rejected while `1GB` and `1gb` both worked).
|
|
26
|
+
*/
|
|
27
|
+
export function parseDuration(input: string): number | null {
|
|
28
|
+
const m = input.match(/^(\d+)([smhd])$/i)
|
|
29
|
+
if (!m) return null
|
|
30
|
+
const n = parseDecimalInt(m[1]!)
|
|
31
|
+
if (n === null) return null
|
|
32
|
+
// Lowercase before the switch: with the /i flag an uppercase `M` would
|
|
33
|
+
// otherwise fall through to the days branch.
|
|
34
|
+
const unit = m[2]!.toLowerCase()
|
|
35
|
+
const mult = unit === 's' ? 1000 : unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000
|
|
36
|
+
return n * mult
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Human-readable byte size with KB/MB/GB/TB/PB suffixes and one
|
|
41
|
+
* decimal of precision below 10 of any unit. Used by `vx info`, `vx cache prune` and the
|
|
42
|
+
* run's `cacheRetention` line.
|
|
43
|
+
*/
|
|
44
|
+
export function formatBytes(n: number): string {
|
|
45
|
+
if (n < 1024) return `${n} B`
|
|
46
|
+
const units = ['KB', 'MB', 'GB', 'TB']
|
|
47
|
+
let v = n / 1024
|
|
48
|
+
for (const u of units) {
|
|
49
|
+
if (v < 1024) return `${v.toFixed(v < 10 ? 1 : 0)} ${u}`
|
|
50
|
+
v /= 1024
|
|
51
|
+
}
|
|
52
|
+
return `${v.toFixed(0)} PB`
|
|
53
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inverse of `taskId` (`graph/task-graph.ts`). Splits on the FIRST
|
|
3
|
+
* `#`, so a task name that itself contains one round-trips: `taskId('a', 'b#c')` → `'a#b#c'` → `['a', 'b#c']`.
|
|
4
|
+
*
|
|
5
|
+
* This exists because it kept being written by hand as `id.split('#', 2)`,
|
|
6
|
+
* which is NOT the inverse — it discards everything after the second segment,
|
|
7
|
+
* so `'a#b#c'` reads back as task `'b'`. Six call sites had that form while
|
|
8
|
+
* `parseDependencySpec` (the surface that decides what actually runs) has
|
|
9
|
+
* always split on the first `#`, so the query layer and the graph disagreed
|
|
10
|
+
* about the identity of the same task: a lookup either found nothing or, worse,
|
|
11
|
+
* answered with a different task's history.
|
|
12
|
+
*
|
|
13
|
+
* A `#`-containing task name is legal and pinned elsewhere in the suite, so
|
|
14
|
+
* this is reachable rather than theoretical. The cache's run history read
|
|
15
|
+
* the same rule from a private copy until item 646; one rule, one place.
|
|
16
|
+
*/
|
|
17
|
+
export function splitTaskId(id: string): [project: string, task: string] {
|
|
18
|
+
const i = id.indexOf('#')
|
|
19
|
+
return i < 0 ? [id, ''] : [id.slice(0, i), id.slice(i + 1)]
|
|
20
|
+
}
|
package/src/util/timing.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// The CPU profiler attributes a tight loop's cost unreliably and hides
|
|
4
4
|
// where an `await` waited; a stage table answers "where did the warm run
|
|
5
5
|
// go?" directly, and costs one boolean check per mark when off. Marks are
|
|
6
|
-
// cumulative from
|
|
6
|
+
// cumulative from this module's load — process start and the imports ahead
|
|
7
|
+
// of it sit outside the table; the table shows each stage's own share.
|
|
7
8
|
|
|
8
9
|
const enabled = process.env.VX_TIMING !== undefined && process.env.VX_TIMING !== ''
|
|
9
10
|
const t0 = Bun.nanoseconds()
|
|
@@ -58,6 +59,15 @@ export function printTimings(): void {
|
|
|
58
59
|
` ${label.padEnd(24)} ${(ns / 1e6).toFixed(1).padStart(8)}ms ${String(count).padStart(6)}`,
|
|
59
60
|
)
|
|
60
61
|
}
|
|
62
|
+
// The trap this table sets, said where it is sprung: a span's total is
|
|
63
|
+
// WALL summed per call, and the run's spans are taken under the
|
|
64
|
+
// scheduler's concurrency, so a span that overlaps other work reads
|
|
65
|
+
// far larger than the work it names. `output dirs` at 124 µs a task is
|
|
66
|
+
// a handful of `lstat`s (items 254 and 407). Compare spans to each
|
|
67
|
+
// other, and measure a suspect one in isolation before chasing it.
|
|
68
|
+
lines.push(
|
|
69
|
+
' (wall per call, summed; concurrent calls overlap — compare spans, not totals)',
|
|
70
|
+
)
|
|
61
71
|
}
|
|
62
72
|
process.stderr.write(lines.join('\n') + '\n')
|
|
63
73
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// A spawn of a bare name walks PATH with a stat per entry — `sh` for every
|
|
2
|
+
// task, `git` for every git call: 200 tasks paid ~2,000 stats on a 100-package
|
|
3
|
+
// workspace for the same answer each time. The walk is done here once and
|
|
4
|
+
// the spawn is handed the absolute path.
|
|
5
|
+
//
|
|
6
|
+
// The PATH walked is vx's OWN, never a task's. A task's PATH starts with its
|
|
7
|
+
// project's and the workspace's `node_modules/.bin`, and `exec.env.define`
|
|
8
|
+
// may set it outright; the shell that parses the command and the git that
|
|
9
|
+
// enumerates the inputs are the machine's tools, not the task's, so a
|
|
10
|
+
// dependency that ships a `sh` bin must not become the interpreter of every
|
|
11
|
+
// command in its project. The task's PATH still decides what the COMMAND
|
|
12
|
+
// resolves, inside that shell.
|
|
13
|
+
//
|
|
14
|
+
// Memoized per PATH value rather than for the process's life: nothing in vx
|
|
15
|
+
// changes PATH mid-run, so a run pays one walk, and a process whose PATH does
|
|
16
|
+
// change (an embedder, a test) is answered for the PATH it has. A miss is
|
|
17
|
+
// not remembered: it ends the spawn that asked, and a long-lived process
|
|
18
|
+
// (`vx watch`) finds the tool once the user installs it.
|
|
19
|
+
|
|
20
|
+
let memoPath: string | undefined
|
|
21
|
+
const found = new Map<string, string>()
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `name`'s absolute path on vx's own PATH. Throws Bun's own shape for a
|
|
25
|
+
* spawn that could not run (`code: 'ENOENT'`) when it is not there, so a
|
|
26
|
+
* call inside a spawn site's `try` reaches the same `isExecutableMissing`
|
|
27
|
+
* refusal the spawn's throw did.
|
|
28
|
+
*/
|
|
29
|
+
export function executablePath(name: string): string {
|
|
30
|
+
const PATH = process.env['PATH'] ?? ''
|
|
31
|
+
if (PATH !== memoPath) {
|
|
32
|
+
found.clear()
|
|
33
|
+
memoPath = PATH
|
|
34
|
+
}
|
|
35
|
+
const hit = found.get(name)
|
|
36
|
+
if (hit !== undefined) return hit
|
|
37
|
+
const at = Bun.which(name, { PATH })
|
|
38
|
+
if (at === null) {
|
|
39
|
+
throw Object.assign(new Error(`Executable not found in $PATH: "${name}"`), {
|
|
40
|
+
code: 'ENOENT',
|
|
41
|
+
path: name,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
// Absolute even for a relative PATH entry (Bun.which resolves it against
|
|
45
|
+
// this process's cwd), so it holds in whatever cwd the spawn is given.
|
|
46
|
+
found.set(name, at)
|
|
47
|
+
return at
|
|
48
|
+
}
|
|
@@ -6,12 +6,50 @@
|
|
|
6
6
|
// index + unstaged — so it captures everything you touched and nothing
|
|
7
7
|
// the base branch moved on with. Matches Turbo's `[<since>]` semantics.
|
|
8
8
|
|
|
9
|
+
import { realpathSync, statSync } from 'node:fs'
|
|
9
10
|
import path from 'node:path'
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
asTrees,
|
|
13
|
+
executablePath,
|
|
14
|
+
UserError,
|
|
15
|
+
gitSpawnRefusal,
|
|
16
|
+
isExecutableMissing,
|
|
17
|
+
taskGlob,
|
|
18
|
+
} from '../util/index.js'
|
|
11
19
|
import { LOCKFILE_NAME } from './lockfile.js'
|
|
12
20
|
import { configImportOwners } from './config-imports.js'
|
|
21
|
+
import { configImports } from './config-cache.js'
|
|
22
|
+
import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
|
|
13
23
|
import { WORKSPACE_FINGERPRINT_FILES } from './fingerprint.js'
|
|
14
|
-
import type { ProjectMeta } from './workspace.js'
|
|
24
|
+
import type { PackageJson, ProjectMeta } from './workspace.js'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Every git call here goes through these two: a git that is not on PATH
|
|
28
|
+
* is one refusal naming the install (util `gitSpawnRefusal`), never the
|
|
29
|
+
* `ENOENT` stack `defaultAffectedBase` showed a minimal image
|
|
30
|
+
* (2026-09-16). A git that ran and failed is each caller's to read.
|
|
31
|
+
*/
|
|
32
|
+
function spawnGitSync(
|
|
33
|
+
args: string[],
|
|
34
|
+
cwd: string,
|
|
35
|
+
stderr: 'pipe' | 'ignore' = 'pipe',
|
|
36
|
+
): ReturnType<typeof Bun.spawnSync> {
|
|
37
|
+
try {
|
|
38
|
+
return Bun.spawnSync({ cmd: [executablePath('git'), ...args], cwd, stdout: 'pipe', stderr })
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (isExecutableMissing(err)) throw gitSpawnRefusal(cwd)
|
|
41
|
+
throw err
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function spawnGit(args: string[], cwd: string): Bun.Subprocess<'ignore', 'pipe', 'pipe'> {
|
|
46
|
+
try {
|
|
47
|
+
return Bun.spawn({ cmd: [executablePath('git'), ...args], cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (isExecutableMissing(err)) throw gitSpawnRefusal(cwd)
|
|
50
|
+
throw err
|
|
51
|
+
}
|
|
52
|
+
}
|
|
15
53
|
|
|
16
54
|
export interface AffectedArgs {
|
|
17
55
|
workspaceRoot: string
|
|
@@ -22,18 +60,18 @@ export interface AffectedArgs {
|
|
|
22
60
|
* Which projects declare a `cache.inputs.workspaceFiles` glob matching one of
|
|
23
61
|
* these workspace-relative paths.
|
|
24
62
|
*
|
|
25
|
-
* A workspace-anchored glob is the documented escape hatch for
|
|
26
|
-
*
|
|
27
|
-
* structurally cannot see it
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* the
|
|
33
|
-
*
|
|
34
|
-
*
|
|
63
|
+
* A workspace-anchored glob is the documented escape hatch for inputs
|
|
64
|
+
* outside the project, so mapping changed paths to project directories
|
|
65
|
+
* structurally cannot see it. Answering needs the RESOLVED configs, which
|
|
66
|
+
* selection runs before loading, so it is a callback, asked only when
|
|
67
|
+
* something changed. It is asked about EVERY changed path, the ones a
|
|
68
|
+
* project owns included: the glob may name a file inside another project
|
|
69
|
+
* (`schema.md` allows it), and asking only the paths no project owns
|
|
70
|
+
* left the declaring project out of a run its key called stale
|
|
71
|
+
* (item 954). The `--affected` sugar's graph walk has already staged every
|
|
72
|
+
* config, so there it costs nothing more.
|
|
35
73
|
*/
|
|
36
|
-
workspaceGlobOwners?: (
|
|
74
|
+
workspaceGlobOwners?: (changedPaths: readonly string[]) => Promise<Iterable<string>>
|
|
37
75
|
/**
|
|
38
76
|
* The fingerprint files a plugin claims (`VxPlugin.fingerprint`) and its
|
|
39
77
|
* answer for a change to one. Resolved lazily: loading the workspace
|
|
@@ -70,6 +108,16 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
|
|
|
70
108
|
`git ref "${args.since}" is not a ref: a base cannot be empty or start with "-".`,
|
|
71
109
|
)
|
|
72
110
|
}
|
|
111
|
+
// `A..B` / `A...B` reached `rev-parse --verify`, which refuses a range, and
|
|
112
|
+
// the user read "did not resolve" about refs that both exist. `..` is
|
|
113
|
+
// illegal in a ref name (git-check-ref-format), so this refuses no ref.
|
|
114
|
+
const range = args.since.indexOf('..')
|
|
115
|
+
if (range >= 0) {
|
|
116
|
+
throw new UserError(
|
|
117
|
+
`git ref "${args.since}" is a range: ranges are not supported — pass the base alone ` +
|
|
118
|
+
`("${args.since.slice(0, range) || 'HEAD'}"); vx diffs it against the working tree.`,
|
|
119
|
+
)
|
|
120
|
+
}
|
|
73
121
|
await verifyRef(args.workspaceRoot, args.since)
|
|
74
122
|
// Diff from the MERGE BASE of `since` and HEAD, not from `since` itself:
|
|
75
123
|
// on a branch whose base has moved on, `git diff <base>` reports every
|
|
@@ -100,9 +148,15 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
|
|
|
100
148
|
// non-ASCII / `"` / `\` character — the quoted string then resolves to no
|
|
101
149
|
// project, while the cache-input enumeration (which uses `-z`) sees the
|
|
102
150
|
// real name and re-keys the task. The two surfaces must agree.
|
|
151
|
+
//
|
|
152
|
+
// `--ignore-submodules=none` because a repository may ask git to hide its
|
|
153
|
+
// submodules (`diff.ignoreSubmodules`, `submodule.<name>.ignore`), and
|
|
154
|
+
// then an edit inside one, or a committed bump, was no change here while
|
|
155
|
+
// the task's key moved (item 951).
|
|
103
156
|
gitPaths(args.workspaceRoot, [
|
|
104
157
|
'diff',
|
|
105
158
|
'--no-renames',
|
|
159
|
+
'--ignore-submodules=none',
|
|
106
160
|
'--relative',
|
|
107
161
|
'--name-only',
|
|
108
162
|
'-z',
|
|
@@ -135,10 +189,25 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
|
|
|
135
189
|
// says per project, so selection asks the plugin the same question, with
|
|
136
190
|
// the bytes at the base ref and in the working tree. Its answer is
|
|
137
191
|
// unioned with the path-owned projects below; only "cannot tell" widens.
|
|
192
|
+
if (await workspaceConfigChanged(args.workspaceRoot, changed)) {
|
|
193
|
+
return new Set(args.projects.map((p) => p.name))
|
|
194
|
+
}
|
|
138
195
|
const fingerprintChanged = changed.filter((p) => FINGERPRINT_SET.has(p))
|
|
139
196
|
const claimedOwned = new Set<string>()
|
|
197
|
+
// A claim may also name a root file core does not fold (`turbo.json`,
|
|
198
|
+
// read by `turbo()`'s stages): it re-keys tasks through their resolved
|
|
199
|
+
// configs and belongs to no project (item 961). Only a changed ROOT name
|
|
200
|
+
// can be one, so a diff inside the projects never loads the claims.
|
|
201
|
+
const rootNamesChanged = changed.filter((p) => !p.includes('/') && !FINGERPRINT_SET.has(p))
|
|
202
|
+
let claims: FingerprintClaims | undefined
|
|
203
|
+
if (
|
|
204
|
+
(fingerprintChanged.length > 0 || rootNamesChanged.length > 0) &&
|
|
205
|
+
args.fingerprintClaims !== undefined
|
|
206
|
+
) {
|
|
207
|
+
claims = await args.fingerprintClaims()
|
|
208
|
+
for (const file of rootNamesChanged) if (claims.files.has(file)) fingerprintChanged.push(file)
|
|
209
|
+
}
|
|
140
210
|
if (fingerprintChanged.length > 0) {
|
|
141
|
-
const claims = args.fingerprintClaims === undefined ? undefined : await args.fingerprintClaims()
|
|
142
211
|
for (const file of fingerprintChanged) {
|
|
143
212
|
if (claims === undefined || !claims.files.has(file)) {
|
|
144
213
|
return new Set(args.projects.map((p) => p.name))
|
|
@@ -153,15 +222,23 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
|
|
|
153
222
|
}
|
|
154
223
|
}
|
|
155
224
|
|
|
156
|
-
const
|
|
225
|
+
const owned = projectsContaining(args.workspaceRoot, changed, args.projects)
|
|
157
226
|
for (const name of claimedOwned) owned.add(name)
|
|
158
227
|
|
|
228
|
+
// A package the change REMOVED is no project now, so containment maps its
|
|
229
|
+
// paths to nothing, and the dependents walk sees only today's graph: a
|
|
230
|
+
// `git rm -r pkgs/lib`, or `lib` dropped from `workspaces`, re-keyed and
|
|
231
|
+
// broke `app#build` while `--affected` said nothing affected (item 959).
|
|
232
|
+
const dependents = await dependentsOfRemoved(args.workspaceRoot, base, changed, args.projects)
|
|
233
|
+
if (dependents === undefined) return new Set(args.projects.map((p) => p.name))
|
|
234
|
+
for (const name of dependents) owned.add(name)
|
|
235
|
+
|
|
159
236
|
// THIRD CHANNEL: a project whose `vx.config.*` IMPORTS a changed file.
|
|
160
237
|
// Resolved-config hashing folds those values into the key, so the same
|
|
161
238
|
// sentence above applies — input hashing sees it, so selection must. This
|
|
162
|
-
// runs on the FULL changed set, not just
|
|
163
|
-
// config reaching into ANOTHER project
|
|
164
|
-
//
|
|
239
|
+
// runs on the FULL changed set, not just the paths no project owns: the
|
|
240
|
+
// common shape is a config reaching into ANOTHER project
|
|
241
|
+
// (`../../src/index.ts`), whose target is owned.
|
|
165
242
|
for (const name of await configImportOwners({
|
|
166
243
|
workspaceRoot: args.workspaceRoot,
|
|
167
244
|
projects: args.projects,
|
|
@@ -171,11 +248,102 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
|
|
|
171
248
|
owned.add(name)
|
|
172
249
|
}
|
|
173
250
|
|
|
174
|
-
if (
|
|
175
|
-
for (const name of await args.workspaceGlobOwners(
|
|
251
|
+
if (changed.length === 0 || args.workspaceGlobOwners === undefined) return owned
|
|
252
|
+
for (const name of await args.workspaceGlobOwners(changed)) owned.add(name)
|
|
176
253
|
return owned
|
|
177
254
|
}
|
|
178
255
|
|
|
256
|
+
/**
|
|
257
|
+
* The projects that depend on a package the change removed: a
|
|
258
|
+
* `<dir>/package.json` the base had and the tree does not, read at the base
|
|
259
|
+
* for its name. `undefined` (every project) when the root manifest's
|
|
260
|
+
* `workspaces` moved: which packages left is a discovery at the base, and
|
|
261
|
+
* selection cannot tell cheaply. Only DELETED manifests are read at the
|
|
262
|
+
* base, one `git show` each; an edited one stays a project and is owned.
|
|
263
|
+
*/
|
|
264
|
+
async function dependentsOfRemoved(
|
|
265
|
+
workspaceRoot: string,
|
|
266
|
+
base: string,
|
|
267
|
+
changed: readonly string[],
|
|
268
|
+
projects: readonly ProjectMeta[],
|
|
269
|
+
): Promise<Set<string> | undefined> {
|
|
270
|
+
const out = new Set<string>()
|
|
271
|
+
const removed = new Set<string>()
|
|
272
|
+
for (const rel of changed) {
|
|
273
|
+
if (rel === 'package.json') {
|
|
274
|
+
const workspacesOf = (bytes: Uint8Array | null): string => {
|
|
275
|
+
if (bytes === null) return ''
|
|
276
|
+
try {
|
|
277
|
+
return JSON.stringify(
|
|
278
|
+
(JSON.parse(new TextDecoder().decode(bytes)) as PackageJson).workspaces ?? null,
|
|
279
|
+
)
|
|
280
|
+
} catch {
|
|
281
|
+
return 'unreadable'
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const before = workspacesOf(await gitBytesAt(workspaceRoot, base, rel))
|
|
285
|
+
const after = workspacesOf(await bytesOrNull(path.join(workspaceRoot, rel)))
|
|
286
|
+
if (before !== after) return undefined
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
if (path.posix.basename(rel) !== 'package.json') continue
|
|
290
|
+
if ((await bytesOrNull(path.join(workspaceRoot, rel))) !== null) continue
|
|
291
|
+
const bytes = await gitBytesAt(workspaceRoot, base, rel)
|
|
292
|
+
if (bytes === null) continue
|
|
293
|
+
try {
|
|
294
|
+
const name = (JSON.parse(new TextDecoder().decode(bytes)) as PackageJson).name
|
|
295
|
+
if (typeof name === 'string' && name !== '') removed.add(name)
|
|
296
|
+
} catch {
|
|
297
|
+
// A manifest that did not parse at the base named no package.
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (removed.size === 0) return out
|
|
301
|
+
for (const p of projects) {
|
|
302
|
+
const pkg = p.packageJson
|
|
303
|
+
for (const deps of [
|
|
304
|
+
pkg.dependencies,
|
|
305
|
+
pkg.devDependencies,
|
|
306
|
+
pkg.peerDependencies,
|
|
307
|
+
pkg.optionalDependencies,
|
|
308
|
+
]) {
|
|
309
|
+
if (deps !== undefined && Object.keys(deps).some((d) => removed.has(d))) out.add(p.name)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return out
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Whether `changed` holds the workspace config or a file it imports by
|
|
317
|
+
* relative specifier. Its plugins' `config` and `project` stages shape
|
|
318
|
+
* every project's resolved config, so such an edit can re-key any task,
|
|
319
|
+
* and selection cannot tell which: an edit that changed every key
|
|
320
|
+
* selected nothing (item 953). The fingerprint leaves the file out (its
|
|
321
|
+
* placement plugins must not split a cache between machines); a stage it
|
|
322
|
+
* installs re-keys through the resolved configs instead. Selection is not
|
|
323
|
+
* hashed, so widening here changes no key.
|
|
324
|
+
*/
|
|
325
|
+
async function workspaceConfigChanged(
|
|
326
|
+
workspaceRoot: string,
|
|
327
|
+
changed: readonly string[],
|
|
328
|
+
): Promise<boolean> {
|
|
329
|
+
if (changed.length === 0) return false
|
|
330
|
+
const set = new Set(changed)
|
|
331
|
+
if (WORKSPACE_CONFIG_FILENAMES.some((n) => set.has(n))) return true
|
|
332
|
+
const config = WORKSPACE_CONFIG_FILENAMES.map((n) => path.join(workspaceRoot, n)).find((f) => {
|
|
333
|
+
try {
|
|
334
|
+
return statSync(f).isFile()
|
|
335
|
+
} catch {
|
|
336
|
+
return false
|
|
337
|
+
}
|
|
338
|
+
})
|
|
339
|
+
if (config === undefined) return false
|
|
340
|
+
const root = realpathSync(workspaceRoot)
|
|
341
|
+
for (const file of await configImports(config)) {
|
|
342
|
+
if (set.has(path.relative(root, file).split(path.sep).join('/'))) return true
|
|
343
|
+
}
|
|
344
|
+
return false
|
|
345
|
+
}
|
|
346
|
+
|
|
179
347
|
/**
|
|
180
348
|
* Does any `cache.inputs.workspaceFiles` entry in `globs` match `rel`?
|
|
181
349
|
*
|
|
@@ -183,17 +351,27 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
|
|
|
183
351
|
* with no positive glob nothing matches. Paths are workspace-root-relative and
|
|
184
352
|
* POSIX-separated, which is the form both git enumeration and the glob
|
|
185
353
|
* resolver already speak.
|
|
354
|
+
*
|
|
355
|
+
* `asTrees` on BOTH sides, because the resolver runs it on both and this
|
|
356
|
+
* has to answer the same question the KEY answers. It did not, and the
|
|
357
|
+
* gap decided whether a changed file rebuilt anything: with
|
|
358
|
+
* `workspaceFiles: ['./generated/**']` or the literal `['generated']` —
|
|
359
|
+
* five spellings in all — the resolver folds `generated/x.txt` into the
|
|
360
|
+
* project's key while a raw `Bun.Glob` matched none of them, so
|
|
361
|
+
* `--affected` left the project out of a run its own key says is stale
|
|
362
|
+
* (item 445). A mirror that normalizes differently from what it mirrors
|
|
363
|
+
* is not a mirror.
|
|
186
364
|
*/
|
|
187
365
|
export function workspaceGlobsMatch(globs: readonly string[], rel: string): boolean {
|
|
188
|
-
const positive:
|
|
189
|
-
const negative:
|
|
366
|
+
const positive: string[] = []
|
|
367
|
+
const negative: string[] = []
|
|
190
368
|
for (const entry of globs) {
|
|
191
|
-
if (entry.startsWith('!')) negative.push(
|
|
192
|
-
else positive.push(
|
|
369
|
+
if (entry.startsWith('!')) negative.push(entry.slice(1))
|
|
370
|
+
else positive.push(entry)
|
|
193
371
|
}
|
|
194
372
|
if (positive.length === 0) return false
|
|
195
|
-
if (!positive.some((g) => g.match(rel))) return false
|
|
196
|
-
return !negative.some((g) => g.match(rel))
|
|
373
|
+
if (!asTrees(positive).some((g) => taskGlob(g).match(rel))) return false
|
|
374
|
+
return !asTrees(negative).some((g) => taskGlob(g).match(rel))
|
|
197
375
|
}
|
|
198
376
|
|
|
199
377
|
/**
|
|
@@ -213,12 +391,7 @@ async function gitBytesAt(
|
|
|
213
391
|
ref: string,
|
|
214
392
|
file: string,
|
|
215
393
|
): Promise<Uint8Array | null> {
|
|
216
|
-
const proc =
|
|
217
|
-
cmd: ['git', 'show', '--end-of-options', `${ref}:./${file}`],
|
|
218
|
-
cwd: workspaceRoot,
|
|
219
|
-
stdout: 'pipe',
|
|
220
|
-
stderr: 'pipe',
|
|
221
|
-
})
|
|
394
|
+
const proc = spawnGit(['show', '--end-of-options', `${ref}:./${file}`], workspaceRoot)
|
|
222
395
|
const [bytes, stderr, exit] = await Promise.all([
|
|
223
396
|
new Response(proc.stdout).bytes(),
|
|
224
397
|
new Response(proc.stderr).text(),
|
|
@@ -238,12 +411,7 @@ async function bytesOrNull(file: string): Promise<Uint8Array | null> {
|
|
|
238
411
|
|
|
239
412
|
/** Run a NUL-separated path-listing git command from the workspace root. */
|
|
240
413
|
async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]> {
|
|
241
|
-
const proc =
|
|
242
|
-
cmd: ['git', ...cmd],
|
|
243
|
-
cwd: workspaceRoot,
|
|
244
|
-
stdout: 'pipe',
|
|
245
|
-
stderr: 'pipe',
|
|
246
|
-
})
|
|
414
|
+
const proc = spawnGit([...cmd], workspaceRoot)
|
|
247
415
|
const stdout = await new Response(proc.stdout).text()
|
|
248
416
|
const stderr = await new Response(proc.stderr).text()
|
|
249
417
|
const exit = await proc.exited
|
|
@@ -256,41 +424,63 @@ async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]>
|
|
|
256
424
|
/**
|
|
257
425
|
* Resolve the default base for `--affected` with no explicit value.
|
|
258
426
|
* Tries the remote's HEAD branch first (`origin/main`, `origin/master`,
|
|
259
|
-
* etc.), then falls back to `HEAD~1
|
|
260
|
-
*
|
|
427
|
+
* etc.), then falls back to `HEAD~1`. A clone with no `origin/HEAD` and
|
|
428
|
+
* no parent commit — a CI checkout at `fetch-depth: 1` — has no base at
|
|
429
|
+
* all, and says so here rather than failing on a ref nobody typed.
|
|
261
430
|
*/
|
|
262
431
|
export async function defaultAffectedBase(workspaceRoot: string): Promise<string> {
|
|
263
|
-
const probe =
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
})
|
|
432
|
+
const probe = spawnGitSync(
|
|
433
|
+
['symbolic-ref', '--short', '-q', 'refs/remotes/origin/HEAD'],
|
|
434
|
+
workspaceRoot,
|
|
435
|
+
'ignore',
|
|
436
|
+
)
|
|
269
437
|
const out = new TextDecoder().decode(probe.stdout).trim()
|
|
270
438
|
if (probe.exitCode === 0 && out.length > 0) return out
|
|
439
|
+
if (revParse(workspaceRoot, 'HEAD~1') === undefined) {
|
|
440
|
+
throw new UserError(
|
|
441
|
+
'--affected has no base here: origin/HEAD is not set and HEAD has no parent to compare ' +
|
|
442
|
+
'with — a shallow clone? Fetch history (actions/checkout: fetch-depth: 0) or name the ' +
|
|
443
|
+
'base: --affected=origin/main',
|
|
444
|
+
)
|
|
445
|
+
}
|
|
271
446
|
return 'HEAD~1'
|
|
272
447
|
}
|
|
273
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Does `ref` name the commit HEAD is on? A base that IS HEAD can never
|
|
451
|
+
* mark anything affected — the shape of a single-branch clone whose
|
|
452
|
+
* `origin/HEAD` is the branch under test.
|
|
453
|
+
*/
|
|
454
|
+
export function refIsHead(workspaceRoot: string, ref: string): boolean {
|
|
455
|
+
const head = revParse(workspaceRoot, 'HEAD')
|
|
456
|
+
return head !== undefined && head === revParse(workspaceRoot, ref)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** The commit `ref` names, or undefined when it does not resolve here. */
|
|
460
|
+
function revParse(workspaceRoot: string, ref: string): string | undefined {
|
|
461
|
+
const proc = spawnGitSync(
|
|
462
|
+
['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`],
|
|
463
|
+
workspaceRoot,
|
|
464
|
+
'ignore',
|
|
465
|
+
)
|
|
466
|
+
const sha = new TextDecoder().decode(proc.stdout).trim()
|
|
467
|
+
return proc.exitCode === 0 && sha.length > 0 ? sha : undefined
|
|
468
|
+
}
|
|
469
|
+
|
|
274
470
|
/** `git merge-base <ref> HEAD`, or `ref` itself when the two share no ancestor. */
|
|
275
471
|
async function mergeBase(workspaceRoot: string, ref: string): Promise<string> {
|
|
276
|
-
const proc =
|
|
277
|
-
cmd: ['git', 'merge-base', '--end-of-options', ref, 'HEAD'],
|
|
278
|
-
cwd: workspaceRoot,
|
|
279
|
-
stdout: 'pipe',
|
|
280
|
-
stderr: 'pipe',
|
|
281
|
-
})
|
|
472
|
+
const proc = spawnGit(['merge-base', '--end-of-options', ref, 'HEAD'], workspaceRoot)
|
|
282
473
|
const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
|
|
283
474
|
const sha = out.trim()
|
|
284
475
|
return exit === 0 && sha.length > 0 ? sha : ref
|
|
285
476
|
}
|
|
286
477
|
|
|
287
478
|
async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
|
|
288
|
-
const proc =
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
})
|
|
479
|
+
const proc = spawnGitSync(
|
|
480
|
+
['rev-parse', '--verify', '--quiet', '--end-of-options', ref],
|
|
481
|
+
workspaceRoot,
|
|
482
|
+
'pipe',
|
|
483
|
+
)
|
|
294
484
|
if (proc.exitCode === 0) return
|
|
295
485
|
// `--verify --quiet` exits 1 for "that ref does not exist" and 128 for
|
|
296
486
|
// "git could not run here at all" (not a repository, corrupt objects,
|
|
@@ -307,11 +497,19 @@ async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
|
|
|
307
497
|
throw new UserError(`git ref "${ref}" did not resolve. Pass a branch or commit you have locally.`)
|
|
308
498
|
}
|
|
309
499
|
|
|
500
|
+
function isDirectory(abs: string): boolean {
|
|
501
|
+
try {
|
|
502
|
+
return statSync(abs).isDirectory()
|
|
503
|
+
} catch {
|
|
504
|
+
return false
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
310
508
|
function projectsContaining(
|
|
311
509
|
workspaceRoot: string,
|
|
312
510
|
changedRelPaths: readonly string[],
|
|
313
511
|
projects: readonly ProjectMeta[],
|
|
314
|
-
):
|
|
512
|
+
): Set<string> {
|
|
315
513
|
// Index projects by their (canonical) dir, then for each changed path walk
|
|
316
514
|
// its ancestor dirs bottom-up until one is a project dir. The FIRST hit is
|
|
317
515
|
// the DEEPEST containing project — so a nested project still wins over its
|
|
@@ -322,9 +520,6 @@ function projectsContaining(
|
|
|
322
520
|
const dirToName = new Map<string, string>()
|
|
323
521
|
for (const p of projects) dirToName.set(p.dir, p.name)
|
|
324
522
|
const owned = new Set<string>()
|
|
325
|
-
// Paths that belong to no project. Only these can reach a task through a
|
|
326
|
-
// workspace-anchored glob, so they are the exact set worth asking about.
|
|
327
|
-
const orphans: string[] = []
|
|
328
523
|
for (const rel of changedRelPaths) {
|
|
329
524
|
let dir = path.resolve(workspaceRoot, rel)
|
|
330
525
|
let hit = false
|
|
@@ -339,7 +534,20 @@ function projectsContaining(
|
|
|
339
534
|
if (parent === dir) break // reached the filesystem root
|
|
340
535
|
dir = parent
|
|
341
536
|
}
|
|
342
|
-
if (
|
|
537
|
+
if (hit) continue
|
|
538
|
+
// A changed path that is a DIRECTORY on disk is a nested repository —
|
|
539
|
+
// a submodule whose checkout moved or is dirty (`vendor/sub`), an
|
|
540
|
+
// embedded repository left untracked (`vendor/nested/`) — because git
|
|
541
|
+
// reports nothing else as a directory. The workspace repository sees
|
|
542
|
+
// the nested one as that single path, so a change inside is a change
|
|
543
|
+
// to it, and every project under it is affected. Its own enumeration
|
|
544
|
+
// already keys those projects on their files (git-inputs.ts); without
|
|
545
|
+
// this, `--affected` after an edit inside selected none of them.
|
|
546
|
+
const abs = path.resolve(workspaceRoot, rel)
|
|
547
|
+
if (isDirectory(abs)) {
|
|
548
|
+
const prefix = abs + path.sep
|
|
549
|
+
for (const [dir, name] of dirToName) if (dir.startsWith(prefix)) owned.add(name)
|
|
550
|
+
}
|
|
343
551
|
}
|
|
344
|
-
return
|
|
552
|
+
return owned
|
|
345
553
|
}
|