@vzn/vx 0.0.18 → 0.0.22
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/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -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/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
|
@@ -1,20 +1,55 @@
|
|
|
1
1
|
// `--affected` — git-relative project selection.
|
|
2
2
|
//
|
|
3
3
|
// Resolves the set of project names whose files have changed since a
|
|
4
|
-
// given git ref
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// We use `git diff --name-only <since>` which by default compares the
|
|
9
|
-
// `<since>` commit to the working tree — i.e. commits-from-`<since>`
|
|
10
|
-
// + index + unstaged. Matches Turbo's `[<since>]` filter semantics.
|
|
4
|
+
// given git ref: `git diff --name-only <merge-base(since, HEAD)>`, which
|
|
5
|
+
// compares that commit to the working tree — commits on this branch +
|
|
6
|
+
// index + unstaged — so it captures everything you touched and nothing
|
|
7
|
+
// the base branch moved on with. Matches Turbo's `[<since>]` semantics.
|
|
11
8
|
|
|
9
|
+
import { realpathSync, statSync } from 'node:fs'
|
|
12
10
|
import path from 'node:path'
|
|
13
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
asTrees,
|
|
13
|
+
executablePath,
|
|
14
|
+
UserError,
|
|
15
|
+
gitSpawnRefusal,
|
|
16
|
+
isExecutableMissing,
|
|
17
|
+
taskGlob,
|
|
18
|
+
} from '../util/index.js'
|
|
14
19
|
import { LOCKFILE_NAME } from './lockfile.js'
|
|
15
20
|
import { configImportOwners } from './config-imports.js'
|
|
21
|
+
import { configImports } from './config-cache.js'
|
|
22
|
+
import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
|
|
16
23
|
import { WORKSPACE_FINGERPRINT_FILES } from './fingerprint.js'
|
|
17
|
-
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
|
+
}
|
|
18
53
|
|
|
19
54
|
export interface AffectedArgs {
|
|
20
55
|
workspaceRoot: string
|
|
@@ -25,18 +60,35 @@ export interface AffectedArgs {
|
|
|
25
60
|
* Which projects declare a `cache.inputs.workspaceFiles` glob matching one of
|
|
26
61
|
* these workspace-relative paths.
|
|
27
62
|
*
|
|
28
|
-
* A workspace-anchored glob is the documented escape hatch for
|
|
29
|
-
*
|
|
30
|
-
* structurally cannot see it
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* the
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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.
|
|
38
73
|
*/
|
|
39
|
-
workspaceGlobOwners?: (
|
|
74
|
+
workspaceGlobOwners?: (changedPaths: readonly string[]) => Promise<Iterable<string>>
|
|
75
|
+
/**
|
|
76
|
+
* The fingerprint files a plugin claims (`VxPlugin.fingerprint`) and its
|
|
77
|
+
* answer for a change to one. Resolved lazily: loading the workspace
|
|
78
|
+
* file costs an evaluation, and a diff that touches no fingerprint file
|
|
79
|
+
* — the common one — never needs it.
|
|
80
|
+
*/
|
|
81
|
+
fingerprintClaims?: () => Promise<FingerprintClaims>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface FingerprintClaims {
|
|
85
|
+
readonly files: ReadonlySet<string>
|
|
86
|
+
/** Project names a change to a claimed file affects; `undefined` = all. */
|
|
87
|
+
affected(change: {
|
|
88
|
+
file: string
|
|
89
|
+
before: Uint8Array | null
|
|
90
|
+
after: Uint8Array | null
|
|
91
|
+
}): Promise<ReadonlySet<string> | undefined>
|
|
40
92
|
}
|
|
41
93
|
|
|
42
94
|
/**
|
|
@@ -45,7 +97,36 @@ export interface AffectedArgs {
|
|
|
45
97
|
* or `<since>` doesn't resolve to a commit.
|
|
46
98
|
*/
|
|
47
99
|
export async function affectedProjects(args: AffectedArgs): Promise<Set<string>> {
|
|
100
|
+
// The base reaches git as an argument, never through a shell, so `$(…)`
|
|
101
|
+
// is opaque — but an option-like value is not: `--output=<path>` is a
|
|
102
|
+
// real `git diff` option and an arbitrary file write. This is a security
|
|
103
|
+
// boundary, so it is a check that knows it is one, before any spawn, and
|
|
104
|
+
// every git call below also ends its options (`--end-of-options`) so a
|
|
105
|
+
// second caller cannot lose the guard by accident.
|
|
106
|
+
if (args.since.length === 0 || args.since.startsWith('-')) {
|
|
107
|
+
throw new UserError(
|
|
108
|
+
`git ref "${args.since}" is not a ref: a base cannot be empty or start with "-".`,
|
|
109
|
+
)
|
|
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
|
+
}
|
|
48
121
|
await verifyRef(args.workspaceRoot, args.since)
|
|
122
|
+
// Diff from the MERGE BASE of `since` and HEAD, not from `since` itself:
|
|
123
|
+
// on a branch whose base has moved on, `git diff <base>` reports every
|
|
124
|
+
// file OTHER people changed on the base (over-selection that defeats a
|
|
125
|
+
// CI `--affected`), and hides your own edit when the base later landed
|
|
126
|
+
// byte-identical content. Turbo and Nx both diff from the merge base;
|
|
127
|
+
// when there is none (unrelated histories, a detached probe) the ref
|
|
128
|
+
// itself is the base, as before.
|
|
129
|
+
const base = await mergeBase(args.workspaceRoot, args.since)
|
|
49
130
|
|
|
50
131
|
const [diffed, untracked] = await Promise.all([
|
|
51
132
|
// `--no-renames` is crucial for project-affected detection: with
|
|
@@ -67,13 +148,20 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
|
|
|
67
148
|
// non-ASCII / `"` / `\` character — the quoted string then resolves to no
|
|
68
149
|
// project, while the cache-input enumeration (which uses `-z`) sees the
|
|
69
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).
|
|
70
156
|
gitPaths(args.workspaceRoot, [
|
|
71
157
|
'diff',
|
|
72
158
|
'--no-renames',
|
|
159
|
+
'--ignore-submodules=none',
|
|
73
160
|
'--relative',
|
|
74
161
|
'--name-only',
|
|
75
162
|
'-z',
|
|
76
|
-
|
|
163
|
+
'--end-of-options',
|
|
164
|
+
base,
|
|
77
165
|
]),
|
|
78
166
|
// `git diff` never reports untracked-but-not-ignored files, but input
|
|
79
167
|
// enumeration does (`git ls-files --cached --others --exclude-standard`),
|
|
@@ -96,18 +184,61 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
|
|
|
96
184
|
// principle: "input hashing sees it, so `--affected` must too."
|
|
97
185
|
//
|
|
98
186
|
// Selection is not hashed, so widening it here changes no cache key.
|
|
99
|
-
|
|
187
|
+
//
|
|
188
|
+
// A file a plugin CLAIMS is the exception: the key folds what the plugin
|
|
189
|
+
// says per project, so selection asks the plugin the same question, with
|
|
190
|
+
// the bytes at the base ref and in the working tree. Its answer is
|
|
191
|
+
// unioned with the path-owned projects below; only "cannot tell" widens.
|
|
192
|
+
if (await workspaceConfigChanged(args.workspaceRoot, changed)) {
|
|
100
193
|
return new Set(args.projects.map((p) => p.name))
|
|
101
194
|
}
|
|
195
|
+
const fingerprintChanged = changed.filter((p) => FINGERPRINT_SET.has(p))
|
|
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
|
+
}
|
|
210
|
+
if (fingerprintChanged.length > 0) {
|
|
211
|
+
for (const file of fingerprintChanged) {
|
|
212
|
+
if (claims === undefined || !claims.files.has(file)) {
|
|
213
|
+
return new Set(args.projects.map((p) => p.name))
|
|
214
|
+
}
|
|
215
|
+
const answer = await claims.affected({
|
|
216
|
+
file,
|
|
217
|
+
before: await gitBytesAt(args.workspaceRoot, base, file),
|
|
218
|
+
after: await bytesOrNull(path.join(args.workspaceRoot, file)),
|
|
219
|
+
})
|
|
220
|
+
if (answer === undefined) return new Set(args.projects.map((p) => p.name))
|
|
221
|
+
for (const name of answer) claimedOwned.add(name)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const owned = projectsContaining(args.workspaceRoot, changed, args.projects)
|
|
226
|
+
for (const name of claimedOwned) owned.add(name)
|
|
102
227
|
|
|
103
|
-
|
|
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)
|
|
104
235
|
|
|
105
236
|
// THIRD CHANNEL: a project whose `vx.config.*` IMPORTS a changed file.
|
|
106
237
|
// Resolved-config hashing folds those values into the key, so the same
|
|
107
238
|
// sentence above applies — input hashing sees it, so selection must. This
|
|
108
|
-
// runs on the FULL changed set, not just
|
|
109
|
-
// config reaching into ANOTHER project
|
|
110
|
-
//
|
|
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.
|
|
111
242
|
for (const name of await configImportOwners({
|
|
112
243
|
workspaceRoot: args.workspaceRoot,
|
|
113
244
|
projects: args.projects,
|
|
@@ -117,11 +248,102 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
|
|
|
117
248
|
owned.add(name)
|
|
118
249
|
}
|
|
119
250
|
|
|
120
|
-
if (
|
|
121
|
-
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)
|
|
122
253
|
return owned
|
|
123
254
|
}
|
|
124
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
|
+
|
|
125
347
|
/**
|
|
126
348
|
* Does any `cache.inputs.workspaceFiles` entry in `globs` match `rel`?
|
|
127
349
|
*
|
|
@@ -129,17 +351,27 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
|
|
|
129
351
|
* with no positive glob nothing matches. Paths are workspace-root-relative and
|
|
130
352
|
* POSIX-separated, which is the form both git enumeration and the glob
|
|
131
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.
|
|
132
364
|
*/
|
|
133
365
|
export function workspaceGlobsMatch(globs: readonly string[], rel: string): boolean {
|
|
134
|
-
const positive:
|
|
135
|
-
const negative:
|
|
366
|
+
const positive: string[] = []
|
|
367
|
+
const negative: string[] = []
|
|
136
368
|
for (const entry of globs) {
|
|
137
|
-
if (entry.startsWith('!')) negative.push(
|
|
138
|
-
else positive.push(
|
|
369
|
+
if (entry.startsWith('!')) negative.push(entry.slice(1))
|
|
370
|
+
else positive.push(entry)
|
|
139
371
|
}
|
|
140
372
|
if (positive.length === 0) return false
|
|
141
|
-
if (!positive.some((g) => g.match(rel))) return false
|
|
142
|
-
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))
|
|
143
375
|
}
|
|
144
376
|
|
|
145
377
|
/**
|
|
@@ -149,14 +381,37 @@ export function workspaceGlobsMatch(globs: readonly string[], rel: string): bool
|
|
|
149
381
|
*/
|
|
150
382
|
const FINGERPRINT_SET: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FILES)
|
|
151
383
|
|
|
384
|
+
/**
|
|
385
|
+
* A root file's bytes at `ref`, or null when the ref has no such file.
|
|
386
|
+
* `./` anchors the path to the cwd (the workspace root) rather than the
|
|
387
|
+
* repository root, for a workspace that is a subdirectory of its repo.
|
|
388
|
+
*/
|
|
389
|
+
async function gitBytesAt(
|
|
390
|
+
workspaceRoot: string,
|
|
391
|
+
ref: string,
|
|
392
|
+
file: string,
|
|
393
|
+
): Promise<Uint8Array | null> {
|
|
394
|
+
const proc = spawnGit(['show', '--end-of-options', `${ref}:./${file}`], workspaceRoot)
|
|
395
|
+
const [bytes, stderr, exit] = await Promise.all([
|
|
396
|
+
new Response(proc.stdout).bytes(),
|
|
397
|
+
new Response(proc.stderr).text(),
|
|
398
|
+
proc.exited,
|
|
399
|
+
])
|
|
400
|
+
if (exit === 0) return bytes
|
|
401
|
+
// "exists on disk but not in <ref>" and "path does not exist in <ref>"
|
|
402
|
+
// are the file being absent at the ref; anything else is git failing.
|
|
403
|
+
if (/does not exist in|exists on disk, but not in/.test(stderr)) return null
|
|
404
|
+
throw new UserError(`git show ${ref}:./${file} failed (exit ${exit}): ${stderr.trim()}`)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function bytesOrNull(file: string): Promise<Uint8Array | null> {
|
|
408
|
+
const f = Bun.file(file)
|
|
409
|
+
return (await f.exists()) ? await f.bytes() : null
|
|
410
|
+
}
|
|
411
|
+
|
|
152
412
|
/** Run a NUL-separated path-listing git command from the workspace root. */
|
|
153
413
|
async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]> {
|
|
154
|
-
const proc =
|
|
155
|
-
cmd: ['git', ...cmd],
|
|
156
|
-
cwd: workspaceRoot,
|
|
157
|
-
stdout: 'pipe',
|
|
158
|
-
stderr: 'pipe',
|
|
159
|
-
})
|
|
414
|
+
const proc = spawnGit([...cmd], workspaceRoot)
|
|
160
415
|
const stdout = await new Response(proc.stdout).text()
|
|
161
416
|
const stderr = await new Response(proc.stderr).text()
|
|
162
417
|
const exit = await proc.exited
|
|
@@ -169,28 +424,63 @@ async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]>
|
|
|
169
424
|
/**
|
|
170
425
|
* Resolve the default base for `--affected` with no explicit value.
|
|
171
426
|
* Tries the remote's HEAD branch first (`origin/main`, `origin/master`,
|
|
172
|
-
* etc.), then falls back to `HEAD~1
|
|
173
|
-
*
|
|
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.
|
|
174
430
|
*/
|
|
175
431
|
export async function defaultAffectedBase(workspaceRoot: string): Promise<string> {
|
|
176
|
-
const probe =
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
})
|
|
432
|
+
const probe = spawnGitSync(
|
|
433
|
+
['symbolic-ref', '--short', '-q', 'refs/remotes/origin/HEAD'],
|
|
434
|
+
workspaceRoot,
|
|
435
|
+
'ignore',
|
|
436
|
+
)
|
|
182
437
|
const out = new TextDecoder().decode(probe.stdout).trim()
|
|
183
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
|
+
}
|
|
184
446
|
return 'HEAD~1'
|
|
185
447
|
}
|
|
186
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
|
+
|
|
470
|
+
/** `git merge-base <ref> HEAD`, or `ref` itself when the two share no ancestor. */
|
|
471
|
+
async function mergeBase(workspaceRoot: string, ref: string): Promise<string> {
|
|
472
|
+
const proc = spawnGit(['merge-base', '--end-of-options', ref, 'HEAD'], workspaceRoot)
|
|
473
|
+
const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
|
|
474
|
+
const sha = out.trim()
|
|
475
|
+
return exit === 0 && sha.length > 0 ? sha : ref
|
|
476
|
+
}
|
|
477
|
+
|
|
187
478
|
async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
|
|
188
|
-
const proc =
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
})
|
|
479
|
+
const proc = spawnGitSync(
|
|
480
|
+
['rev-parse', '--verify', '--quiet', '--end-of-options', ref],
|
|
481
|
+
workspaceRoot,
|
|
482
|
+
'pipe',
|
|
483
|
+
)
|
|
194
484
|
if (proc.exitCode === 0) return
|
|
195
485
|
// `--verify --quiet` exits 1 for "that ref does not exist" and 128 for
|
|
196
486
|
// "git could not run here at all" (not a repository, corrupt objects,
|
|
@@ -207,11 +497,19 @@ async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
|
|
|
207
497
|
throw new UserError(`git ref "${ref}" did not resolve. Pass a branch or commit you have locally.`)
|
|
208
498
|
}
|
|
209
499
|
|
|
500
|
+
function isDirectory(abs: string): boolean {
|
|
501
|
+
try {
|
|
502
|
+
return statSync(abs).isDirectory()
|
|
503
|
+
} catch {
|
|
504
|
+
return false
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
210
508
|
function projectsContaining(
|
|
211
509
|
workspaceRoot: string,
|
|
212
510
|
changedRelPaths: readonly string[],
|
|
213
511
|
projects: readonly ProjectMeta[],
|
|
214
|
-
):
|
|
512
|
+
): Set<string> {
|
|
215
513
|
// Index projects by their (canonical) dir, then for each changed path walk
|
|
216
514
|
// its ancestor dirs bottom-up until one is a project dir. The FIRST hit is
|
|
217
515
|
// the DEEPEST containing project — so a nested project still wins over its
|
|
@@ -222,9 +520,6 @@ function projectsContaining(
|
|
|
222
520
|
const dirToName = new Map<string, string>()
|
|
223
521
|
for (const p of projects) dirToName.set(p.dir, p.name)
|
|
224
522
|
const owned = new Set<string>()
|
|
225
|
-
// Paths that belong to no project. Only these can reach a task through a
|
|
226
|
-
// workspace-anchored glob, so they are the exact set worth asking about.
|
|
227
|
-
const orphans: string[] = []
|
|
228
523
|
for (const rel of changedRelPaths) {
|
|
229
524
|
let dir = path.resolve(workspaceRoot, rel)
|
|
230
525
|
let hit = false
|
|
@@ -239,7 +534,20 @@ function projectsContaining(
|
|
|
239
534
|
if (parent === dir) break // reached the filesystem root
|
|
240
535
|
dir = parent
|
|
241
536
|
}
|
|
242
|
-
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
|
+
}
|
|
243
551
|
}
|
|
244
|
-
return
|
|
552
|
+
return owned
|
|
245
553
|
}
|