@vzn/vx 0.0.646 → 0.0.649
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 +29 -22
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +112 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +18 -2
- package/src/bin.ts +53 -12
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +268 -53
- package/src/cache/git-inputs.ts +219 -87
- package/src/cache/index.ts +3 -2
- package/src/cache/inputs.ts +460 -78
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +49 -9
- package/src/cache/layered-cache.ts +123 -58
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +19 -3
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +90 -14
- package/src/cache/run-history.ts +70 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +25 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +58 -19
- package/src/cli/index.ts +79 -33
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +186 -31
- package/src/cli/last.ts +439 -45
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +70 -14
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +109 -34
- package/src/cli/select.ts +143 -46
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +151 -49
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +154 -10
- package/src/exec/index.ts +6 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +3 -1
- package/src/exec/runner.ts +253 -54
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +211 -24
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +270 -60
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +167 -61
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +90 -30
- package/src/index.ts +30 -13
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +4 -1
- package/src/orchestrator/execute-task.ts +130 -38
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +1 -1
- package/src/orchestrator/hit-restore.ts +217 -119
- package/src/orchestrator/index.ts +4 -3
- package/src/orchestrator/local-floor.ts +119 -25
- package/src/orchestrator/local-shortcircuit.ts +28 -4
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +42 -8
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +84 -29
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +185 -17
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +214 -42
- package/src/orchestrator/plugin.ts +113 -37
- package/src/orchestrator/prepare.ts +168 -30
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +149 -13
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -29
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +331 -88
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +70 -20
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +90 -26
- package/src/orchestrator/task-hash.ts +78 -13
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +7 -1
- package/src/orchestrator/telemetry-host.ts +22 -2
- package/src/orchestrator/telemetry.ts +53 -21
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +31 -9
- package/src/report/framed-output.ts +141 -50
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +170 -179
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +81 -53
- package/src/report/summary.ts +114 -32
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +7 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +4 -2
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +21 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +91 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +61 -3
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +17 -11
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -1
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +69 -18
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +226 -37
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
|
@@ -6,7 +6,8 @@
|
|
|
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 {
|
|
9
|
+
import { lstatSync } from 'node:fs'
|
|
10
|
+
import { lstat, readlink, realpath, stat } from 'node:fs/promises'
|
|
10
11
|
import path from 'node:path'
|
|
11
12
|
import {
|
|
12
13
|
executablePath,
|
|
@@ -18,11 +19,11 @@ import {
|
|
|
18
19
|
relPosix,
|
|
19
20
|
UserError,
|
|
20
21
|
} from '../util/index.js'
|
|
21
|
-
import { LOCKFILE_NAME } from './lockfile.js'
|
|
22
|
+
import { LOCKFILE_NAME, lockKey } from './lockfile.js'
|
|
22
23
|
import { configImportOwners, realpathOr } from './config-imports.js'
|
|
23
24
|
import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
|
|
24
25
|
import { bunPatchFiles } from './fingerprint.js'
|
|
25
|
-
import { PROJECT_CONFIG_FILENAMES, type ProjectMeta } from './workspace.js'
|
|
26
|
+
import { PROJECT_CONFIG_FILENAMES, REVIEW_FILE, type ProjectMeta } from './workspace.js'
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Every git call here goes through these two: a git that is not on PATH
|
|
@@ -38,7 +39,9 @@ function spawnGitSync(
|
|
|
38
39
|
try {
|
|
39
40
|
return Bun.spawnSync({
|
|
40
41
|
cmd: [executablePath('git'), ...args],
|
|
41
|
-
|
|
42
|
+
// A probe never fetches: in a partial clone, `rev-parse` of a commit
|
|
43
|
+
// it lacks fetched that commit and all its history (50k objects).
|
|
44
|
+
env: { ...gitEnv(), GIT_NO_LAZY_FETCH: '1' },
|
|
42
45
|
cwd,
|
|
43
46
|
stdout: 'pipe',
|
|
44
47
|
stderr,
|
|
@@ -99,6 +102,7 @@ export interface AffectedArgs {
|
|
|
99
102
|
workspaceGlobOwners?: (
|
|
100
103
|
changedPaths: readonly string[],
|
|
101
104
|
nested: readonly string[],
|
|
105
|
+
dirLinks: ReadonlySet<string>,
|
|
102
106
|
) => Promise<Iterable<string>>
|
|
103
107
|
/**
|
|
104
108
|
* The fingerprint files a plugin claims (`VxPlugin.fingerprint`) and its
|
|
@@ -120,6 +124,12 @@ export interface AffectedArgs {
|
|
|
120
124
|
* -s` here. A changed or untracked link is a changed path already.
|
|
121
125
|
*/
|
|
122
126
|
links?: () => Promise<readonly string[] | null>
|
|
127
|
+
/**
|
|
128
|
+
* The tracked paths git does not look at on disk (assume-unchanged,
|
|
129
|
+
* skip-worktree), workspace-relative, from the run's enumeration; null or
|
|
130
|
+
* absent asks `git ls-files -v` here.
|
|
131
|
+
*/
|
|
132
|
+
flagged?: () => Promise<readonly string[] | null>
|
|
123
133
|
}
|
|
124
134
|
|
|
125
135
|
export interface FingerprintClaims {
|
|
@@ -132,7 +142,29 @@ export interface FingerprintClaims {
|
|
|
132
142
|
extras?: ReadonlyMap<string, { before: Uint8Array | null; after: Uint8Array | null }>
|
|
133
143
|
}): Promise<ReadonlySet<string> | undefined>
|
|
134
144
|
/** The files a claimed file names that its claimant keys too; absent = it reads none. */
|
|
135
|
-
extraFiles?(file: string, bytes: Uint8Array): readonly string[] | undefined
|
|
145
|
+
extraFiles?(file: string, bytes: Uint8Array): Promise<readonly string[] | undefined>
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `extraFiles` on one side of a claimed file. Which side failed to parse is
|
|
150
|
+
* the whole fix: a broken lockfile at the base ref is not the working
|
|
151
|
+
* tree's to regenerate, and the bare refusal named neither.
|
|
152
|
+
*/
|
|
153
|
+
async function extraFilesOn(
|
|
154
|
+
claims: FingerprintClaims,
|
|
155
|
+
file: string,
|
|
156
|
+
bytes: Uint8Array,
|
|
157
|
+
side: string,
|
|
158
|
+
): Promise<readonly string[] | undefined> {
|
|
159
|
+
try {
|
|
160
|
+
return await claims.extraFiles?.(file, bytes)
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (!(err instanceof Error)) throw err
|
|
163
|
+
throw new UserError(
|
|
164
|
+
`${err.message} (${side})`,
|
|
165
|
+
err instanceof UserError ? err.code : 'VX_E_REFUSED',
|
|
166
|
+
)
|
|
167
|
+
}
|
|
136
168
|
}
|
|
137
169
|
|
|
138
170
|
/**
|
|
@@ -145,7 +177,7 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
|
|
|
145
177
|
}
|
|
146
178
|
|
|
147
179
|
/**
|
|
148
|
-
* What `--affected` seeds its tasks from (
|
|
180
|
+
* What `--affected` seeds its tasks from (2026-10-04): the changed
|
|
149
181
|
* projects, each one's changed paths relative to it, and the projects a
|
|
150
182
|
* change reaches as a whole — every task in one is affected whatever its
|
|
151
183
|
* inputs say, because the reason was no path of its own (a lockfile claim,
|
|
@@ -167,8 +199,18 @@ export interface AffectedChanges {
|
|
|
167
199
|
* in it, so a `workspaceFiles` glob that reaches under one is reached.
|
|
168
200
|
*/
|
|
169
201
|
nested?: readonly string[]
|
|
202
|
+
/**
|
|
203
|
+
* The changed paths a glob matches as a directory (a link to one),
|
|
204
|
+
* workspace-relative: `declaresInput`'s `dirLink`.
|
|
205
|
+
*/
|
|
206
|
+
dirLinks?: ReadonlySet<string>
|
|
207
|
+
/** Project → its `dirLinks`, relative to the project's directory. */
|
|
208
|
+
dirLinksIn?: ReadonlyMap<string, ReadonlySet<string>>
|
|
170
209
|
}
|
|
171
210
|
|
|
211
|
+
/** `<base>...HEAD` / `<base>..HEAD`: the base alone, read as `affectedChanges` reads it. */
|
|
212
|
+
const HEAD_RANGE = /^(.+?)(\.{2,3})HEAD$/s
|
|
213
|
+
|
|
172
214
|
export async function affectedChanges(
|
|
173
215
|
args: AffectedArgs,
|
|
174
216
|
perTask = true,
|
|
@@ -177,7 +219,7 @@ export async function affectedChanges(
|
|
|
177
219
|
// vx already diffs from the merge base (three dots' meaning), and the
|
|
178
220
|
// working tree it diffs to holds HEAD (D-117). Its two-dot `[A..HEAD]`
|
|
179
221
|
// diffs from A itself (G-145). Any other range is refused.
|
|
180
|
-
const headRange =
|
|
222
|
+
const headRange = HEAD_RANGE.exec(args.since)
|
|
181
223
|
const since = headRange?.[1] ?? args.since
|
|
182
224
|
const fromMergeBase = headRange?.[2] !== '..'
|
|
183
225
|
// The base reaches git as an argument, never through a shell, so `$(…)`
|
|
@@ -220,9 +262,11 @@ export async function affectedChanges(
|
|
|
220
262
|
// when there is none (unrelated histories, a detached probe) the ref
|
|
221
263
|
// itself is the base, as before. Turbo's two-dot range `A..HEAD` is the
|
|
222
264
|
// one form that diffs from A itself.
|
|
265
|
+
// Asked beside the base, not after it: the diff's shape waits on it.
|
|
266
|
+
const partial = isPartialClone(args.workspaceRoot).catch(() => false)
|
|
223
267
|
const base = fromMergeBase
|
|
224
268
|
? await mergeBase(args.workspaceRoot, since)
|
|
225
|
-
:
|
|
269
|
+
: await rangeBase(args.workspaceRoot, since)
|
|
226
270
|
|
|
227
271
|
const [diffed, untracked] = await Promise.all([
|
|
228
272
|
// `--no-renames` is crucial for project-affected detection: with
|
|
@@ -252,7 +296,7 @@ export async function affectedChanges(
|
|
|
252
296
|
//
|
|
253
297
|
// `--raw` for each path's mode: a gitlink (160000) is a nested
|
|
254
298
|
// repository, one path standing for every file in it (`nested` below).
|
|
255
|
-
gitDiffPaths(args.workspaceRoot, base),
|
|
299
|
+
gitDiffPaths(args.workspaceRoot, base, partial),
|
|
256
300
|
// `git diff` never reports untracked-but-not-ignored files, but input
|
|
257
301
|
// enumeration does (`git ls-files --cached --others --exclude-standard`),
|
|
258
302
|
// so a brand-new source file changes a task's cache key. Union it in or
|
|
@@ -264,22 +308,54 @@ export async function affectedChanges(
|
|
|
264
308
|
|
|
265
309
|
const projects = await args.projects
|
|
266
310
|
|
|
267
|
-
//
|
|
268
|
-
// file: re-running `vx lock` must not mark
|
|
311
|
+
// What vx writes under `.vx` (the lock, its git files, the cache) is
|
|
312
|
+
// excluded like a gitignored file: re-running `vx lock` must not mark
|
|
313
|
+
// every project affected, nor the root project whole. The lock's
|
|
314
|
+
// entries are read below.
|
|
269
315
|
// An untracked file under `node_modules` is an install, which the input
|
|
270
316
|
// enumeration drops too (`isInstalledPath`); a tracked one is a source.
|
|
317
|
+
// So is each project's `vx.lock.json`, the review copy `vx lock` writes
|
|
318
|
+
// beside its config and nothing reads: a re-lock rewriting them all
|
|
319
|
+
// selected every project while no key moved.
|
|
320
|
+
const reviewCopies = new Set(projects.map((p) => path.join(p.dir, REVIEW_FILE)))
|
|
321
|
+
const rootDir = path.resolve(args.workspaceRoot)
|
|
271
322
|
const changed = [...diffed.paths, ...untracked.filter((s) => !isInstalledPath(s))].filter(
|
|
272
|
-
(s) =>
|
|
323
|
+
(s) =>
|
|
324
|
+
!VX_OWN.has(s) &&
|
|
325
|
+
!s.startsWith('.vx/cache/') &&
|
|
326
|
+
!s.startsWith('.vx/tmp/') &&
|
|
327
|
+
(path.posix.basename(s) !== REVIEW_FILE || !reviewCopies.has(path.join(rootDir, s))),
|
|
273
328
|
)
|
|
274
329
|
// A symlinked input keys the content behind it (cache/linked-inputs.ts),
|
|
275
330
|
// and the diff names that content's path, never the link's: a link whose
|
|
276
331
|
// target changed is a changed path itself.
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
332
|
+
// One index listing serves both when the run's enumeration did not.
|
|
333
|
+
let index: Promise<IndexEntry[]> | undefined
|
|
334
|
+
const listIndex = () => (index ??= indexEntries(args.workspaceRoot))
|
|
335
|
+
const [links, flagged] = await Promise.all([
|
|
336
|
+
(async () =>
|
|
337
|
+
(await args.links?.().catch(() => null)) ??
|
|
338
|
+
(await listIndex()).filter((e) => e.mode === '120000').map((e) => e.path))(),
|
|
339
|
+
(async () =>
|
|
340
|
+
(await args.flagged?.().catch(() => null)) ??
|
|
341
|
+
(await listIndex()).filter((e) => e.flagged).map((e) => e.path))(),
|
|
342
|
+
])
|
|
343
|
+
const dirLinkSet = new Set<string>()
|
|
344
|
+
changed.push(...(await linkedChanges(args.workspaceRoot, links, changed, dirLinkSet)))
|
|
345
|
+
// A path git was told not to look at (`update-index --assume-unchanged`,
|
|
346
|
+
// `--skip-worktree`): `git diff` reports no edit to it, and the key
|
|
347
|
+
// hashes it from disk, so the task missed and selection left it out.
|
|
348
|
+
if (flagged.length > 0) {
|
|
349
|
+
changed.push(...(await flaggedChanges(args.workspaceRoot, flagged, await listIndex(), changed)))
|
|
350
|
+
}
|
|
351
|
+
// A changed path that is a link to a directory, or was a link and is
|
|
352
|
+
// no link to a file now: a glob reaching below it keys what is behind
|
|
353
|
+
// it (cache/inputs.ts `mayHoldMatch`), so it is matched as a directory.
|
|
354
|
+
const dirLinks = await changedDirLinks(
|
|
355
|
+
args.workspaceRoot,
|
|
356
|
+
changed,
|
|
357
|
+
dirLinkSet,
|
|
358
|
+
new Set([...diffed.links, ...untracked]),
|
|
283
359
|
)
|
|
284
360
|
// The nested repositories git reports as one path: a gitlink, or an
|
|
285
361
|
// untracked embedded repository (`dir/`, the only path git prints so).
|
|
@@ -310,8 +386,8 @@ export async function affectedChanges(
|
|
|
310
386
|
// changes no key.
|
|
311
387
|
const wsConfig = WORKSPACE_CONFIG_FILENAMES.find((n) => changed.includes(n))
|
|
312
388
|
if (wsConfig !== undefined) return everything(wsConfig)
|
|
313
|
-
//
|
|
314
|
-
//
|
|
389
|
+
// The workspace file is loaded once, for the extra files below and the
|
|
390
|
+
// changed root names after them.
|
|
315
391
|
let claims: FingerprintClaims | undefined
|
|
316
392
|
const loadClaims = async (): Promise<FingerprintClaims | undefined> =>
|
|
317
393
|
(claims ??= await args.fingerprintClaims?.())
|
|
@@ -320,30 +396,33 @@ export async function affectedChanges(
|
|
|
320
396
|
// patches, a `file:`, `link:` or `portal:` directory
|
|
321
397
|
// a manager copies or links) is part of
|
|
322
398
|
// that lockfile: a change to one, or under one, is a lockfile change the
|
|
323
|
-
// claimant answers with it.
|
|
324
|
-
//
|
|
399
|
+
// claimant answers with it. It may sit inside another project's
|
|
400
|
+
// directory, so every changed path is asked: asking only the paths no
|
|
401
|
+
// project owned selected the owner and never the project the lockfile
|
|
402
|
+
// says reaches it.
|
|
325
403
|
const extrasBy = new Map<
|
|
326
404
|
string,
|
|
327
405
|
Map<string, { before: Uint8Array | null; after: Uint8Array | null }>
|
|
328
406
|
>()
|
|
329
|
-
const
|
|
330
|
-
const loose = changed.filter((rel) => {
|
|
331
|
-
const abs = path.join(rootDir, rel)
|
|
332
|
-
return !projects.some((p) => p.dir !== rootDir && abs.startsWith(p.dir + path.sep))
|
|
333
|
-
})
|
|
334
|
-
const extraClaims = loose.length > 0 ? await loadClaims() : undefined
|
|
407
|
+
const extraClaims = changed.length > 0 ? await loadClaims() : undefined
|
|
335
408
|
for (const file of extraClaims?.files ?? []) {
|
|
336
409
|
const after = await bytesOrNull(path.join(args.workspaceRoot, file))
|
|
337
|
-
|
|
338
|
-
|
|
410
|
+
// An unchanged claimed file is the same bytes at the base: one read,
|
|
411
|
+
// one parse, no git.
|
|
412
|
+
const same = !changed.includes(file)
|
|
413
|
+
const before = same ? after : await gitBytesAt(args.workspaceRoot, base, file)
|
|
414
|
+
const namedAfter =
|
|
415
|
+
after === null ? [] : await extraFilesOn(extraClaims!, file, after, 'in the working tree')
|
|
339
416
|
const named = new Set([
|
|
340
|
-
...(before === null
|
|
417
|
+
...(before === null || same
|
|
418
|
+
? []
|
|
419
|
+
: ((await extraFilesOn(extraClaims!, file, before, 'as of the base ref')) ?? [])),
|
|
341
420
|
...(namedAfter ?? []),
|
|
342
421
|
])
|
|
343
422
|
const extras = new Map<string, { before: Uint8Array | null; after: Uint8Array | null }>()
|
|
344
423
|
for (const rel of named) {
|
|
345
424
|
const at = path.posix.normalize(rel).replace(/\/+$/, '')
|
|
346
|
-
if (
|
|
425
|
+
if (changed.includes(at)) {
|
|
347
426
|
extras.set(rel, {
|
|
348
427
|
before: await gitBytesAt(args.workspaceRoot, base, at),
|
|
349
428
|
after: await bytesOrNull(path.join(args.workspaceRoot, at)),
|
|
@@ -352,7 +431,7 @@ export async function affectedChanges(
|
|
|
352
431
|
}
|
|
353
432
|
// A directory's sides are not its bytes: a file under it changed,
|
|
354
433
|
// so they differ, and only the projects reaching it move.
|
|
355
|
-
const under =
|
|
434
|
+
const under = changed.filter((p) => p.startsWith(`${at}/`))
|
|
356
435
|
if (under.length > 0) {
|
|
357
436
|
extras.set(rel, { before: null, after: new TextEncoder().encode(under.join('\0')) })
|
|
358
437
|
}
|
|
@@ -363,7 +442,7 @@ export async function affectedChanges(
|
|
|
363
442
|
// bun.lock names (the lockfile records no hash of them): it cannot
|
|
364
443
|
// say which projects one reaches, so its edit widens.
|
|
365
444
|
const patches = new Set(bunPatchFiles(after))
|
|
366
|
-
const patch =
|
|
445
|
+
const patch = changed.find((p) => patches.has(p))
|
|
367
446
|
if (patch !== undefined) return everything(patch)
|
|
368
447
|
}
|
|
369
448
|
}
|
|
@@ -396,6 +475,7 @@ export async function affectedChanges(
|
|
|
396
475
|
),
|
|
397
476
|
)
|
|
398
477
|
const paths = new Map<string, string[]>()
|
|
478
|
+
const dirLinksIn = new Map<string, Set<string>>()
|
|
399
479
|
const whole = new Set<string>(claimedOwned.keys())
|
|
400
480
|
const owned = projectsContaining(
|
|
401
481
|
args.workspaceRoot,
|
|
@@ -405,22 +485,62 @@ export async function affectedChanges(
|
|
|
405
485
|
realDirs,
|
|
406
486
|
perTask
|
|
407
487
|
? {
|
|
408
|
-
path: (name, rel) => {
|
|
488
|
+
path: (name, rel, wsRel) => {
|
|
409
489
|
const list = paths.get(name)
|
|
410
490
|
if (list) list.push(rel)
|
|
411
491
|
else paths.set(name, [rel])
|
|
492
|
+
if (!dirLinks.has(wsRel)) return
|
|
493
|
+
const links = dirLinksIn.get(name)
|
|
494
|
+
if (links) links.add(rel)
|
|
495
|
+
else dirLinksIn.set(name, new Set([rel]))
|
|
412
496
|
},
|
|
413
497
|
repo: (name) => whole.add(name),
|
|
414
498
|
}
|
|
415
499
|
: undefined,
|
|
416
500
|
)
|
|
417
501
|
for (const name of claimedOwned.keys()) owned.add(name)
|
|
502
|
+
// A run reads each config from the lock, so an entry that moved moved
|
|
503
|
+
// its project's keys, though no config or import did (one evaluated
|
|
504
|
+
// under another env, a preset imported by package name).
|
|
505
|
+
if (diffed.paths.includes(LOCKFILE_NAME) || untracked.includes(LOCKFILE_NAME)) {
|
|
506
|
+
// A side with no lock (or one that will not parse) ran nothing from
|
|
507
|
+
// it: the paths of the configs say what changed, as before the lock.
|
|
508
|
+
const entries = (bytes: Uint8Array | null): Record<string, unknown> | null => {
|
|
509
|
+
if (bytes === null) return null
|
|
510
|
+
try {
|
|
511
|
+
const locked = (JSON.parse(new TextDecoder().decode(bytes)) as { projects?: unknown })
|
|
512
|
+
.projects
|
|
513
|
+
return typeof locked === 'object' && locked !== null
|
|
514
|
+
? (locked as Record<string, unknown>)
|
|
515
|
+
: null
|
|
516
|
+
} catch {
|
|
517
|
+
return null
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const before = entries(await gitBytesAt(args.workspaceRoot, base, LOCKFILE_NAME))
|
|
521
|
+
const after = entries(await bytesOrNull(path.join(args.workspaceRoot, LOCKFILE_NAME)))
|
|
522
|
+
const same = (dir: string): boolean =>
|
|
523
|
+
before === null ||
|
|
524
|
+
after === null ||
|
|
525
|
+
(Object.hasOwn(before, dir) === Object.hasOwn(after, dir) &&
|
|
526
|
+
JSON.stringify(before[dir]) === JSON.stringify(after[dir]))
|
|
527
|
+
for (const p of projects) {
|
|
528
|
+
if (same(lockKey(rootDir, p.dir))) continue
|
|
529
|
+
owned.add(p.name)
|
|
530
|
+
whole.add(p.name)
|
|
531
|
+
}
|
|
532
|
+
}
|
|
418
533
|
|
|
419
|
-
// A config that makes an existing directory a project took
|
|
420
|
-
// the project above it.
|
|
421
|
-
const
|
|
422
|
-
if (
|
|
423
|
-
const atBase = await gitBlobsAt(args.workspaceRoot, base,
|
|
534
|
+
// A config or manifest that makes an existing directory a project took
|
|
535
|
+
// files from the project above it.
|
|
536
|
+
const markers = changed.filter((rel) => PROJECT_MARKERS.has(path.posix.basename(rel)))
|
|
537
|
+
if (markers.length > 0) {
|
|
538
|
+
const atBase = await gitBlobsAt(args.workspaceRoot, base, markers)
|
|
539
|
+
// A config the base had in a directory that is no project now: the
|
|
540
|
+
// project is gone, and with it the edge naming who depended on it,
|
|
541
|
+
// while their keys lost an upstream. Today's graph cannot say who.
|
|
542
|
+
const gone = await removedProjectConfig(args.workspaceRoot, atBase, projects)
|
|
543
|
+
if (gone !== undefined) return everything(gone)
|
|
424
544
|
for (const name of parentsOfNewNested(args.workspaceRoot, atBase, projects)) {
|
|
425
545
|
owned.add(name)
|
|
426
546
|
whole.add(name)
|
|
@@ -435,16 +555,20 @@ export async function affectedChanges(
|
|
|
435
555
|
// (`../../src/index.ts`), whose target is owned.
|
|
436
556
|
// Per task, a project that owns the changed file still needs to know its
|
|
437
557
|
// config imported it: that re-keys every task, whatever their inputs.
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
558
|
+
// A file the workspace config imports widens as the config itself does.
|
|
559
|
+
if (args.configImports !== false) {
|
|
560
|
+
const imports = await configImportOwners({
|
|
561
|
+
workspaceRoot: args.workspaceRoot,
|
|
562
|
+
projects: projects,
|
|
563
|
+
changed,
|
|
564
|
+
skip: perTask ? new Set() : owned,
|
|
565
|
+
workspaceConfigs: WORKSPACE_CONFIG_FILENAMES.map((n) => path.join(rootDir, n)),
|
|
566
|
+
})
|
|
567
|
+
if (imports.workspace !== undefined) return everything(imports.workspace)
|
|
568
|
+
for (const name of imports.owners) {
|
|
569
|
+
owned.add(name)
|
|
570
|
+
whole.add(name)
|
|
571
|
+
}
|
|
448
572
|
}
|
|
449
573
|
|
|
450
574
|
const nestedDirs = [...nested].map((rel) => rel.replace(/\/$/, ''))
|
|
@@ -455,22 +579,32 @@ export async function affectedChanges(
|
|
|
455
579
|
whole,
|
|
456
580
|
...(claimedOwned.size > 0 ? { wholeBy: claimedOwned } : {}),
|
|
457
581
|
...(nestedDirs.length > 0 ? { nested: nestedDirs } : {}),
|
|
582
|
+
...(dirLinks.size > 0 ? { dirLinks, dirLinksIn } : {}),
|
|
458
583
|
})
|
|
459
584
|
if (changed.length === 0 || args.workspaceGlobOwners === undefined) return done()
|
|
460
|
-
for (const name of await args.workspaceGlobOwners(changed, nestedDirs)) owned.add(name)
|
|
585
|
+
for (const name of await args.workspaceGlobOwners(changed, nestedDirs, dirLinks)) owned.add(name)
|
|
461
586
|
return done()
|
|
462
587
|
}
|
|
463
588
|
|
|
589
|
+
/** What vx writes under `.vx`, which no key reads (cache/inputs.ts `ALWAYS_IGNORE`). */
|
|
590
|
+
const VX_OWN: ReadonlySet<string> = new Set([LOCKFILE_NAME, '.vx/.gitattributes', '.vx/.gitignore'])
|
|
591
|
+
|
|
464
592
|
const CONFIG_NAMES: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
|
|
593
|
+
/**
|
|
594
|
+
* The files that make a directory a project: core's configs, and the
|
|
595
|
+
* `package.json` a `discover` plugin reads (core keys every project's own).
|
|
596
|
+
* Discovery names no file it read, so these stand for the base's project set.
|
|
597
|
+
*/
|
|
598
|
+
const PROJECT_MARKERS: ReadonlySet<string> = new Set([...PROJECT_CONFIG_FILENAMES, 'package.json'])
|
|
465
599
|
|
|
466
600
|
/**
|
|
467
601
|
* The projects a NEW nested project took files from. A project's inputs
|
|
468
|
-
* stop at every project below it, so a
|
|
602
|
+
* stop at every project below it, so a marker that makes an existing
|
|
469
603
|
* directory a project re-keys the project above it — its `**` lost the
|
|
470
604
|
* directory's files — while containment maps the change to the new
|
|
471
605
|
* project alone, and `--affected` left the re-keyed parent out. "New" is
|
|
472
|
-
* judged at the base: none of the changed
|
|
473
|
-
* there. `atBase` holds each changed
|
|
606
|
+
* judged at the base: none of the changed markers in its directory was
|
|
607
|
+
* there. `atBase` holds each changed marker there.
|
|
474
608
|
*/
|
|
475
609
|
function parentsOfNewNested(
|
|
476
610
|
workspaceRoot: string,
|
|
@@ -498,6 +632,24 @@ function parentsOfNewNested(
|
|
|
498
632
|
return out
|
|
499
633
|
}
|
|
500
634
|
|
|
635
|
+
/** The first config in `atBase` the base had whose file and project are both gone now. */
|
|
636
|
+
async function removedProjectConfig(
|
|
637
|
+
workspaceRoot: string,
|
|
638
|
+
atBase: ReadonlyMap<string, Uint8Array | null>,
|
|
639
|
+
projects: readonly ProjectMeta[],
|
|
640
|
+
): Promise<string | undefined> {
|
|
641
|
+
const dirs = new Set(projects.map((p) => p.dir.normalize('NFC')))
|
|
642
|
+
for (const [rel, bytes] of atBase) {
|
|
643
|
+
// A manifest's directory may never have been a project; a config's was.
|
|
644
|
+
if (bytes === null || !CONFIG_NAMES.has(path.posix.basename(rel))) continue
|
|
645
|
+
const dir = path.resolve(workspaceRoot, path.posix.dirname(rel)).normalize('NFC')
|
|
646
|
+
if (dirs.has(dir)) continue
|
|
647
|
+
if (await Bun.file(path.join(workspaceRoot, rel)).exists()) continue
|
|
648
|
+
return rel
|
|
649
|
+
}
|
|
650
|
+
return undefined
|
|
651
|
+
}
|
|
652
|
+
|
|
501
653
|
/**
|
|
502
654
|
* Each of `files` (workspace-root-relative) as `ref` has it, in one `git
|
|
503
655
|
* cat-file --batch`: null for a file the ref does not have. `./` anchors
|
|
@@ -580,12 +732,82 @@ async function bytesOrNull(file: string): Promise<Uint8Array | null> {
|
|
|
580
732
|
return (await f.exists()) ? await f.bytes() : null
|
|
581
733
|
}
|
|
582
734
|
|
|
583
|
-
|
|
584
|
-
|
|
735
|
+
interface IndexEntry {
|
|
736
|
+
path: string
|
|
737
|
+
mode: string
|
|
738
|
+
oid: string
|
|
739
|
+
/** Assume-unchanged or skip-worktree: git does not look at it on disk. */
|
|
740
|
+
flagged: boolean
|
|
741
|
+
skipWorktree: boolean
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* The index's stage-0 entries, workspace-relative. `ls-files -v` prefixes
|
|
746
|
+
* a tag: `S` skip-worktree, a lowercase letter assume-unchanged (`s` both).
|
|
747
|
+
*/
|
|
748
|
+
async function indexEntries(workspaceRoot: string): Promise<IndexEntry[]> {
|
|
749
|
+
const out: IndexEntry[] = []
|
|
750
|
+
for (const record of await gitPaths(workspaceRoot, ['ls-files', '-s', '-v', '-z'])) {
|
|
751
|
+
const m = /^(\S) (\d+) ([0-9a-f]+) 0\t(.*)$/s.exec(record)
|
|
752
|
+
if (m === null) continue
|
|
753
|
+
const tag = m[1]!
|
|
754
|
+
out.push({
|
|
755
|
+
path: m[4]!,
|
|
756
|
+
mode: m[2]!,
|
|
757
|
+
oid: m[3]!,
|
|
758
|
+
flagged: tag === 'S' || (tag >= 'a' && tag <= 'z'),
|
|
759
|
+
skipWorktree: tag === 'S' || tag === 's',
|
|
760
|
+
})
|
|
761
|
+
}
|
|
762
|
+
return out
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* The flagged paths whose disk side moved from the index (`git diff` covers
|
|
767
|
+
* the index against the base): a file whose bytes hash to another blob, a
|
|
768
|
+
* link, or an assume-unchanged file deleted. An absent skip-worktree file
|
|
769
|
+
* is a sparse checkout's: the key leaves it out, and so does this.
|
|
770
|
+
*/
|
|
771
|
+
async function flaggedChanges(
|
|
772
|
+
workspaceRoot: string,
|
|
773
|
+
flagged: readonly string[],
|
|
774
|
+
index: readonly IndexEntry[],
|
|
775
|
+
changed: readonly string[],
|
|
776
|
+
): Promise<string[]> {
|
|
777
|
+
const byPath = new Map(index.map((e) => [e.path, e]))
|
|
778
|
+
const seen = new Set(changed)
|
|
585
779
|
const out: string[] = []
|
|
586
|
-
|
|
587
|
-
|
|
780
|
+
const hash: IndexEntry[] = []
|
|
781
|
+
for (const rel of flagged) {
|
|
782
|
+
const entry = byPath.get(rel)
|
|
783
|
+
if (entry === undefined || seen.has(rel)) continue
|
|
784
|
+
// Synchronous: a sparse checkout flags its every absent file, and one
|
|
785
|
+
// awaited stat each was 7 s per 100,000 (a loop of these, 0.15 s).
|
|
786
|
+
if (lstatSync(path.join(workspaceRoot, rel), { throwIfNoEntry: false }) === undefined) {
|
|
787
|
+
if (!entry.skipWorktree) out.push(rel)
|
|
788
|
+
} else if (entry.mode === '120000' || rel.includes('\n')) out.push(rel)
|
|
789
|
+
else hash.push(entry)
|
|
588
790
|
}
|
|
791
|
+
if (hash.length === 0) return out
|
|
792
|
+
// `--stdin-paths` hashes each file as `git add` would (its clean
|
|
793
|
+
// filters), so an unchanged one names the index's blob.
|
|
794
|
+
const proc = spawnGit(
|
|
795
|
+
['hash-object', '--stdin-paths'],
|
|
796
|
+
workspaceRoot,
|
|
797
|
+
new TextEncoder().encode(hash.map((e) => `${e.path}\n`).join('')),
|
|
798
|
+
)
|
|
799
|
+
const [stdout, stderr, exit] = await Promise.all([
|
|
800
|
+
new Response(proc.stdout).text(),
|
|
801
|
+
new Response(proc.stderr).text(),
|
|
802
|
+
proc.exited,
|
|
803
|
+
])
|
|
804
|
+
if (exit !== 0) {
|
|
805
|
+
throw new UserError(`git hash-object failed (exit ${exit}): ${stderr.trim()}`, 'VX_E_GIT')
|
|
806
|
+
}
|
|
807
|
+
const oids = stdout.split('\n')
|
|
808
|
+
hash.forEach((e, i) => {
|
|
809
|
+
if (oids[i] !== e.oid) out.push(e.path)
|
|
810
|
+
})
|
|
589
811
|
return out
|
|
590
812
|
}
|
|
591
813
|
|
|
@@ -600,6 +822,8 @@ async function linkedChanges(
|
|
|
600
822
|
workspaceRoot: string,
|
|
601
823
|
links: readonly string[],
|
|
602
824
|
changed: readonly string[],
|
|
825
|
+
/** Given each link to a directory. */
|
|
826
|
+
dirs: Set<string>,
|
|
603
827
|
): Promise<string[]> {
|
|
604
828
|
if (links.length === 0) return []
|
|
605
829
|
const realRoot = await realpath(workspaceRoot).catch(() => workspaceRoot)
|
|
@@ -614,6 +838,7 @@ async function linkedChanges(
|
|
|
614
838
|
} catch {
|
|
615
839
|
continue
|
|
616
840
|
}
|
|
841
|
+
if (dir) dirs.add(link)
|
|
617
842
|
const rel = path.relative(realRoot, real)
|
|
618
843
|
if (rel.startsWith('..') || path.isAbsolute(rel)) out.push(link)
|
|
619
844
|
else inside.push({ link, target: rel.split(path.sep).join('/'), dir })
|
|
@@ -628,7 +853,8 @@ async function linkedChanges(
|
|
|
628
853
|
'--directory',
|
|
629
854
|
'-z',
|
|
630
855
|
'--',
|
|
631
|
-
|
|
856
|
+
// A name is a path, never pathspec magic (`:x`, `*`).
|
|
857
|
+
...(named.length === inside.length ? named.map((n) => `:(literal)${n}`) : ['.']),
|
|
632
858
|
])
|
|
633
859
|
const changedSet = new Set(changed)
|
|
634
860
|
const under = (p: string, dir: string): boolean => dir === '' || p.startsWith(`${dir}/`)
|
|
@@ -643,6 +869,45 @@ async function linkedChanges(
|
|
|
643
869
|
return out
|
|
644
870
|
}
|
|
645
871
|
|
|
872
|
+
/**
|
|
873
|
+
* The changed paths a glob must match as a directory: a tracked link to
|
|
874
|
+
* one (`dirs`), and each of `maybe` (a link on either side of the diff,
|
|
875
|
+
* an untracked path) that is a link now to no file.
|
|
876
|
+
*/
|
|
877
|
+
async function changedDirLinks(
|
|
878
|
+
workspaceRoot: string,
|
|
879
|
+
changed: readonly string[],
|
|
880
|
+
dirs: ReadonlySet<string>,
|
|
881
|
+
maybe: ReadonlySet<string>,
|
|
882
|
+
): Promise<Set<string>> {
|
|
883
|
+
const out = new Set<string>()
|
|
884
|
+
await Promise.all(
|
|
885
|
+
changed.map(async (rel) => {
|
|
886
|
+
if (dirs.has(rel)) out.add(rel)
|
|
887
|
+
else if (
|
|
888
|
+
maybe.has(rel) &&
|
|
889
|
+
!rel.endsWith('/') &&
|
|
890
|
+
(await noFileLink(path.join(workspaceRoot, rel)))
|
|
891
|
+
) {
|
|
892
|
+
out.add(rel)
|
|
893
|
+
}
|
|
894
|
+
}),
|
|
895
|
+
)
|
|
896
|
+
return out
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** A link now to anything but a file, or a path that is gone (a deleted link). */
|
|
900
|
+
async function noFileLink(abs: string): Promise<boolean> {
|
|
901
|
+
try {
|
|
902
|
+
if (!(await lstat(abs)).isSymbolicLink()) return false
|
|
903
|
+
} catch {
|
|
904
|
+
return true
|
|
905
|
+
}
|
|
906
|
+
return !(await stat(abs)
|
|
907
|
+
.then((s) => s.isFile())
|
|
908
|
+
.catch(() => false))
|
|
909
|
+
}
|
|
910
|
+
|
|
646
911
|
/** Run a NUL-separated path-listing git command from the workspace root. */
|
|
647
912
|
async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]> {
|
|
648
913
|
const proc = spawnGit([...cmd], workspaceRoot)
|
|
@@ -653,68 +918,168 @@ async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]>
|
|
|
653
918
|
const stderr = await new Response(proc.stderr).text()
|
|
654
919
|
const exit = await proc.exited
|
|
655
920
|
if (exit !== 0) {
|
|
656
|
-
throw new UserError(
|
|
921
|
+
throw new UserError(
|
|
922
|
+
`git ${cmd[cmd[0] === '-c' ? 2 : 0]} failed (exit ${exit}): ${stderr.trim()}`,
|
|
923
|
+
'VX_E_GIT',
|
|
924
|
+
)
|
|
657
925
|
}
|
|
658
926
|
return stdout.split('\0').filter((s) => s.length > 0)
|
|
659
927
|
}
|
|
660
928
|
|
|
661
929
|
/**
|
|
662
|
-
* `git diff --raw -z <base>`'s paths,
|
|
663
|
-
*
|
|
930
|
+
* `git diff --raw -z <base>`'s paths, those that are a symlink on either
|
|
931
|
+
* side, and those that are a gitlink on either side: one the change
|
|
932
|
+
* removed took its files with it. Each record
|
|
664
933
|
* is `:<mode> <mode> <oid> <oid> <status>` then its path (one path:
|
|
665
934
|
* `--no-renames`).
|
|
935
|
+
*
|
|
936
|
+
* `diff.autoRefreshIndex=false`: a porcelain `diff` that meets a
|
|
937
|
+
* stat-dirty file rewrites the index under `index.lock`, and ignores
|
|
938
|
+
* `--no-optional-locks`, so a user's `git commit` beside a run failed on
|
|
939
|
+
* that lock. Without the refresh a stat-only change is listed as a
|
|
940
|
+
* modification (`statOnly` drops it).
|
|
666
941
|
*/
|
|
667
942
|
async function gitDiffPaths(
|
|
668
943
|
workspaceRoot: string,
|
|
669
944
|
base: string,
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
945
|
+
partial: Promise<boolean>,
|
|
946
|
+
): Promise<{ paths: string[]; gitlinks: string[]; links: string[] }> {
|
|
947
|
+
const diff = (...revs: string[]) =>
|
|
948
|
+
gitPaths(workspaceRoot, [
|
|
949
|
+
'-c',
|
|
950
|
+
'diff.autoRefreshIndex=false',
|
|
951
|
+
'diff',
|
|
952
|
+
'--no-renames',
|
|
953
|
+
'--ignore-submodules=none',
|
|
954
|
+
'--relative',
|
|
955
|
+
'--raw',
|
|
956
|
+
'--no-abbrev',
|
|
957
|
+
'-z',
|
|
958
|
+
'--end-of-options',
|
|
959
|
+
...revs,
|
|
960
|
+
])
|
|
961
|
+
// A partial clone lacks the base's blobs, and a stat-dirty file made
|
|
962
|
+
// `git diff <base>` read them to drop it: one fetch per file, and git
|
|
963
|
+
// 2.43 crashed (exit 139) on the batch. The base's tree against HEAD's
|
|
964
|
+
// reads no blob, HEAD's against the working tree reads only blobs a
|
|
965
|
+
// checkout has; their union over-selects only a file the working tree
|
|
966
|
+
// put back as the base had it.
|
|
967
|
+
const lists =
|
|
968
|
+
(await partial) || fetchedInto.has(workspaceRoot)
|
|
969
|
+
? await Promise.all([diff(base, 'HEAD'), diff('HEAD')])
|
|
970
|
+
: [await diff(base)]
|
|
681
971
|
const paths: string[] = []
|
|
682
972
|
const gitlinks: string[] = []
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
973
|
+
const links: string[] = []
|
|
974
|
+
const seen = new Set<string>()
|
|
975
|
+
const unread: { rel: string; oid: string; link: boolean }[] = []
|
|
976
|
+
for (const fields of lists) {
|
|
977
|
+
for (let i = 0; i + 1 < fields.length; i += 2) {
|
|
978
|
+
const rel = fields[i + 1]!
|
|
979
|
+
if (seen.has(rel)) continue
|
|
980
|
+
seen.add(rel)
|
|
981
|
+
paths.push(rel)
|
|
982
|
+
const head = fields[i]!
|
|
983
|
+
if (head.startsWith('160000 ', 1) || head.startsWith('160000 ', 8)) gitlinks.push(rel)
|
|
984
|
+
if (head.startsWith('120000 ', 1) || head.startsWith('120000 ', 8)) links.push(rel)
|
|
985
|
+
const [src, dst, oid, worktree, status] = head.slice(1).split(' ')
|
|
986
|
+
if (status === 'M' && src === dst && /^0+$/.test(worktree!) && src !== '160000')
|
|
987
|
+
unread.push({ rel, oid: oid!, link: src === '120000' })
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
if (unread.length === 0) return { paths, gitlinks, links }
|
|
991
|
+
const same = await statOnly(workspaceRoot, unread)
|
|
992
|
+
return { paths: paths.filter((p) => !same.has(p)), gitlinks, links }
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Of the paths the diff listed as modified in the working tree, those
|
|
997
|
+
* whose content is still the base's blob: the working-tree side is hashed
|
|
998
|
+
* as `git add` would (filters, a link's target), so only a stat changed.
|
|
999
|
+
*/
|
|
1000
|
+
async function statOnly(
|
|
1001
|
+
workspaceRoot: string,
|
|
1002
|
+
unread: readonly { rel: string; oid: string; link: boolean }[],
|
|
1003
|
+
): Promise<Set<string>> {
|
|
1004
|
+
const same = new Set<string>()
|
|
1005
|
+
const files: { rel: string; oid: string }[] = []
|
|
1006
|
+
for (const u of unread) {
|
|
1007
|
+
if (!u.link) {
|
|
1008
|
+
// `--stdin-paths` reads a line and unquotes a leading `"`; the
|
|
1009
|
+
// decoder replaced a byte that was not UTF-8. Such a path stays listed.
|
|
1010
|
+
if (!/[\n\uFFFD]/.test(u.rel) && !u.rel.startsWith('"')) files.push(u)
|
|
1011
|
+
continue
|
|
1012
|
+
}
|
|
1013
|
+
try {
|
|
1014
|
+
const target = await readlink(path.join(workspaceRoot, u.rel), 'buffer')
|
|
1015
|
+
const hasher = new Bun.CryptoHasher(u.oid.length === 64 ? 'sha256' : 'sha1')
|
|
1016
|
+
hasher.update(`blob ${target.length}\0`)
|
|
1017
|
+
hasher.update(target)
|
|
1018
|
+
if (hasher.digest('hex') === u.oid) same.add(u.rel)
|
|
1019
|
+
} catch {
|
|
1020
|
+
// not a link any more: a change
|
|
1021
|
+
}
|
|
688
1022
|
}
|
|
689
|
-
|
|
1023
|
+
if (files.length === 0) return same
|
|
1024
|
+
const proc = spawnGit(
|
|
1025
|
+
['hash-object', '--stdin-paths'],
|
|
1026
|
+
workspaceRoot,
|
|
1027
|
+
new TextEncoder().encode(files.map((f) => `${f.rel}\n`).join('')),
|
|
1028
|
+
)
|
|
1029
|
+
const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
|
|
1030
|
+
if (exit !== 0) return same
|
|
1031
|
+
const oids = out.split('\n')
|
|
1032
|
+
files.forEach((f, i) => {
|
|
1033
|
+
if (oids[i] === f.oid) same.add(f.rel)
|
|
1034
|
+
})
|
|
1035
|
+
return same
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/** A remote that serves missing objects on demand: `--filter` on a clone or a fetch. */
|
|
1039
|
+
async function isPartialClone(workspaceRoot: string): Promise<boolean> {
|
|
1040
|
+
const proc = spawnGit(
|
|
1041
|
+
['config', '--get-regexp', String.raw`^(remote\..*\.promisor|extensions\.partialclone)$`],
|
|
1042
|
+
workspaceRoot,
|
|
1043
|
+
)
|
|
1044
|
+
const [out] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
|
|
1045
|
+
return out.split('\n').some((l) => l.startsWith('extensions.') || l.endsWith(' true'))
|
|
690
1046
|
}
|
|
691
1047
|
|
|
692
1048
|
/**
|
|
693
1049
|
* The base a bare `--affected` compares with: the merge base of HEAD and
|
|
694
1050
|
* the base branch (`origin/<baseBranch>`, or the local branch in a clone
|
|
695
1051
|
* with no remote; its tip when they share no history). On the base branch
|
|
696
|
-
* itself there is none (
|
|
1052
|
+
* itself there is none (2026-10-17): what a push to main changed
|
|
697
1053
|
* is the last good commit's to say (`--affected=<sha>`), and `HEAD~1`
|
|
698
1054
|
* guessed it wrong after any multi-commit push.
|
|
699
1055
|
*/
|
|
700
1056
|
export async function defaultAffectedBase(
|
|
701
1057
|
workspaceRoot: string,
|
|
702
1058
|
baseBranch: string | undefined,
|
|
1059
|
+
/** The verb's way to take every project instead: `vx show` has no `--all`. */
|
|
1060
|
+
instead = 'run --all (the cache skips what did not change)',
|
|
703
1061
|
): Promise<string> {
|
|
704
|
-
const found =
|
|
1062
|
+
const found =
|
|
1063
|
+
(await baseBranchRef(workspaceRoot, baseBranch)) ??
|
|
1064
|
+
(await fetchBaseBranch(workspaceRoot, baseBranch))
|
|
705
1065
|
if (found === undefined) {
|
|
706
1066
|
const noHistory = noHistoryRefusal(workspaceRoot)
|
|
707
1067
|
if (noHistory !== undefined) throw noHistory
|
|
708
1068
|
throw new UserError(
|
|
709
1069
|
`--affected has no base here: ${baseBranch === undefined ? 'no origin/HEAD, main or master' : `no ${baseBranch} or origin/${baseBranch}`} ` +
|
|
710
|
-
'to compare with
|
|
711
|
-
'set `baseBranch`, or name the base: --affected=origin/main',
|
|
1070
|
+
'to compare with: set `baseBranch`, or name the base: --affected=origin/main',
|
|
712
1071
|
'VX_E_AFFECTED_BASE',
|
|
713
1072
|
)
|
|
714
1073
|
}
|
|
1074
|
+
// A detached HEAD (a CI checkout of a pushed commit) is on the base
|
|
1075
|
+
// branch when the branch holds it: the base was HEAD, or a commit after
|
|
1076
|
+
// it, and nothing ran, green.
|
|
1077
|
+
if (!found.onIt && found.detached && (await isAncestor(workspaceRoot, 'HEAD', found.ref))) {
|
|
1078
|
+
found.onIt = true
|
|
1079
|
+
}
|
|
715
1080
|
if (found.onIt) {
|
|
716
1081
|
throw new UserError(
|
|
717
|
-
`--affected has no default on the base branch (${found.branch}): name the last good commit, --affected=<sha>`,
|
|
1082
|
+
`--affected has no default on the base branch (${found.branch}): ${instead}, or name the last good commit, --affected=<sha>`,
|
|
718
1083
|
'VX_E_AFFECTED_BASE',
|
|
719
1084
|
)
|
|
720
1085
|
}
|
|
@@ -749,7 +1114,7 @@ function noHistoryRefusal(workspaceRoot: string): UserError | undefined {
|
|
|
749
1114
|
async function baseBranchRef(
|
|
750
1115
|
workspaceRoot: string,
|
|
751
1116
|
configured: string | undefined,
|
|
752
|
-
): Promise<{ ref: string; branch: string; onIt: boolean } | undefined> {
|
|
1117
|
+
): Promise<{ ref: string; branch: string; onIt: boolean; detached: boolean } | undefined> {
|
|
753
1118
|
const text = async (args: string[]): Promise<string> => {
|
|
754
1119
|
const proc = spawnGit(args, workspaceRoot)
|
|
755
1120
|
const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
|
|
@@ -772,7 +1137,12 @@ async function baseBranchRef(
|
|
|
772
1137
|
text(['symbolic-ref', '--short', '-q', 'HEAD']),
|
|
773
1138
|
])
|
|
774
1139
|
const have = new Set(refs.split('\n'))
|
|
775
|
-
const at = (branch: string, ref: string) => ({
|
|
1140
|
+
const at = (branch: string, ref: string) => ({
|
|
1141
|
+
ref,
|
|
1142
|
+
branch,
|
|
1143
|
+
onIt: head === branch,
|
|
1144
|
+
detached: head === '',
|
|
1145
|
+
})
|
|
776
1146
|
if (remoteHead.startsWith('origin/') && have.has(remoteHead)) {
|
|
777
1147
|
return at(remoteHead.slice('origin/'.length), remoteHead)
|
|
778
1148
|
}
|
|
@@ -782,13 +1152,15 @@ async function baseBranchRef(
|
|
|
782
1152
|
}
|
|
783
1153
|
|
|
784
1154
|
/**
|
|
785
|
-
*
|
|
786
|
-
*
|
|
787
|
-
*
|
|
1155
|
+
* The base `ref` names (`<base>...HEAD` reads as `<base>`, as
|
|
1156
|
+
* `affectedChanges` reads it) when it is the commit HEAD is on, else
|
|
1157
|
+
* undefined. A base that IS HEAD can never mark anything affected — the
|
|
1158
|
+
* shape of a single-branch clone whose `origin/HEAD` is the branch under test.
|
|
788
1159
|
*/
|
|
789
|
-
export function
|
|
1160
|
+
export function headBase(workspaceRoot: string, ref: string): string | undefined {
|
|
790
1161
|
const head = revParse(workspaceRoot, 'HEAD')
|
|
791
|
-
|
|
1162
|
+
const base = HEAD_RANGE.exec(ref)?.[1] ?? ref
|
|
1163
|
+
return head !== undefined && head === revParse(workspaceRoot, base) ? base : undefined
|
|
792
1164
|
}
|
|
793
1165
|
|
|
794
1166
|
/** The commit `ref` names, or undefined when it does not resolve here. */
|
|
@@ -826,15 +1198,37 @@ async function mergeBase(workspaceRoot: string, ref: string): Promise<string> {
|
|
|
826
1198
|
// which the default base's search already resolved (`HEAD~1`).
|
|
827
1199
|
const known = HEAD_ANCESTOR.test(ref) ? resolvedRefs.get(`${workspaceRoot}\0${ref}`) : undefined
|
|
828
1200
|
if (known !== undefined) return known
|
|
829
|
-
const
|
|
830
|
-
|
|
831
|
-
const
|
|
832
|
-
if (
|
|
1201
|
+
const sha = await gitMergeBase(workspaceRoot, ref)
|
|
1202
|
+
if (sha !== undefined) return sha
|
|
1203
|
+
const reached = await reachMergeBase(workspaceRoot, ref)
|
|
1204
|
+
if (reached !== undefined) return reached
|
|
1205
|
+
await verifyTreeRef(workspaceRoot, ref)
|
|
1206
|
+
// The ref itself is the base only for histories that share nothing. A
|
|
1207
|
+
// shallow clone may just lack the commits between them (origin gone, a
|
|
1208
|
+
// fetch failed, past MAX_DEEPEN), and its tip selected what the base
|
|
1209
|
+
// branch changed.
|
|
1210
|
+
if (
|
|
1211
|
+
revParse(workspaceRoot, ref) !== undefined &&
|
|
1212
|
+
revParse(workspaceRoot, 'HEAD') !== undefined &&
|
|
1213
|
+
isShallow(workspaceRoot)
|
|
1214
|
+
) {
|
|
1215
|
+
throw new UserError(
|
|
1216
|
+
`git ref "${ref}" has no merge base with HEAD in this shallow clone, and origin did not ` +
|
|
1217
|
+
'give the history between them: fetch it (`git fetch --unshallow origin`).',
|
|
1218
|
+
'VX_E_AFFECTED_BASE',
|
|
1219
|
+
)
|
|
1220
|
+
}
|
|
1221
|
+
return ref
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* `verifyRef`, then refuse `<rev>:<path>`: it names the tree (or blob) at
|
|
1226
|
+
* <path>, and diffed against the working tree its paths miss the <path>/
|
|
1227
|
+
* prefix, so `develop:pkgs` selected projects nothing had changed, green.
|
|
1228
|
+
* A root tree (`develop^{tree}`, the empty tree) diffs right and stays a base.
|
|
1229
|
+
*/
|
|
1230
|
+
async function verifyTreeRef(workspaceRoot: string, ref: string): Promise<void> {
|
|
833
1231
|
await verifyRef(workspaceRoot, ref)
|
|
834
|
-
// `<rev>:<path>` names the tree (or blob) at <path>: diffed against the
|
|
835
|
-
// working tree, its paths miss the <path>/ prefix, and `develop:pkgs`
|
|
836
|
-
// selected projects nothing had changed, green. A root tree
|
|
837
|
-
// (`develop^{tree}`, the empty tree) diffs right and stays a base.
|
|
838
1232
|
const sub = /^([^:]*):(?!\/)(.+)$/s.exec(ref)
|
|
839
1233
|
if (sub !== null) {
|
|
840
1234
|
throw new UserError(
|
|
@@ -843,9 +1237,201 @@ async function mergeBase(workspaceRoot: string, ref: string): Promise<string> {
|
|
|
843
1237
|
'VX_E_AFFECTED_BASE',
|
|
844
1238
|
)
|
|
845
1239
|
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* The merge base of `ref` and HEAD, or undefined when there is none. A
|
|
1244
|
+
* shallow clone fetches what it needs from origin (`reachMergeBase`):
|
|
1245
|
+
* a plugin reading the base branch's history (shared timings) asks this,
|
|
1246
|
+
* not `git merge-base`, so a one-commit checkout serves it too.
|
|
1247
|
+
*/
|
|
1248
|
+
export async function mergeBaseWith(
|
|
1249
|
+
workspaceRoot: string,
|
|
1250
|
+
ref: string,
|
|
1251
|
+
): Promise<string | undefined> {
|
|
1252
|
+
return (await gitMergeBase(workspaceRoot, ref)) ?? (await reachMergeBase(workspaceRoot, ref))
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/** Whether `commit` is `ref` or one of its ancestors. */
|
|
1256
|
+
async function isAncestor(workspaceRoot: string, commit: string, ref: string): Promise<boolean> {
|
|
1257
|
+
const proc = spawnGit(
|
|
1258
|
+
['merge-base', '--is-ancestor', '--end-of-options', commit, ref],
|
|
1259
|
+
workspaceRoot,
|
|
1260
|
+
)
|
|
1261
|
+
return (await proc.exited) === 0
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
async function gitMergeBase(workspaceRoot: string, ref: string): Promise<string | undefined> {
|
|
1265
|
+
const proc = spawnGit(['merge-base', '--end-of-options', ref, 'HEAD'], workspaceRoot)
|
|
1266
|
+
const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
|
|
1267
|
+
const sha = out.trim()
|
|
1268
|
+
return exit === 0 && sha.length > 0 ? sha : undefined
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* A shallow clone (CI's checkout fetches one commit) lacks the base, or
|
|
1273
|
+
* the history between it and HEAD: this fetches from origin what the
|
|
1274
|
+
* merge base needs and nothing more (2026-10-24: "we only need
|
|
1275
|
+
* current commit and branch out commit"). The base's tip at depth 1, then
|
|
1276
|
+
* both tips deepened 1, 2, 4… commits until git finds the merge base;
|
|
1277
|
+
* commits and trees only, as `--name-only` reads no blob. A pull request's
|
|
1278
|
+
* merge checkout has its base as HEAD's parent, so it needs one fetch.
|
|
1279
|
+
* Undefined when the clone is whole, has no origin, or the fetch failed.
|
|
1280
|
+
*/
|
|
1281
|
+
async function reachMergeBase(workspaceRoot: string, ref: string): Promise<string | undefined> {
|
|
1282
|
+
if (!isShallow(workspaceRoot)) return undefined
|
|
1283
|
+
const head = revParse(workspaceRoot, 'HEAD')
|
|
1284
|
+
if (head === undefined) return undefined
|
|
1285
|
+
const ancestor = HEAD_ANCESTOR.test(ref)
|
|
1286
|
+
let tip = ancestor ? undefined : revParse(workspaceRoot, ref)
|
|
1287
|
+
if (tip === undefined && !ancestor) {
|
|
1288
|
+
tip = await fetchTip(workspaceRoot, ref)
|
|
1289
|
+
if (tip === undefined) return undefined
|
|
1290
|
+
}
|
|
1291
|
+
if (tip !== undefined && parentsOf(workspaceRoot, head).includes(tip)) return tip
|
|
1292
|
+
for (let depth = 1; depth <= MAX_DEEPEN; depth *= 2) {
|
|
1293
|
+
const tips = tip === undefined ? [head] : [head, tip]
|
|
1294
|
+
if (!(await fetchOrigin(workspaceRoot, [`--deepen=${depth}`, ...tips]))) return undefined
|
|
1295
|
+
// `HEAD~2` is its own merge base with HEAD, once the history has it.
|
|
1296
|
+
if (ancestor) {
|
|
1297
|
+
const at = revParse(workspaceRoot, ref)
|
|
1298
|
+
if (at !== undefined) return at
|
|
1299
|
+
} else {
|
|
1300
|
+
const found = await gitMergeBase(workspaceRoot, tip!)
|
|
1301
|
+
if (found !== undefined) return found
|
|
1302
|
+
}
|
|
1303
|
+
if (!isShallow(workspaceRoot)) return undefined
|
|
1304
|
+
}
|
|
1305
|
+
return undefined
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/** `ref`'s commit fetched from origin at depth 1, or undefined when origin cannot name it. */
|
|
1309
|
+
async function fetchTip(workspaceRoot: string, ref: string): Promise<string | undefined> {
|
|
1310
|
+
const spec = originSpec(ref)
|
|
1311
|
+
if (spec === undefined || !(await fetchOrigin(workspaceRoot, ['--depth=1', spec]))) {
|
|
1312
|
+
return undefined
|
|
1313
|
+
}
|
|
1314
|
+
return revParse(workspaceRoot, ref)
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/**
|
|
1318
|
+
* The base of a two-dot `<ref>..HEAD`: `ref` itself, which a shallow clone
|
|
1319
|
+
* fetches as the three-dot form fetches its tip (CI's depth-1 checkout
|
|
1320
|
+
* refused `origin/main..HEAD` while `origin/main...HEAD` worked). HEAD's
|
|
1321
|
+
* own ancestor is its merge base with HEAD, reached by deepening.
|
|
1322
|
+
*/
|
|
1323
|
+
async function rangeBase(workspaceRoot: string, ref: string): Promise<string> {
|
|
1324
|
+
if (HEAD_ANCESTOR.test(ref)) return mergeBase(workspaceRoot, ref)
|
|
1325
|
+
if (revParse(workspaceRoot, ref) === undefined && isShallow(workspaceRoot)) {
|
|
1326
|
+
await fetchTip(workspaceRoot, ref)
|
|
1327
|
+
}
|
|
1328
|
+
await verifyTreeRef(workspaceRoot, ref)
|
|
846
1329
|
return ref
|
|
847
1330
|
}
|
|
848
1331
|
|
|
1332
|
+
/** The clones `fetchOrigin` fetched into, so made partial: `isPartialClone` may have answered first. */
|
|
1333
|
+
const fetchedInto = new Set<string>()
|
|
1334
|
+
|
|
1335
|
+
/** Deepening stops here (2,047 commits on each side): past it, the base is the user's to fetch. */
|
|
1336
|
+
const MAX_DEEPEN = 1024
|
|
1337
|
+
|
|
1338
|
+
function isShallow(workspaceRoot: string): boolean {
|
|
1339
|
+
const proc = spawnGitSync(['rev-parse', '--is-shallow-repository'], workspaceRoot, 'ignore')
|
|
1340
|
+
return new TextDecoder().decode(proc.stdout).trim() === 'true'
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** The parents a commit names, even where a shallow clone cut them off. */
|
|
1344
|
+
function parentsOf(workspaceRoot: string, sha: string): string[] {
|
|
1345
|
+
const proc = spawnGitSync(
|
|
1346
|
+
['cat-file', '--end-of-options', 'commit', sha],
|
|
1347
|
+
workspaceRoot,
|
|
1348
|
+
'ignore',
|
|
1349
|
+
)
|
|
1350
|
+
const out: string[] = []
|
|
1351
|
+
for (const line of new TextDecoder().decode(proc.stdout).split('\n')) {
|
|
1352
|
+
if (line === '') break
|
|
1353
|
+
if (line.startsWith('parent ')) out.push(line.slice('parent '.length))
|
|
1354
|
+
}
|
|
1355
|
+
return out
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/**
|
|
1359
|
+
* What to ask origin for a base: a commit id as it is (GitHub serves any
|
|
1360
|
+
* commit a ref reaches), `origin/<branch>` as that branch into its
|
|
1361
|
+
* remote-tracking ref, any other plain name as origin's tag of that name
|
|
1362
|
+
* (a release base, `v1`; `--no-tags` fetched none). An expression (`v1~2`)
|
|
1363
|
+
* is a name only this clone knows.
|
|
1364
|
+
*/
|
|
1365
|
+
function originSpec(ref: string): string | undefined {
|
|
1366
|
+
if (/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(ref)) return ref
|
|
1367
|
+
const branch = /^(?:refs\/remotes\/)?origin\/(.+)$/s.exec(ref)?.[1]
|
|
1368
|
+
if (branch !== undefined) {
|
|
1369
|
+
return branch === 'HEAD' ? undefined : `+refs/heads/${branch}:refs/remotes/origin/${branch}`
|
|
1370
|
+
}
|
|
1371
|
+
const tag = /^(?:refs\/tags\/)?([^\s~^:?*[\\@{]+)$/.exec(ref)?.[1]
|
|
1372
|
+
if (tag === undefined || tag === 'HEAD' || tag.startsWith('refs/')) return undefined
|
|
1373
|
+
return `+refs/tags/${tag}:refs/tags/${tag}`
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* A shallow clone with no base branch at all (a CI checkout of one
|
|
1378
|
+
* commit): fetch it from origin at depth 1, the configured one or
|
|
1379
|
+
* origin's default.
|
|
1380
|
+
*/
|
|
1381
|
+
async function fetchBaseBranch(
|
|
1382
|
+
workspaceRoot: string,
|
|
1383
|
+
configured: string | undefined,
|
|
1384
|
+
): Promise<{ ref: string; branch: string; onIt: boolean; detached: boolean } | undefined> {
|
|
1385
|
+
if (!isShallow(workspaceRoot)) return undefined
|
|
1386
|
+
const branch = configured ?? (await originDefaultBranch(workspaceRoot))
|
|
1387
|
+
if (branch === undefined) return undefined
|
|
1388
|
+
const spec = `+refs/heads/${branch}:refs/remotes/origin/${branch}`
|
|
1389
|
+
if (!(await fetchOrigin(workspaceRoot, ['--depth=1', spec]))) return undefined
|
|
1390
|
+
const proc = spawnGitSync(['symbolic-ref', '--short', '-q', 'HEAD'], workspaceRoot, 'ignore')
|
|
1391
|
+
const head = new TextDecoder().decode(proc.stdout).trim()
|
|
1392
|
+
return { ref: `origin/${branch}`, branch, onIt: head === branch, detached: head === '' }
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/** The branch origin's HEAD names, asked of origin (a one-commit checkout has no `origin/HEAD`). */
|
|
1396
|
+
async function originDefaultBranch(workspaceRoot: string): Promise<string | undefined> {
|
|
1397
|
+
const proc = spawnGit(['ls-remote', '--symref', 'origin', 'HEAD'], workspaceRoot)
|
|
1398
|
+
const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
|
|
1399
|
+
if (exit !== 0) return undefined
|
|
1400
|
+
return /^ref: refs\/heads\/(\S+)\tHEAD$/m.exec(out)?.[1]
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/**
|
|
1404
|
+
* `git fetch` from origin into a shallow clone, without blobs: the diff
|
|
1405
|
+
* names paths, and the lockfile a claim reads at the base is fetched on
|
|
1406
|
+
* demand. Never prompts for credentials; false when it failed.
|
|
1407
|
+
*/
|
|
1408
|
+
async function fetchOrigin(workspaceRoot: string, args: string[]): Promise<boolean> {
|
|
1409
|
+
try {
|
|
1410
|
+
const proc = Bun.spawn({
|
|
1411
|
+
cmd: [
|
|
1412
|
+
executablePath('git'),
|
|
1413
|
+
'fetch',
|
|
1414
|
+
'--quiet',
|
|
1415
|
+
'--no-tags',
|
|
1416
|
+
'--no-recurse-submodules',
|
|
1417
|
+
'--filter=blob:none',
|
|
1418
|
+
'origin',
|
|
1419
|
+
...args,
|
|
1420
|
+
],
|
|
1421
|
+
env: { ...gitEnv(), GIT_TERMINAL_PROMPT: '0' },
|
|
1422
|
+
cwd: workspaceRoot,
|
|
1423
|
+
stdin: 'ignore',
|
|
1424
|
+
stdout: 'ignore',
|
|
1425
|
+
stderr: 'ignore',
|
|
1426
|
+
})
|
|
1427
|
+
fetchedInto.add(workspaceRoot)
|
|
1428
|
+
return (await proc.exited) === 0
|
|
1429
|
+
} catch (err) {
|
|
1430
|
+
if (isExecutableMissing(err)) throw gitSpawnRefusal(workspaceRoot)
|
|
1431
|
+
throw err
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
849
1435
|
async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
|
|
850
1436
|
if (resolvedRefs.has(`${workspaceRoot}\0${ref}`)) return
|
|
851
1437
|
const proc = spawnGitSync(
|
|
@@ -863,20 +1449,37 @@ async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
|
|
|
863
1449
|
if (proc.exitCode !== 1) {
|
|
864
1450
|
const noHistory = noHistoryRefusal(workspaceRoot)
|
|
865
1451
|
if (noHistory !== undefined) throw noHistory
|
|
1452
|
+
// `--quiet` also silences a ref git refused in its own words (a reflog
|
|
1453
|
+
// entry the log lacks, `main@{9}`: exit 128): ask again to say why.
|
|
1454
|
+
if (stderr.length === 0) {
|
|
1455
|
+
const loud = spawnGitSync(
|
|
1456
|
+
['rev-parse', '--verify', '--end-of-options', ref],
|
|
1457
|
+
workspaceRoot,
|
|
1458
|
+
'pipe',
|
|
1459
|
+
)
|
|
1460
|
+
const why = new TextDecoder()
|
|
1461
|
+
.decode(loud.stderr)
|
|
1462
|
+
.trim()
|
|
1463
|
+
.replace(/^fatal: /, '')
|
|
1464
|
+
if (loud.exitCode !== 0 && why.length > 0) {
|
|
1465
|
+
throw new UserError(
|
|
1466
|
+
`git ref "${ref}" did not resolve: ${why.replace(/\.?$/, '.')}`,
|
|
1467
|
+
'VX_E_AFFECTED_BASE',
|
|
1468
|
+
)
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
866
1471
|
throw new UserError(
|
|
867
1472
|
`git rev-parse failed (exit ${proc.exitCode}) in ${workspaceRoot}` +
|
|
868
1473
|
(stderr.length > 0 ? `: ${stderr}` : ''),
|
|
869
1474
|
'VX_E_GIT',
|
|
870
1475
|
)
|
|
871
1476
|
}
|
|
872
|
-
// A shallow clone
|
|
873
|
-
//
|
|
874
|
-
const shallow = spawnGitSync(['rev-parse', '--is-shallow-repository'], workspaceRoot, 'pipe')
|
|
875
|
-
const isShallow = new TextDecoder().decode(shallow.stdout).trim() === 'true'
|
|
1477
|
+
// A shallow clone whose base origin could not give (`reachMergeBase`):
|
|
1478
|
+
// no origin, no network, a name only another clone has.
|
|
876
1479
|
throw new UserError(
|
|
877
1480
|
`git ref "${ref}" did not resolve. Pass a branch or commit you have locally.` +
|
|
878
|
-
(isShallow
|
|
879
|
-
? ' This clone is shallow: fetch the
|
|
1481
|
+
(isShallow(workspaceRoot)
|
|
1482
|
+
? ' This clone is shallow and origin did not give it: fetch the base (`git fetch --depth=1 origin <base>`).'
|
|
880
1483
|
: ''),
|
|
881
1484
|
'VX_E_AFFECTED_BASE',
|
|
882
1485
|
)
|
|
@@ -890,7 +1493,7 @@ function projectsContaining(
|
|
|
890
1493
|
projects: readonly ProjectMeta[],
|
|
891
1494
|
realDirs: ReadonlyMap<string, string>,
|
|
892
1495
|
/** Told each owned path relative to its project, and each nested repository's projects. */
|
|
893
|
-
on?: { path(name: string, rel: string): void; repo(name: string): void },
|
|
1496
|
+
on?: { path(name: string, rel: string, wsRel: string): void; repo(name: string): void },
|
|
894
1497
|
): Set<string> {
|
|
895
1498
|
// Index projects by their (canonical) dir, then for each changed path walk
|
|
896
1499
|
// its ancestor dirs bottom-up until one is a project dir. The FIRST hit is
|
|
@@ -924,7 +1527,7 @@ function projectsContaining(
|
|
|
924
1527
|
const name = dirToName.get(dir)
|
|
925
1528
|
if (name !== undefined) {
|
|
926
1529
|
owned.add(name)
|
|
927
|
-
on?.path(name, relPosix(dir, path.resolve(workspaceRoot, rel).normalize('NFC')))
|
|
1530
|
+
on?.path(name, relPosix(dir, path.resolve(workspaceRoot, rel).normalize('NFC')), rel)
|
|
928
1531
|
// Its files are the project's inputs, and none of them is this
|
|
929
1532
|
// path: a task's globs cannot say whether they reach inside.
|
|
930
1533
|
if (repo) on?.repo(name)
|