@vzn/vx 0.0.651 → 0.0.653
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 +3 -4
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +15 -20
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +37 -3
- package/src/cache/archive.ts +197 -32
- package/src/cache/cache.ts +882 -230
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +151 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +12 -1
- package/src/cache/inputs.ts +616 -135
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +29 -16
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/lockfile-memo.ts +2 -1
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +94 -25
- package/src/cache/schema.ts +46 -10
- package/src/cli/cache.ts +149 -36
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +27 -4
- package/src/cli/index.ts +62 -7
- package/src/cli/info.ts +5 -4
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +218 -43
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +36 -45
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +70 -28
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +73 -9
- package/src/cli/watch-set.ts +10 -8
- package/src/cli/watch.ts +190 -33
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +56 -30
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +116 -16
- package/src/exec/index.ts +1 -0
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +446 -64
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/definition.ts +16 -3
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +176 -39
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +347 -160
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +39 -6
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +193 -73
- package/src/index.ts +6 -3
- package/src/orchestrator/admission.ts +14 -10
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +42 -13
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +8 -10
- package/src/orchestrator/excluded-keys.ts +15 -3
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +612 -138
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +141 -93
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +53 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +60 -61
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +32 -16
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +82 -16
- package/src/orchestrator/plan.ts +39 -17
- package/src/orchestrator/plugin-host.ts +238 -110
- package/src/orchestrator/plugin.ts +59 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +101 -23
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +95 -74
- package/src/orchestrator/run-report.ts +3 -2
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +531 -122
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +314 -44
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +29 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +34 -27
- package/src/orchestrator/task-artifacts.ts +16 -3
- package/src/orchestrator/task-hash.ts +191 -34
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +85 -13
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +23 -6
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +179 -162
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +108 -31
- package/src/report/plain-output.ts +15 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +61 -18
- package/src/settings.ts +50 -14
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +142 -6
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +51 -7
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +18 -1
- package/src/util/size.ts +12 -2
- package/src/util/tail.ts +4 -1
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +192 -86
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +18 -2
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +58 -31
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
|
@@ -11,8 +11,10 @@ import { lstat, readlink, realpath, stat } from 'node:fs/promises'
|
|
|
11
11
|
import path from 'node:path'
|
|
12
12
|
import {
|
|
13
13
|
executablePath,
|
|
14
|
+
gitCaseArgs,
|
|
14
15
|
gitEnv,
|
|
15
16
|
gitSpawnRefusal,
|
|
17
|
+
ignoreCaseLies,
|
|
16
18
|
isExecutableMissing,
|
|
17
19
|
isInstalledPath,
|
|
18
20
|
notAWorkTree,
|
|
@@ -31,7 +33,7 @@ import {
|
|
|
31
33
|
import { configImportOwners, realpathOr } from './config-imports.js'
|
|
32
34
|
import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
|
|
33
35
|
import { bunPatchFiles } from './fingerprint.js'
|
|
34
|
-
import { PROJECT_CONFIG_FILENAMES, REVIEW_FILE, type ProjectMeta } from './workspace.js'
|
|
36
|
+
import { inVxDir, PROJECT_CONFIG_FILENAMES, REVIEW_FILE, type ProjectMeta } from './workspace.js'
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
39
|
* Every git call here goes through these two: a git that is not on PATH
|
|
@@ -111,6 +113,8 @@ export interface AffectedArgs {
|
|
|
111
113
|
changedPaths: readonly string[],
|
|
112
114
|
nested: readonly string[],
|
|
113
115
|
dirLinks: ReadonlySet<string>,
|
|
116
|
+
/** `AffectedChanges.untracked`. */
|
|
117
|
+
untracked: ReadonlySet<string>,
|
|
114
118
|
) => Promise<Iterable<string>>
|
|
115
119
|
/**
|
|
116
120
|
* The fingerprint files a plugin claims (`VxPlugin.fingerprint`) and its
|
|
@@ -205,6 +209,8 @@ export interface AffectedChanges {
|
|
|
205
209
|
whole: ReadonlySet<string>
|
|
206
210
|
/** A whole project → the workspace-wide file that made it so (a workspace config, a claimed root file). */
|
|
207
211
|
wholeBy?: ReadonlyMap<string, string>
|
|
212
|
+
/** `wholeBy`'s file reached every project (the workspace config), not only those a claim named. */
|
|
213
|
+
wide?: boolean
|
|
208
214
|
/**
|
|
209
215
|
* The changed paths that are nested repositories (a submodule, an
|
|
210
216
|
* embedded repository), workspace-relative: each stands for every file
|
|
@@ -218,6 +224,14 @@ export interface AffectedChanges {
|
|
|
218
224
|
dirLinks?: ReadonlySet<string>
|
|
219
225
|
/** Project → its `dirLinks`, relative to the project's directory. */
|
|
220
226
|
dirLinksIn?: ReadonlyMap<string, ReadonlySet<string>>
|
|
227
|
+
/**
|
|
228
|
+
* Project → its changed paths git tracks neither now nor at the base,
|
|
229
|
+
* relative to the project's directory: one under a task's own outputs
|
|
230
|
+
* is no input of it.
|
|
231
|
+
*/
|
|
232
|
+
untrackedIn?: ReadonlyMap<string, ReadonlySet<string>>
|
|
233
|
+
/** Those changed paths, workspace-relative: `declaresInput`'s `untracked`. */
|
|
234
|
+
untracked?: ReadonlySet<string>
|
|
221
235
|
}
|
|
222
236
|
|
|
223
237
|
/** `<base>...HEAD` / `<base>..HEAD`: the base alone, read as `affectedChanges` reads it. */
|
|
@@ -275,7 +289,19 @@ export async function affectedChanges(
|
|
|
275
289
|
// itself is the base, as before. Turbo's two-dot range `A..HEAD` is the
|
|
276
290
|
// one form that diffs from A itself.
|
|
277
291
|
// Asked beside the base, not after it: the diff's shape waits on it.
|
|
278
|
-
const
|
|
292
|
+
const config = gitConfigFacts(args.workspaceRoot).catch(() => ({
|
|
293
|
+
partial: false,
|
|
294
|
+
ignoreCase: false,
|
|
295
|
+
autocrlf: false,
|
|
296
|
+
}))
|
|
297
|
+
const partial = config.then((c) => c.partial)
|
|
298
|
+
const autocrlf = config.then((c) => c.autocrlf)
|
|
299
|
+
// `core.ignorecase=true` on a disk that tells case apart hid a new
|
|
300
|
+
// `a.txt` beside the tracked `A.txt` from the untracked listings.
|
|
301
|
+
const caseArgs = config.then((c) => {
|
|
302
|
+
if (c.ignoreCase) ignoreCaseLies(args.workspaceRoot, args.workspaceRoot)
|
|
303
|
+
return gitCaseArgs(args.workspaceRoot)
|
|
304
|
+
})
|
|
279
305
|
const base = fromMergeBase
|
|
280
306
|
? await mergeBase(args.workspaceRoot, since)
|
|
281
307
|
: await rangeBase(args.workspaceRoot, since)
|
|
@@ -308,14 +334,20 @@ export async function affectedChanges(
|
|
|
308
334
|
//
|
|
309
335
|
// `--raw` for each path's mode: a gitlink (160000) is a nested
|
|
310
336
|
// repository, one path standing for every file in it (`nested` below).
|
|
311
|
-
gitDiffPaths(args.workspaceRoot, base, partial),
|
|
337
|
+
gitDiffPaths(args.workspaceRoot, base, partial, autocrlf),
|
|
312
338
|
// `git diff` never reports untracked-but-not-ignored files, but input
|
|
313
339
|
// enumeration does (`git ls-files --cached --others --exclude-standard`),
|
|
314
340
|
// so a brand-new source file changes a task's cache key. Union it in or
|
|
315
341
|
// `--affected` skips a package that genuinely has new work.
|
|
316
342
|
(async () =>
|
|
317
343
|
(await args.untracked?.().catch(() => null)) ??
|
|
318
|
-
gitPaths(args.workspaceRoot, [
|
|
344
|
+
gitPaths(args.workspaceRoot, [
|
|
345
|
+
...(await caseArgs),
|
|
346
|
+
'ls-files',
|
|
347
|
+
'--others',
|
|
348
|
+
'--exclude-standard',
|
|
349
|
+
'-z',
|
|
350
|
+
]))(),
|
|
319
351
|
])
|
|
320
352
|
|
|
321
353
|
const projects = await args.projects
|
|
@@ -328,16 +360,19 @@ export async function affectedChanges(
|
|
|
328
360
|
// enumeration drops too (`isInstalledPath`); a tracked one is a source.
|
|
329
361
|
// So is each project's `vx.lock.json`, the review copy `vx lock` writes
|
|
330
362
|
// beside its config and nothing reads: a re-lock rewriting them all
|
|
331
|
-
// selected every project while no key moved.
|
|
363
|
+
// selected every project while no key moved. The temp a killed write of
|
|
364
|
+
// any of these left beside it is vx's too.
|
|
332
365
|
const reviewCopies = new Set(projects.map((p) => path.join(p.dir, REVIEW_FILE)))
|
|
333
366
|
const rootDir = path.resolve(args.workspaceRoot)
|
|
334
|
-
const changed = [...diffed.paths, ...untracked.filter((s) => !isInstalledPath(s))].filter(
|
|
335
|
-
(
|
|
336
|
-
|
|
367
|
+
const changed = [...diffed.paths, ...untracked.filter((s) => !isInstalledPath(s))].filter((s) => {
|
|
368
|
+
const own = s.replace(WRITE_TEMP, '')
|
|
369
|
+
return (
|
|
370
|
+
!VX_OWN.has(own) &&
|
|
337
371
|
!s.startsWith('.vx/cache/') &&
|
|
338
372
|
!s.startsWith('.vx/tmp/') &&
|
|
339
|
-
(path.posix.basename(
|
|
340
|
-
|
|
373
|
+
(path.posix.basename(own) !== REVIEW_FILE || !reviewCopies.has(path.join(rootDir, own)))
|
|
374
|
+
)
|
|
375
|
+
})
|
|
341
376
|
// A symlinked input keys the content behind it (cache/linked-inputs.ts),
|
|
342
377
|
// and the diff names that content's path, never the link's: a link whose
|
|
343
378
|
// target changed is a changed path itself.
|
|
@@ -353,12 +388,22 @@ export async function affectedChanges(
|
|
|
353
388
|
(await listIndex()).filter((e) => e.flagged).map((e) => e.path))(),
|
|
354
389
|
])
|
|
355
390
|
const dirLinkSet = new Set<string>()
|
|
356
|
-
changed.push(
|
|
391
|
+
changed.push(
|
|
392
|
+
...(await linkedChanges(args.workspaceRoot, links, changed, dirLinkSet, await caseArgs)),
|
|
393
|
+
)
|
|
357
394
|
// A path git was told not to look at (`update-index --assume-unchanged`,
|
|
358
395
|
// `--skip-worktree`): `git diff` reports no edit to it, and the key
|
|
359
396
|
// hashes it from disk, so the task missed and selection left it out.
|
|
360
397
|
if (flagged.length > 0) {
|
|
361
|
-
changed.push(
|
|
398
|
+
changed.push(
|
|
399
|
+
...(await flaggedChanges(
|
|
400
|
+
args.workspaceRoot,
|
|
401
|
+
flagged,
|
|
402
|
+
await listIndex(),
|
|
403
|
+
changed,
|
|
404
|
+
await autocrlf,
|
|
405
|
+
)),
|
|
406
|
+
)
|
|
362
407
|
}
|
|
363
408
|
// A changed path that is a link to a directory, or was a link and is
|
|
364
409
|
// no link to a file now: a glob reaching below it keys what is behind
|
|
@@ -390,7 +435,7 @@ export async function affectedChanges(
|
|
|
390
435
|
const everything = (file: string): AffectedChanges => {
|
|
391
436
|
const all = new Set(projects.map((p) => p.name))
|
|
392
437
|
const wholeBy = new Map([...all].map((n) => [n, file]))
|
|
393
|
-
return { projects: all, changed, paths: new Map(), whole: all, wholeBy }
|
|
438
|
+
return { projects: all, changed, paths: new Map(), whole: all, wholeBy, wide: true }
|
|
394
439
|
}
|
|
395
440
|
// The workspace config's plugins shape every project's resolved config,
|
|
396
441
|
// so its edit can re-key any task and selection cannot tell which (item
|
|
@@ -519,6 +564,11 @@ export async function affectedChanges(
|
|
|
519
564
|
)
|
|
520
565
|
const paths = new Map<string, string[]>()
|
|
521
566
|
const dirLinksIn = new Map<string, Set<string>>()
|
|
567
|
+
// A path git tracked at the base and no longer does is in both lists: the
|
|
568
|
+
// base key folded it, so it moved a key and stays an input.
|
|
569
|
+
const tracked = new Set(diffed.paths)
|
|
570
|
+
const untrackedSet = new Set(untracked.filter((rel) => !tracked.has(rel)))
|
|
571
|
+
const untrackedIn = new Map<string, Set<string>>()
|
|
522
572
|
const whole = new Set<string>(claimedOwned.keys())
|
|
523
573
|
const owned = projectsContaining(
|
|
524
574
|
args.workspaceRoot,
|
|
@@ -532,6 +582,11 @@ export async function affectedChanges(
|
|
|
532
582
|
const list = paths.get(name)
|
|
533
583
|
if (list) list.push(rel)
|
|
534
584
|
else paths.set(name, [rel])
|
|
585
|
+
if (untrackedSet.has(wsRel)) {
|
|
586
|
+
const loose = untrackedIn.get(name)
|
|
587
|
+
if (loose) loose.add(rel)
|
|
588
|
+
else untrackedIn.set(name, new Set([rel]))
|
|
589
|
+
}
|
|
535
590
|
if (!dirLinks.has(wsRel)) return
|
|
536
591
|
const links = dirLinksIn.get(name)
|
|
537
592
|
if (links) links.add(rel)
|
|
@@ -610,6 +665,10 @@ export async function affectedChanges(
|
|
|
610
665
|
workspaceConfigs: WORKSPACE_CONFIG_FILENAMES.map((n) => path.join(rootDir, n)),
|
|
611
666
|
})
|
|
612
667
|
if (imports.workspace !== undefined) return everything(imports.workspace)
|
|
668
|
+
if (imports.outside !== undefined) {
|
|
669
|
+
const moved = await outsideChanged(args.workspaceRoot, base, imports.outside)
|
|
670
|
+
if (moved !== undefined) return everything(moved)
|
|
671
|
+
}
|
|
613
672
|
for (const name of imports.owners) {
|
|
614
673
|
owned.add(name)
|
|
615
674
|
whole.add(name)
|
|
@@ -625,9 +684,12 @@ export async function affectedChanges(
|
|
|
625
684
|
...(claimedOwned.size > 0 ? { wholeBy: claimedOwned } : {}),
|
|
626
685
|
...(nestedDirs.length > 0 ? { nested: nestedDirs } : {}),
|
|
627
686
|
...(dirLinks.size > 0 ? { dirLinks, dirLinksIn } : {}),
|
|
687
|
+
...(untrackedIn.size > 0 ? { untrackedIn } : {}),
|
|
688
|
+
...(untrackedSet.size > 0 ? { untracked: untrackedSet } : {}),
|
|
628
689
|
})
|
|
629
690
|
if (changed.length === 0 || args.workspaceGlobOwners === undefined) return done()
|
|
630
|
-
|
|
691
|
+
const owners = await args.workspaceGlobOwners(changed, nestedDirs, dirLinks, untrackedSet)
|
|
692
|
+
for (const name of owners) owned.add(name)
|
|
631
693
|
return done()
|
|
632
694
|
}
|
|
633
695
|
|
|
@@ -639,6 +701,9 @@ const VX_OWN: ReadonlySet<string> = new Set([
|
|
|
639
701
|
'.vx/.gitignore',
|
|
640
702
|
])
|
|
641
703
|
|
|
704
|
+
/** The suffix of the temp a write renames into place (util/write-file.ts). */
|
|
705
|
+
const WRITE_TEMP = /\.tmp-\d+-[0-9a-z]*$/
|
|
706
|
+
|
|
642
707
|
const CONFIG_NAMES: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
|
|
643
708
|
/**
|
|
644
709
|
* The files that make a directory a project: core's configs, and the
|
|
@@ -694,6 +759,8 @@ async function removedProjectConfigs(
|
|
|
694
759
|
for (const [rel, bytes] of atBase) {
|
|
695
760
|
// A manifest's directory may never have been a project; a config's was.
|
|
696
761
|
if (bytes === null || !CONFIG_NAMES.has(path.posix.basename(rel))) continue
|
|
762
|
+
// Under `.vx` no config made a project, at the base either.
|
|
763
|
+
if (inVxDir(rel)) continue
|
|
697
764
|
const dir = path.resolve(workspaceRoot, path.posix.dirname(rel)).normalize('NFC')
|
|
698
765
|
if (dirs.has(dir)) continue
|
|
699
766
|
if (await Bun.file(path.join(workspaceRoot, rel)).exists()) continue
|
|
@@ -832,11 +899,13 @@ async function flaggedChanges(
|
|
|
832
899
|
flagged: readonly string[],
|
|
833
900
|
index: readonly IndexEntry[],
|
|
834
901
|
changed: readonly string[],
|
|
902
|
+
/** `core.autocrlf` converts (`gitConfigFacts`). */
|
|
903
|
+
autocrlf: boolean,
|
|
835
904
|
): Promise<string[]> {
|
|
836
905
|
const byPath = new Map(index.map((e) => [e.path, e]))
|
|
837
906
|
const seen = new Set(changed)
|
|
838
907
|
const out: string[] = []
|
|
839
|
-
|
|
908
|
+
let hash: IndexEntry[] = []
|
|
840
909
|
for (const rel of flagged) {
|
|
841
910
|
const entry = byPath.get(rel)
|
|
842
911
|
if (entry === undefined || seen.has(rel)) continue
|
|
@@ -847,6 +916,13 @@ async function flaggedChanges(
|
|
|
847
916
|
} else if (entry.mode === '120000' || rel.includes('\n')) out.push(rel)
|
|
848
917
|
else hash.push(entry)
|
|
849
918
|
}
|
|
919
|
+
const raw = await filtered(
|
|
920
|
+
workspaceRoot,
|
|
921
|
+
hash.map((e) => e.path),
|
|
922
|
+
autocrlf,
|
|
923
|
+
)
|
|
924
|
+
for (const e of hash) if (raw.has(e.path)) out.push(e.path)
|
|
925
|
+
if (raw.size > 0) hash = hash.filter((e) => !raw.has(e.path))
|
|
850
926
|
if (hash.length === 0) return out
|
|
851
927
|
// `--stdin-paths` hashes each file as `git add` would (its clean
|
|
852
928
|
// filters), so an unchanged one names the index's blob.
|
|
@@ -883,6 +959,8 @@ async function linkedChanges(
|
|
|
883
959
|
changed: readonly string[],
|
|
884
960
|
/** Given each link to a directory. */
|
|
885
961
|
dirs: Set<string>,
|
|
962
|
+
/** `gitCaseArgs`, for the ignored listing. */
|
|
963
|
+
caseArgs: readonly string[],
|
|
886
964
|
): Promise<string[]> {
|
|
887
965
|
if (links.length === 0) return []
|
|
888
966
|
const realRoot = await realpath(workspaceRoot).catch(() => workspaceRoot)
|
|
@@ -905,6 +983,7 @@ async function linkedChanges(
|
|
|
905
983
|
if (inside.length === 0) return out
|
|
906
984
|
const named = inside.map((l) => l.target).filter((t) => t !== '')
|
|
907
985
|
const ignored = await gitPaths(workspaceRoot, [
|
|
986
|
+
...caseArgs,
|
|
908
987
|
'ls-files',
|
|
909
988
|
'--others',
|
|
910
989
|
'--ignored',
|
|
@@ -1002,6 +1081,8 @@ async function gitDiffPaths(
|
|
|
1002
1081
|
workspaceRoot: string,
|
|
1003
1082
|
base: string,
|
|
1004
1083
|
partial: Promise<boolean>,
|
|
1084
|
+
/** `core.autocrlf` converts (`gitConfigFacts`). */
|
|
1085
|
+
autocrlf: Promise<boolean>,
|
|
1005
1086
|
): Promise<{ paths: string[]; gitlinks: string[]; links: string[] }> {
|
|
1006
1087
|
const diff = (...revs: string[]) =>
|
|
1007
1088
|
gitPaths(workspaceRoot, [
|
|
@@ -1047,7 +1128,7 @@ async function gitDiffPaths(
|
|
|
1047
1128
|
}
|
|
1048
1129
|
}
|
|
1049
1130
|
if (unread.length === 0) return { paths, gitlinks, links }
|
|
1050
|
-
const same = await statOnly(workspaceRoot, unread)
|
|
1131
|
+
const same = await statOnly(workspaceRoot, unread, await autocrlf)
|
|
1051
1132
|
return { paths: paths.filter((p) => !same.has(p)), gitlinks, links }
|
|
1052
1133
|
}
|
|
1053
1134
|
|
|
@@ -1059,9 +1140,11 @@ async function gitDiffPaths(
|
|
|
1059
1140
|
async function statOnly(
|
|
1060
1141
|
workspaceRoot: string,
|
|
1061
1142
|
unread: readonly { rel: string; oid: string; link: boolean }[],
|
|
1143
|
+
/** `core.autocrlf` converts (`gitConfigFacts`). */
|
|
1144
|
+
autocrlf: boolean,
|
|
1062
1145
|
): Promise<Set<string>> {
|
|
1063
1146
|
const same = new Set<string>()
|
|
1064
|
-
|
|
1147
|
+
let files: { rel: string; oid: string }[] = []
|
|
1065
1148
|
for (const u of unread) {
|
|
1066
1149
|
if (!u.link) {
|
|
1067
1150
|
// `--stdin-paths` reads a line and unquotes a leading `"`; the
|
|
@@ -1079,6 +1162,12 @@ async function statOnly(
|
|
|
1079
1162
|
// not a link any more: a change
|
|
1080
1163
|
}
|
|
1081
1164
|
}
|
|
1165
|
+
const raw = await filtered(
|
|
1166
|
+
workspaceRoot,
|
|
1167
|
+
files.map((f) => f.rel),
|
|
1168
|
+
autocrlf,
|
|
1169
|
+
)
|
|
1170
|
+
if (raw.size > 0) files = files.filter((f) => !raw.has(f.rel))
|
|
1082
1171
|
if (files.length === 0) return same
|
|
1083
1172
|
const proc = spawnGit(
|
|
1084
1173
|
['hash-object', '--stdin-paths'],
|
|
@@ -1094,14 +1183,103 @@ async function statOnly(
|
|
|
1094
1183
|
return same
|
|
1095
1184
|
}
|
|
1096
1185
|
|
|
1097
|
-
/**
|
|
1098
|
-
|
|
1186
|
+
/**
|
|
1187
|
+
* The first of `files` (absolute, above the workspace root) that changed
|
|
1188
|
+
* since `base` or is untracked, workspace-relative: `gitDiffPaths` is
|
|
1189
|
+
* `--relative` and never lists one. A file outside the repository is
|
|
1190
|
+
* git's to tell no more than the key's to pin.
|
|
1191
|
+
*/
|
|
1192
|
+
async function outsideChanged(
|
|
1193
|
+
workspaceRoot: string,
|
|
1194
|
+
base: string,
|
|
1195
|
+
files: readonly string[],
|
|
1196
|
+
): Promise<string | undefined> {
|
|
1197
|
+
const realRoot = realpathOr(workspaceRoot)
|
|
1198
|
+
const top = spawnGitSync(['rev-parse', '--show-toplevel'], workspaceRoot)
|
|
1199
|
+
if (top.exitCode !== 0) return undefined
|
|
1200
|
+
const repo = realpathOr(new TextDecoder().decode(top.stdout).replace(/\n$/, ''))
|
|
1201
|
+
const rels = files.filter((f) => f.startsWith(repo + path.sep)).map((f) => relPosix(realRoot, f))
|
|
1202
|
+
if (rels.length === 0) return undefined
|
|
1203
|
+
const specs = rels.map((r) => `:(literal)${r}`)
|
|
1204
|
+
const [diffed, untracked] = await Promise.all([
|
|
1205
|
+
gitPaths(workspaceRoot, [
|
|
1206
|
+
'-c',
|
|
1207
|
+
'diff.autoRefreshIndex=false',
|
|
1208
|
+
'diff',
|
|
1209
|
+
'--no-renames',
|
|
1210
|
+
'--name-only',
|
|
1211
|
+
'-z',
|
|
1212
|
+
'--end-of-options',
|
|
1213
|
+
base,
|
|
1214
|
+
'--',
|
|
1215
|
+
...specs,
|
|
1216
|
+
]),
|
|
1217
|
+
gitPaths(workspaceRoot, ['ls-files', '--others', '--exclude-standard', '-z', '--', ...specs]),
|
|
1218
|
+
])
|
|
1219
|
+
if (diffed.length > 0) return relPosix(realRoot, path.join(repo, diffed[0]!))
|
|
1220
|
+
return untracked[0]
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Of `rels`, the paths a clean filter can rewrite (`core.autocrlf`, or a
|
|
1225
|
+
* `text`, `eol`, `ident`, `filter` or `working-tree-encoding` attribute),
|
|
1226
|
+
* as the key asks it (cache/git-inputs.ts `dropFilteredOids`). `hash-object`
|
|
1227
|
+
* names such a file's filtered blob, which a CRLF edit to an LF file leaves
|
|
1228
|
+
* the base's, while the key hashes the raw bytes; and raw bytes equal to the
|
|
1229
|
+
* blob prove nothing, since a checkout under `eol=crlf` writes others. So
|
|
1230
|
+
* each is a change. A failed `check-attr` names every path.
|
|
1231
|
+
*/
|
|
1232
|
+
async function filtered(
|
|
1233
|
+
workspaceRoot: string,
|
|
1234
|
+
rels: readonly string[],
|
|
1235
|
+
autocrlf: boolean,
|
|
1236
|
+
): Promise<Set<string>> {
|
|
1237
|
+
if (rels.length === 0 || autocrlf) return new Set(rels)
|
|
1238
|
+
const proc = spawnGit(
|
|
1239
|
+
['check-attr', '--stdin', '-z', 'text', 'eol', 'ident', 'filter', 'working-tree-encoding'],
|
|
1240
|
+
workspaceRoot,
|
|
1241
|
+
new TextEncoder().encode(rels.map((r) => `${r}\0`).join('')),
|
|
1242
|
+
)
|
|
1243
|
+
const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
|
|
1244
|
+
if (exit !== 0) return new Set(rels)
|
|
1245
|
+
const hit = new Set<string>()
|
|
1246
|
+
const fields = out.split('\0')
|
|
1247
|
+
for (let i = 0; i + 2 < fields.length; i += 3) {
|
|
1248
|
+
if (fields[i + 2] !== 'unspecified' && fields[i + 2] !== 'unset') hit.add(fields[i]!)
|
|
1249
|
+
}
|
|
1250
|
+
return hit
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* Three config facts in one `git config`: `partial`, a remote that serves
|
|
1255
|
+
* missing objects on demand (`--filter` on a clone or a fetch),
|
|
1256
|
+
* `ignoreCase`, `core.ignorecase` set true, and `autocrlf`,
|
|
1257
|
+
* `core.autocrlf` set `true` or `input`.
|
|
1258
|
+
*/
|
|
1259
|
+
async function gitConfigFacts(
|
|
1260
|
+
workspaceRoot: string,
|
|
1261
|
+
): Promise<{ partial: boolean; ignoreCase: boolean; autocrlf: boolean }> {
|
|
1099
1262
|
const proc = spawnGit(
|
|
1100
|
-
[
|
|
1263
|
+
[
|
|
1264
|
+
'config',
|
|
1265
|
+
'--get-regexp',
|
|
1266
|
+
String.raw`^(remote\..*\.promisor|extensions\.partialclone|core\.ignorecase|core\.autocrlf)$`,
|
|
1267
|
+
],
|
|
1101
1268
|
workspaceRoot,
|
|
1102
1269
|
)
|
|
1103
1270
|
const [out] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
|
|
1104
|
-
|
|
1271
|
+
let partial = false
|
|
1272
|
+
let ignoreCase = false
|
|
1273
|
+
let autocrlf = false
|
|
1274
|
+
for (const l of out.split('\n')) {
|
|
1275
|
+
// A bare key (`[core] ignorecase`) is true, as git reads it.
|
|
1276
|
+
if (l.startsWith('core.ignorecase'))
|
|
1277
|
+
ignoreCase = /^core\.ignorecase( (true|yes|on|[-+]?0*[1-9]\d*[kmg]?))?$/i.test(l)
|
|
1278
|
+
else if (l.startsWith('core.autocrlf'))
|
|
1279
|
+
autocrlf = /^core\.autocrlf( (input|true|yes|on|[-+]?0*[1-9]\d*[kmg]?))?$/i.test(l)
|
|
1280
|
+
else if (l.startsWith('extensions.') || l.endsWith(' true')) partial = true
|
|
1281
|
+
}
|
|
1282
|
+
return { partial, ignoreCase, autocrlf }
|
|
1105
1283
|
}
|
|
1106
1284
|
|
|
1107
1285
|
/**
|
|
@@ -1272,8 +1450,10 @@ async function mergeBase(workspaceRoot: string, ref: string): Promise<string> {
|
|
|
1272
1450
|
isShallow(workspaceRoot)
|
|
1273
1451
|
) {
|
|
1274
1452
|
throw new UserError(
|
|
1275
|
-
`git ref "${ref}" has no merge base with HEAD in this shallow clone,
|
|
1276
|
-
|
|
1453
|
+
`git ref "${ref}" has no merge base with HEAD in this shallow clone, ` +
|
|
1454
|
+
(hasOrigin(workspaceRoot)
|
|
1455
|
+
? 'and origin did not give the history between them: fetch it (`git fetch --unshallow origin`).'
|
|
1456
|
+
: 'which has no origin remote: fetch the history between them (`git fetch --unshallow <url>`).'),
|
|
1277
1457
|
'VX_E_AFFECTED_BASE',
|
|
1278
1458
|
)
|
|
1279
1459
|
}
|
|
@@ -1388,7 +1568,7 @@ async function rangeBase(workspaceRoot: string, ref: string): Promise<string> {
|
|
|
1388
1568
|
return ref
|
|
1389
1569
|
}
|
|
1390
1570
|
|
|
1391
|
-
/** The clones `fetchOrigin` fetched into, so made partial: `
|
|
1571
|
+
/** The clones `fetchOrigin` fetched into, so made partial: `gitConfigFacts` may have answered first. */
|
|
1392
1572
|
const fetchedInto = new Set<string>()
|
|
1393
1573
|
|
|
1394
1574
|
/** Deepening stops here (2,047 commits on each side): past it, the base is the user's to fetch. */
|
|
@@ -1399,6 +1579,18 @@ function isShallow(workspaceRoot: string): boolean {
|
|
|
1399
1579
|
return new TextDecoder().decode(proc.stdout).trim() === 'true'
|
|
1400
1580
|
}
|
|
1401
1581
|
|
|
1582
|
+
/**
|
|
1583
|
+
* Whether origin has a URL. Asked only in a shallow clone, before a fetch
|
|
1584
|
+
* or on a refusal. Not `git remote get-url`: a `[remote "origin"]` of
|
|
1585
|
+
* promisor keys alone (git's filtered fetch from no origin writes one) it
|
|
1586
|
+
* names as a URL.
|
|
1587
|
+
*/
|
|
1588
|
+
function hasOrigin(workspaceRoot: string): boolean {
|
|
1589
|
+
return (
|
|
1590
|
+
spawnGitSync(['config', '--get', 'remote.origin.url'], workspaceRoot, 'ignore').exitCode === 0
|
|
1591
|
+
)
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1402
1594
|
/** The parents a commit names, even where a shallow clone cut them off. */
|
|
1403
1595
|
function parentsOf(workspaceRoot: string, sha: string): string[] {
|
|
1404
1596
|
const proc = spawnGitSync(
|
|
@@ -1465,6 +1657,9 @@ async function originDefaultBranch(workspaceRoot: string): Promise<string | unde
|
|
|
1465
1657
|
* demand. Never prompts for credentials; false when it failed.
|
|
1466
1658
|
*/
|
|
1467
1659
|
async function fetchOrigin(workspaceRoot: string, args: string[]): Promise<boolean> {
|
|
1660
|
+
// With no origin the fetch fails, yet first writes a promisor
|
|
1661
|
+
// `[remote "origin"]` into the user's config.
|
|
1662
|
+
if (!hasOrigin(workspaceRoot)) return false
|
|
1468
1663
|
try {
|
|
1469
1664
|
const proc = Bun.spawn({
|
|
1470
1665
|
cmd: [
|
|
@@ -1537,9 +1732,12 @@ async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
|
|
|
1537
1732
|
// no origin, no network, a name only another clone has.
|
|
1538
1733
|
throw new UserError(
|
|
1539
1734
|
`git ref "${ref}" did not resolve. Pass a branch or commit you have locally.` +
|
|
1540
|
-
(isShallow(workspaceRoot)
|
|
1541
|
-
? '
|
|
1542
|
-
:
|
|
1735
|
+
(!isShallow(workspaceRoot)
|
|
1736
|
+
? ''
|
|
1737
|
+
: hasOrigin(workspaceRoot)
|
|
1738
|
+
? ' This clone is shallow and origin did not give it: fetch the base (`git fetch --depth=1 origin <base>`).'
|
|
1739
|
+
: ' This clone is shallow and has no origin remote: fetch the base from where it lives ' +
|
|
1740
|
+
'(`git fetch --depth=1 <url> <base>`), or add one (`git remote add origin <url>`).'),
|
|
1543
1741
|
'VX_E_AFFECTED_BASE',
|
|
1544
1742
|
)
|
|
1545
1743
|
}
|
|
@@ -65,8 +65,8 @@ export function checkBracketLiterals(root: string, projects: Iterable<ProjectEnt
|
|
|
65
65
|
const lists: [string, readonly string[] | undefined, string][] = [
|
|
66
66
|
['inputs.files', t.inputs?.files, p.dir],
|
|
67
67
|
['inputs.workspaceFiles', t.inputs?.workspaceFiles, root],
|
|
68
|
-
['
|
|
69
|
-
['
|
|
68
|
+
['outputs.files', t.outputs?.files, p.dir],
|
|
69
|
+
['outputs.workspaceFiles', t.outputs?.workspaceFiles, root],
|
|
70
70
|
]
|
|
71
71
|
for (const [field, globs, base] of lists) {
|
|
72
72
|
for (const glob of globs ?? []) {
|
|
@@ -34,16 +34,20 @@ import {
|
|
|
34
34
|
import { writeFile } from 'node:fs/promises'
|
|
35
35
|
import { createRequire } from 'node:module'
|
|
36
36
|
import path from 'node:path'
|
|
37
|
-
import { xxh3hex } from '../util/index.js'
|
|
37
|
+
import { refuseForeignCacheDir, xxh3hex } from '../util/index.js'
|
|
38
38
|
|
|
39
39
|
/** Bumped when what a bundle holds, or how it is checked, changes. */
|
|
40
|
-
const COMPILED_VERSION =
|
|
40
|
+
const COMPILED_VERSION = 3
|
|
41
41
|
|
|
42
42
|
/** What `import.meta` becomes in a bundled file: an identifier no source spells. */
|
|
43
43
|
const META = '__vxImportMeta'
|
|
44
44
|
const META_FIELDS = new Set(['dir', 'dirname', 'path', 'filename', 'file', 'url', 'main'])
|
|
45
45
|
const META_USE = new RegExp(`\\b${META}\\b(\\.[\\w$]+)?`, 'g')
|
|
46
46
|
const COMPUTED_LOAD = /\b(?:require|import)\(\s*(?![\s'"])|\brequire\.resolve\b/
|
|
47
|
+
// Bun's build and transpiler inline `process.env.NODE_ENV` by default, and a
|
|
48
|
+
// bundle's name folds no env: it froze the building run's value. Defined as
|
|
49
|
+
// itself, it is read at run time, as the source reads it.
|
|
50
|
+
const LIVE_NODE_ENV = { 'process.env.NODE_ENV': 'process.env.NODE_ENV' }
|
|
47
51
|
|
|
48
52
|
interface Manifest {
|
|
49
53
|
version: number
|
|
@@ -65,7 +69,17 @@ function nameOf(configPath: string, bytes: Uint8Array): string {
|
|
|
65
69
|
return `workspace-${xxh3hex(`${COMPILED_VERSION}\0${Bun.revision}\0${configPath}\0${xxh3hex(bytes)}`)}`
|
|
66
70
|
}
|
|
67
71
|
|
|
68
|
-
function fresh(manifest: Manifest): boolean {
|
|
72
|
+
function fresh(manifest: Manifest, configPath: string): boolean {
|
|
73
|
+
// Built through a link: retargeted at a file of the same bytes, the config's
|
|
74
|
+
// relative imports are other files. A config reached by its real path pays
|
|
75
|
+
// no realpath for this.
|
|
76
|
+
if (manifest.entry !== configPath) {
|
|
77
|
+
try {
|
|
78
|
+
if (realpathSync(configPath) !== manifest.entry) return false
|
|
79
|
+
} catch {
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
69
83
|
for (const [file, hash] of Object.entries(manifest.inputs)) {
|
|
70
84
|
if (file === manifest.entry) continue
|
|
71
85
|
let bytes: Uint8Array
|
|
@@ -109,7 +123,7 @@ export function loadCompiled(
|
|
|
109
123
|
} catch {
|
|
110
124
|
return undefined
|
|
111
125
|
}
|
|
112
|
-
if (manifest.version !== COMPILED_VERSION || !fresh(manifest)) return undefined
|
|
126
|
+
if (manifest.version !== COMPILED_VERSION || !fresh(manifest, configPath)) return undefined
|
|
113
127
|
const ns =
|
|
114
128
|
manifest.skip === true
|
|
115
129
|
? null
|
|
@@ -158,6 +172,9 @@ export async function buildCompiled(
|
|
|
158
172
|
answered.set(configPath, { bytes, ns })
|
|
159
173
|
const dir = compiledDir(root)
|
|
160
174
|
if (!existsSync(path.dirname(dir))) return
|
|
175
|
+
// Before the first write there: the cache's own open, which refuses a
|
|
176
|
+
// `.vx/cache` linked to the home directory, comes after this load.
|
|
177
|
+
refuseForeignCacheDir(path.dirname(dir), root)
|
|
161
178
|
const name = nameOf(configPath, bytes)
|
|
162
179
|
const entryFile = realpathSync(configPath)
|
|
163
180
|
const inputs: Record<string, string> = {}
|
|
@@ -178,6 +195,7 @@ export async function buildCompiled(
|
|
|
178
195
|
format: 'cjs',
|
|
179
196
|
bytecode: true,
|
|
180
197
|
external: ['@vzn/vx'],
|
|
198
|
+
define: LIVE_NODE_ENV,
|
|
181
199
|
outdir: tmp,
|
|
182
200
|
naming: `${name}.js`,
|
|
183
201
|
metafile: true,
|
|
@@ -206,6 +224,7 @@ export async function buildCompiled(
|
|
|
206
224
|
t = new Bun.Transpiler({
|
|
207
225
|
loader,
|
|
208
226
|
define: {
|
|
227
|
+
...LIVE_NODE_ENV,
|
|
209
228
|
'import.meta': META,
|
|
210
229
|
__dirname: JSON.stringify(fileDir),
|
|
211
230
|
__filename: JSON.stringify(file),
|