@vzn/vx 0.0.636 → 0.0.638
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 +39 -11
- package/docs/caching.md +246 -213
- package/docs/cli.md +791 -705
- package/docs/execution.md +68 -76
- package/docs/features.md +47 -42
- package/docs/patterns.md +26 -25
- package/docs/schema.md +392 -205
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +4 -4
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +28 -13
- package/src/bin.ts +30 -9
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +124 -53
- package/src/cache/chained-cache.ts +4 -0
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +15 -1
- package/src/cache/inputs.ts +102 -36
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +15 -18
- package/src/cache/layered-cache.ts +20 -102
- package/src/cache/plugin-cache.ts +248 -0
- package/src/cache/policy.ts +32 -27
- package/src/cache/run-history.ts +104 -14
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +58 -31
- package/src/cache/scoped-cache.ts +237 -0
- package/src/cli/cache.ts +173 -34
- package/src/cli/completions.ts +11 -7
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +110 -70
- package/src/cli/index.ts +67 -24
- package/src/cli/info.ts +43 -27
- package/src/cli/init.ts +115 -407
- package/src/cli/last.ts +157 -9
- package/src/cli/lock.ts +356 -122
- package/src/cli/plan-format.ts +7 -5
- package/src/cli/plugin-commands.ts +81 -14
- package/src/cli/plugin-templates.ts +11 -11
- package/src/cli/run.ts +116 -160
- package/src/cli/select.ts +50 -47
- package/src/cli/show.ts +83 -59
- package/src/cli/task-verb.ts +6 -13
- package/src/cli/upgrade.ts +415 -149
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +39 -61
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +18 -4
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +227 -230
- package/src/cli/why.ts +38 -18
- package/src/cli/workspace-config.ts +33 -70
- package/src/config.ts +169 -70
- package/src/exec/env.ts +1 -31
- package/src/exec/executor.ts +1 -5
- package/src/exec/kill-tree.ts +2 -1
- package/src/exec/sandbox-runtime.ts +12 -1
- package/src/exec/sandbox-violations.ts +9 -1
- package/src/graph/dependency-spec.ts +1 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +27 -7
- package/src/graph/task-graph.ts +76 -24
- package/src/handoff.ts +169 -0
- package/src/index.ts +35 -58
- package/src/orchestrator/affected-tasks.ts +15 -8
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +147 -69
- package/src/orchestrator/failure-mode.ts +19 -10
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +9 -5
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +126 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +20 -5
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +80 -41
- package/src/orchestrator/metrics.ts +78 -9
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +42 -29
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +100 -66
- package/src/orchestrator/plugin.ts +173 -67
- package/src/orchestrator/prepare.ts +163 -78
- package/src/orchestrator/projects.ts +105 -99
- package/src/orchestrator/remote-prefetch.ts +11 -6
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-records.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +244 -119
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +49 -7
- package/src/orchestrator/summary.ts +115 -106
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +4 -4
- package/src/settings.ts +428 -0
- package/src/{orchestrator → util}/colors.ts +5 -0
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +33 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +16 -3
- package/src/util/paths.ts +10 -0
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +31 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ui.ts +186 -0
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +84 -232
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +209 -268
- package/src/workspace/dependency-rules.ts +65 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +20 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +307 -215
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +363 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
package/src/cache/git-inputs.ts
CHANGED
|
@@ -6,9 +6,21 @@
|
|
|
6
6
|
// task declared. Nothing here reads a config or applies a boundary.
|
|
7
7
|
|
|
8
8
|
import path from 'node:path'
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
existsSync,
|
|
11
|
+
lstatSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
realpathSync,
|
|
15
|
+
renameSync,
|
|
16
|
+
rmSync,
|
|
17
|
+
utimesSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
type Stats,
|
|
20
|
+
} from 'node:fs'
|
|
10
21
|
import {
|
|
11
22
|
executablePath,
|
|
23
|
+
gitEnv,
|
|
12
24
|
gitSpawnRefusal,
|
|
13
25
|
isInstalledPath,
|
|
14
26
|
notAWorkTree,
|
|
@@ -60,6 +72,7 @@ export function repoFacts(dir: string): RepoFacts | null {
|
|
|
60
72
|
'--git-path',
|
|
61
73
|
'index',
|
|
62
74
|
],
|
|
75
|
+
env: gitEnv(),
|
|
63
76
|
cwd: dir,
|
|
64
77
|
stdout: 'pipe',
|
|
65
78
|
stderr: 'ignore',
|
|
@@ -99,6 +112,7 @@ export function gitRefusal(dir: string): UserError | undefined {
|
|
|
99
112
|
try {
|
|
100
113
|
proc = Bun.spawnSync({
|
|
101
114
|
cmd: [executablePath('git'), 'rev-parse', '--is-inside-work-tree'],
|
|
115
|
+
env: gitEnv(),
|
|
102
116
|
cwd: dir,
|
|
103
117
|
stdout: 'ignore',
|
|
104
118
|
stderr: 'pipe',
|
|
@@ -218,6 +232,56 @@ export class GitFilesCache extends Map<string, readonly string[]> {
|
|
|
218
232
|
* be opened from a string, so `resolveFiles` refuses one it would fold.
|
|
219
233
|
*/
|
|
220
234
|
private undecodable = new Set<string>()
|
|
235
|
+
/**
|
|
236
|
+
* What git tracks, by absolute path, within `trackedScopes`: a file there
|
|
237
|
+
* no clean deletes (`committedIn`). Filled by the
|
|
238
|
+
* enumeration, and per directory on demand for one it did not cover.
|
|
239
|
+
*/
|
|
240
|
+
private tracked = new Set<string>()
|
|
241
|
+
private trackedScopes: string[] = []
|
|
242
|
+
/**
|
|
243
|
+
* Under `core.ignorecase` (macOS's default volume) git's `Foo.txt` is the
|
|
244
|
+
* disk's `foo.txt` after a rename git never saw, so paths are compared
|
|
245
|
+
* case-folded. Decided at the first note: the run's `git var -l`, else
|
|
246
|
+
* the platform.
|
|
247
|
+
*/
|
|
248
|
+
private foldCase: boolean | undefined
|
|
249
|
+
|
|
250
|
+
private trackedKey(abs: string): string {
|
|
251
|
+
this.foldCase ??=
|
|
252
|
+
this.vars === null
|
|
253
|
+
? process.platform === 'darwin'
|
|
254
|
+
: this.vars.get('core.ignorecase')?.trim().toLowerCase() === 'true'
|
|
255
|
+
return this.foldCase ? abs.toLowerCase() : abs
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
noteTracked(scopes: readonly string[], absPaths: readonly string[]): void {
|
|
259
|
+
for (const p of absPaths) this.tracked.add(this.trackedKey(p))
|
|
260
|
+
this.trackedScopes.push(...scopes)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Whether git tracks a file under `dir`: committed (or `git add`ed)
|
|
265
|
+
* files are the user's, never cleaned (owner, 2026-10-14: "never
|
|
266
|
+
* delete committed file"). A directory no enumeration covered (a
|
|
267
|
+
* nested repository, a cache no enumeration filled) is listed once.
|
|
268
|
+
* git lists a submodule as its gitlink path alone, so a file under a
|
|
269
|
+
* tracked path is committed too.
|
|
270
|
+
*/
|
|
271
|
+
committedIn(dir: string): (abs: string) => boolean {
|
|
272
|
+
let listed = false
|
|
273
|
+
return (abs) => {
|
|
274
|
+
if (!listed) {
|
|
275
|
+
listed = true
|
|
276
|
+
const covered = this.trackedScopes.some((s) => dir === s || dir.startsWith(s + path.sep))
|
|
277
|
+
if (!covered) this.noteTracked([dir], listTracked(dir))
|
|
278
|
+
}
|
|
279
|
+
for (let p = abs; p.length > dir.length; p = path.dirname(p))
|
|
280
|
+
if (this.tracked.has(this.trackedKey(p))) return true
|
|
281
|
+
return false
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
221
285
|
/**
|
|
222
286
|
* When the enumeration that vouched for `oids` started (ms since the
|
|
223
287
|
* epoch): an index OID says what a file held when `git status` looked,
|
|
@@ -332,30 +396,6 @@ export class GitFilesCache extends Map<string, readonly string[]> {
|
|
|
332
396
|
}
|
|
333
397
|
}
|
|
334
398
|
|
|
335
|
-
/** Tracked files a task's clean removed this run, and whose clean (A-48). */
|
|
336
|
-
private trackedCleans: Array<{ path: string; by: string }> = []
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Note which of the files a task's clean just removed git tracks. Called
|
|
340
|
-
* before `markOutputsChanged`, which drops their OIDs. A committed output
|
|
341
|
-
* another task reads with no edge to its producer is gone for as long as
|
|
342
|
-
* the producer runs (vueuse's `metadata/index.json`, N's dogfood), and the
|
|
343
|
-
* reader failed naming only the missing file (A-48).
|
|
344
|
-
*/
|
|
345
|
-
noteClean(by: string, dir: string, rels: readonly string[]): void {
|
|
346
|
-
const oids = this.oids.get(dir)
|
|
347
|
-
if (oids === undefined) return
|
|
348
|
-
for (const rel of rels) {
|
|
349
|
-
const abs = path.resolve(dir, rel)
|
|
350
|
-
if (oids.has(abs)) this.trackedCleans.push({ path: abs, by })
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/** The tracked files other tasks' cleans removed that are still missing. */
|
|
355
|
-
trackedCleansMissing(except: string): Array<{ path: string; by: string }> {
|
|
356
|
-
return this.trackedCleans.filter((c) => c.by !== except && !existsSync(c.path))
|
|
357
|
-
}
|
|
358
|
-
|
|
359
399
|
/** Trusted index OIDs for a project (abs path → oid), if any survive. */
|
|
360
400
|
oidsFor(projectDir: string): ReadonlyMap<string, string> | undefined {
|
|
361
401
|
return this.oids.get(projectDir)
|
|
@@ -476,6 +516,7 @@ export function runGitLsFiles(cwd: string): GitLsResult {
|
|
|
476
516
|
try {
|
|
477
517
|
proc = Bun.spawnSync({
|
|
478
518
|
cmd: [executablePath('git'), 'ls-files', '-s', '--others', '--exclude-standard', '-z', '.'],
|
|
519
|
+
env: gitEnv(),
|
|
479
520
|
cwd,
|
|
480
521
|
stdout: 'pipe',
|
|
481
522
|
stderr: 'pipe',
|
|
@@ -496,6 +537,32 @@ export function runGitLsFiles(cwd: string): GitLsResult {
|
|
|
496
537
|
return parsed
|
|
497
538
|
}
|
|
498
539
|
|
|
540
|
+
/**
|
|
541
|
+
* The files git tracks under `dir`, absolute: one `ls-files` of the index,
|
|
542
|
+
* no worktree walk. Outside a work tree (an embedder's temp dir) nothing
|
|
543
|
+
* is tracked.
|
|
544
|
+
*/
|
|
545
|
+
function listTracked(dir: string): string[] {
|
|
546
|
+
let proc
|
|
547
|
+
try {
|
|
548
|
+
proc = Bun.spawnSync({
|
|
549
|
+
cmd: [executablePath('git'), 'ls-files', '-z', '--', '.'],
|
|
550
|
+
env: gitEnv(),
|
|
551
|
+
cwd: dir,
|
|
552
|
+
stdout: 'pipe',
|
|
553
|
+
stderr: 'pipe',
|
|
554
|
+
})
|
|
555
|
+
} catch {
|
|
556
|
+
return []
|
|
557
|
+
}
|
|
558
|
+
if (proc.exitCode !== 0) return []
|
|
559
|
+
const { text } = decodeGitZ(proc.stdout)
|
|
560
|
+
return text
|
|
561
|
+
.split('\0')
|
|
562
|
+
.filter((rel) => rel.length > 0)
|
|
563
|
+
.map((rel) => path.join(dir, rel))
|
|
564
|
+
}
|
|
565
|
+
|
|
499
566
|
/**
|
|
500
567
|
* Replace each nested repository in a listing — a gitlink (a submodule) or
|
|
501
568
|
* an untracked `dir/` (an embedded repository) — with the files its OWN git
|
|
@@ -853,7 +920,10 @@ async function dropFilteredOids(
|
|
|
853
920
|
// index blob keyed it through an edit (item 977).
|
|
854
921
|
if (!attributesPossible) {
|
|
855
922
|
for (const rel of args.listed) {
|
|
856
|
-
if (
|
|
923
|
+
if (
|
|
924
|
+
(rel === '.gitattributes' || rel.endsWith('/.gitattributes')) &&
|
|
925
|
+
attributesCanConvert(path.join(args.workspaceRoot, rel))
|
|
926
|
+
) {
|
|
857
927
|
attributesPossible = true
|
|
858
928
|
break
|
|
859
929
|
}
|
|
@@ -915,11 +985,40 @@ function attributesInIndex(args: {
|
|
|
915
985
|
return true
|
|
916
986
|
}
|
|
917
987
|
for (const rel of args.tracked) {
|
|
918
|
-
if (
|
|
988
|
+
if (
|
|
989
|
+
(rel === '.gitattributes' || rel.endsWith('/.gitattributes')) &&
|
|
990
|
+
attributesCanConvert(path.join(args.workspaceRoot, rel))
|
|
991
|
+
)
|
|
992
|
+
return true
|
|
919
993
|
}
|
|
920
994
|
return attributesAbove(args)
|
|
921
995
|
}
|
|
922
996
|
|
|
997
|
+
/**
|
|
998
|
+
* Whether a `.gitattributes` can set an attribute that converts bytes. One
|
|
999
|
+
* that names none of them, nor `binary` (git's one built-in macro) nor a
|
|
1000
|
+
* macro definition, cannot: `.vx/.gitattributes`' `linguist-generated`
|
|
1001
|
+
* line asked `check-attr` on every run (21 ms) and changed nothing. A macro
|
|
1002
|
+
* defined outside the tree already counts as an attributes source. A file
|
|
1003
|
+
* that does not read counts as one that converts. Read once per enumeration.
|
|
1004
|
+
*/
|
|
1005
|
+
function attributesCanConvert(file: string): boolean {
|
|
1006
|
+
let can = attributeReads.get(file)
|
|
1007
|
+
if (can === undefined) {
|
|
1008
|
+
try {
|
|
1009
|
+
can = CONVERTING_ATTRIBUTE.test(readFileSync(file, 'utf8'))
|
|
1010
|
+
} catch {
|
|
1011
|
+
can = true
|
|
1012
|
+
}
|
|
1013
|
+
attributeReads.set(file, can)
|
|
1014
|
+
}
|
|
1015
|
+
return can
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const CONVERTING_ATTRIBUTE =
|
|
1019
|
+
/\[attr\]|\b(?:text|eol|ident|filter|working-tree-encoding|crlf|binary)\b/
|
|
1020
|
+
const attributeReads = new Map<string, boolean>()
|
|
1021
|
+
|
|
923
1022
|
/**
|
|
924
1023
|
* The repository root, derived from the workspace root and git's own
|
|
925
1024
|
* `--show-prefix` (the repo→workspace path). Exact and free: the prefix
|
|
@@ -950,7 +1049,8 @@ function attributesAbove(args: {
|
|
|
950
1049
|
for (;;) {
|
|
951
1050
|
if (!seen.has(dir)) {
|
|
952
1051
|
seen.add(dir)
|
|
953
|
-
|
|
1052
|
+
const file = path.join(dir, '.gitattributes')
|
|
1053
|
+
if (existsSync(file) && attributesCanConvert(file)) return true
|
|
954
1054
|
}
|
|
955
1055
|
const parent = path.dirname(dir)
|
|
956
1056
|
// Stop at the repo root, and never walk past the filesystem root on a
|
|
@@ -1180,6 +1280,10 @@ export async function populateGitFilesCache(
|
|
|
1180
1280
|
export interface GitEnumeration {
|
|
1181
1281
|
/** Every enumerated path, workspace-relative, tracked and untracked. */
|
|
1182
1282
|
all: string[]
|
|
1283
|
+
/** The paths the index lists (a submodule as its gitlink), workspace-relative: what git tracks. */
|
|
1284
|
+
tracked: readonly string[]
|
|
1285
|
+
/** The directories `tracked` covers, workspace-relative (`.` for the whole tree). */
|
|
1286
|
+
scopes: readonly string[]
|
|
1183
1287
|
/** Workspace-relative path → index OID, for files whose worktree state matched the index. */
|
|
1184
1288
|
trusted: Map<string, string>
|
|
1185
1289
|
/** Whether the worktree had uncommitted changes; null when `git status` failed. */
|
|
@@ -1230,6 +1334,91 @@ export function gitPathspecs(
|
|
|
1230
1334
|
return scoped ? rels : ['.']
|
|
1231
1335
|
}
|
|
1232
1336
|
|
|
1337
|
+
/**
|
|
1338
|
+
* Whether the index holds a racily clean entry: a file stamped no earlier
|
|
1339
|
+
* than the second the index was written. git cannot trust its stat and
|
|
1340
|
+
* re-hashes the file on every `status` until something rewrites the index,
|
|
1341
|
+
* and a fresh checkout is all such entries. A layout this does not read
|
|
1342
|
+
* answers false.
|
|
1343
|
+
*/
|
|
1344
|
+
function hasRacyEntry(index: Uint8Array, mtimeSec: number, oidBytes: number): boolean {
|
|
1345
|
+
const view = new DataView(index.buffer, index.byteOffset, index.byteLength)
|
|
1346
|
+
if (index.length < 12 || view.getUint32(0) !== 0x44495243) return false
|
|
1347
|
+
const version = view.getUint32(4)
|
|
1348
|
+
if (version < 2 || version > 4) return false
|
|
1349
|
+
const count = view.getUint32(8)
|
|
1350
|
+
let at = 12
|
|
1351
|
+
for (let i = 0; i < count; i++) {
|
|
1352
|
+
const flagsAt = at + 40 + oidBytes
|
|
1353
|
+
if (flagsAt + 2 > index.length) return false
|
|
1354
|
+
if (view.getUint32(at + 8) >= mtimeSec) return true
|
|
1355
|
+
let pathAt = flagsAt + 2
|
|
1356
|
+
if (version >= 3 && (view.getUint16(flagsAt) & 0x4000) !== 0) pathAt += 2
|
|
1357
|
+
// v4 opens the path with a varint (bytes of the previous path to drop).
|
|
1358
|
+
if (version === 4) while ((index[pathAt++] ?? 0) & 0x80);
|
|
1359
|
+
const end = index.indexOf(0, pathAt)
|
|
1360
|
+
if (end < 0) return false
|
|
1361
|
+
at = version === 4 ? end + 1 : at + ((end - at + 8) & ~7)
|
|
1362
|
+
}
|
|
1363
|
+
return false
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/** A private copy a run older than this may still have handed to `status`. */
|
|
1367
|
+
const STALE_INDEX_MS = 60_000
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* The index `status` reads when the real one holds a racily clean entry: a
|
|
1371
|
+
* copy refreshed once and kept beside it, named by the real index's bytes.
|
|
1372
|
+
* `status` reads the real index without writing it (item 880), so in a
|
|
1373
|
+
* checkout nothing else had refreshed, every run re-hashed every racy file:
|
|
1374
|
+
* 347 against 8 ms on 1,800 files. The copy keeps the real mtime until git
|
|
1375
|
+
* refreshes it, so git's own racy rule holds for it; only stats move, the
|
|
1376
|
+
* entries are the real index's. Undefined: read the real index.
|
|
1377
|
+
*/
|
|
1378
|
+
async function refreshedIndex(
|
|
1379
|
+
indexFile: string,
|
|
1380
|
+
read: { bytes: Uint8Array; hash: string; st: Stats },
|
|
1381
|
+
objectFormat: 'sha1' | 'sha256',
|
|
1382
|
+
spawnGit: (
|
|
1383
|
+
args: string[],
|
|
1384
|
+
stdin?: string,
|
|
1385
|
+
env?: Record<string, string>,
|
|
1386
|
+
) => Promise<GitRun | null>,
|
|
1387
|
+
): Promise<string | undefined> {
|
|
1388
|
+
if (GIT_LOCATION_ENV.some((name) => process.env[name] !== undefined)) return undefined
|
|
1389
|
+
const { bytes, hash, st } = read
|
|
1390
|
+
if (!hasRacyEntry(bytes, Math.floor(st.mtimeMs / 1000), objectFormat === 'sha256' ? 32 : 20)) {
|
|
1391
|
+
return undefined
|
|
1392
|
+
}
|
|
1393
|
+
const dir = path.dirname(indexFile)
|
|
1394
|
+
const name = `vx-index-${hash}`
|
|
1395
|
+
const copy = path.join(dir, name)
|
|
1396
|
+
if (existsSync(copy)) return copy
|
|
1397
|
+
const tmp = `${copy}.${process.pid}`
|
|
1398
|
+
try {
|
|
1399
|
+
writeFileSync(tmp, bytes)
|
|
1400
|
+
utimesSync(tmp, st.atime, st.mtime)
|
|
1401
|
+
const run = await spawnGit(['update-index', '-q', '--refresh'], undefined, {
|
|
1402
|
+
GIT_INDEX_FILE: tmp,
|
|
1403
|
+
})
|
|
1404
|
+
if (run === null || run.exitCode !== 0) throw new Error('refresh failed')
|
|
1405
|
+
renameSync(tmp, copy)
|
|
1406
|
+
} catch {
|
|
1407
|
+
rmSync(tmp, { force: true })
|
|
1408
|
+
return undefined
|
|
1409
|
+
}
|
|
1410
|
+
const stale = Date.now() - STALE_INDEX_MS
|
|
1411
|
+
for (const other of readdirSync(dir)) {
|
|
1412
|
+
if (!other.startsWith('vx-index-') || other === name) continue
|
|
1413
|
+
try {
|
|
1414
|
+
if (lstatSync(path.join(dir, other)).mtimeMs < stale) rmSync(path.join(dir, other))
|
|
1415
|
+
} catch {
|
|
1416
|
+
// another run swept it
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
return copy
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1233
1422
|
/**
|
|
1234
1423
|
* The spawn half of `populateGitFilesCache`, separated so an UNSCOPED run
|
|
1235
1424
|
* can start it before the configs are evaluated — the enumeration needs
|
|
@@ -1241,6 +1430,7 @@ export async function startGitEnumeration(
|
|
|
1241
1430
|
workspaceRoot: string,
|
|
1242
1431
|
pathspecs: readonly string[],
|
|
1243
1432
|
): Promise<GitEnumeration> {
|
|
1433
|
+
attributeReads.clear()
|
|
1244
1434
|
// The spawns are independent — run them concurrently so the
|
|
1245
1435
|
// bulk-populate costs max(ls-files, status) wall time, not the sum
|
|
1246
1436
|
// (status alone is ~74 ms on a 1000-project tree; serial spawning
|
|
@@ -1261,7 +1451,7 @@ export async function startGitEnumeration(
|
|
|
1261
1451
|
stdin: stdin === undefined ? 'ignore' : new TextEncoder().encode(stdin),
|
|
1262
1452
|
stdout: 'pipe',
|
|
1263
1453
|
stderr: 'pipe',
|
|
1264
|
-
|
|
1454
|
+
env: env !== undefined ? { ...gitEnv(), ...env } : gitEnv(),
|
|
1265
1455
|
})
|
|
1266
1456
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
1267
1457
|
new Response(proc.stdout).bytes(),
|
|
@@ -1284,33 +1474,15 @@ export async function startGitEnumeration(
|
|
|
1284
1474
|
// `ls-files --others` for the untracked set walked the same tree a second
|
|
1285
1475
|
// time (~50 ms of CPU, concurrent with status but contending with it).
|
|
1286
1476
|
const listing = spawnGit(['ls-files', '-s', '-v', '-z', '--', ...pathspecs])
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
'--porcelain',
|
|
1297
|
-
'-z',
|
|
1298
|
-
'-uall',
|
|
1299
|
-
'--ignored=matching',
|
|
1300
|
-
'--no-renames',
|
|
1301
|
-
'--',
|
|
1302
|
-
...pathspecs,
|
|
1303
|
-
]),
|
|
1304
|
-
// The gate for whether a clean filter can rewrite bytes between the
|
|
1305
|
-
// index and the worktree: git's merged config (`core.autocrlf`) and,
|
|
1306
|
-
// from git 2.42, the attributes files it reads outside the tree
|
|
1307
|
-
// (`attributeFilesOutsideTree`). No tree scan. It replaced a
|
|
1308
|
-
// `config --get-regexp` of three keys at the same cost (1.29 against
|
|
1309
|
-
// 1.32 ms, min of 30), which could not name the default global file.
|
|
1310
|
-
// Not foldable into the rev-parse below: rev-parse prints no config.
|
|
1311
|
-
spawnGit(['var', '-l']),
|
|
1312
|
-
])
|
|
1313
|
-
// Asked while the three above run, from the memo the file hasher reads
|
|
1477
|
+
// The gate for whether a clean filter can rewrite bytes between the
|
|
1478
|
+
// index and the worktree: git's merged config (`core.autocrlf`) and,
|
|
1479
|
+
// from git 2.42, the attributes files it reads outside the tree
|
|
1480
|
+
// (`attributeFilesOutsideTree`). No tree scan. It replaced a
|
|
1481
|
+
// `config --get-regexp` of three keys at the same cost (1.29 against
|
|
1482
|
+
// 1.32 ms, min of 30), which could not name the default global file.
|
|
1483
|
+
// Not foldable into the rev-parse below: rev-parse prints no config.
|
|
1484
|
+
const varsRun = spawnGit(['var', '-l'])
|
|
1485
|
+
// Asked while the two above run, from the memo the file hasher reads
|
|
1314
1486
|
// too (`repoFacts`). `prefix` is the repo→workspace path (empty when the
|
|
1315
1487
|
// workspace root IS the git root): `ls-files` prints cwd(workspace)-
|
|
1316
1488
|
// relative paths but `status` prints repo-root-relative ones, so when the
|
|
@@ -1336,23 +1508,59 @@ export async function startGitEnumeration(
|
|
|
1336
1508
|
facts === null || facts.indexFile === ''
|
|
1337
1509
|
? undefined
|
|
1338
1510
|
: path.resolve(workspaceRoot, facts.indexFile)
|
|
1339
|
-
const
|
|
1340
|
-
{
|
|
1511
|
+
const readIndex = async (): Promise<
|
|
1512
|
+
{ bytes: Uint8Array; hash: string; st: Stats; key: string; stamp: string } | undefined
|
|
1341
1513
|
> => {
|
|
1342
1514
|
if (indexFile === undefined) return undefined
|
|
1343
1515
|
try {
|
|
1516
|
+
// Stat before the read: a write between them leaves an mtime older
|
|
1517
|
+
// than the bytes, which only makes more entries racy.
|
|
1344
1518
|
const st = lstatSync(indexFile)
|
|
1345
1519
|
const bytes = await Bun.file(indexFile).bytes()
|
|
1520
|
+
const hash = xxh3hex(bytes)
|
|
1346
1521
|
return {
|
|
1347
|
-
|
|
1522
|
+
bytes,
|
|
1523
|
+
hash,
|
|
1524
|
+
st,
|
|
1525
|
+
key: `${hash}:${pathspecKey}`,
|
|
1348
1526
|
stamp: `${st.ino}:${st.ctimeMs}:${st.size}`,
|
|
1349
|
-
ctimeMs: st.ctimeMs,
|
|
1350
1527
|
}
|
|
1351
1528
|
} catch {
|
|
1352
1529
|
return undefined
|
|
1353
1530
|
}
|
|
1354
1531
|
}
|
|
1355
|
-
const indexKeyRead =
|
|
1532
|
+
const indexKeyRead = readIndex()
|
|
1533
|
+
const statusIndex = indexKeyRead.then((read) =>
|
|
1534
|
+
read === undefined || indexFile === undefined
|
|
1535
|
+
? undefined
|
|
1536
|
+
: refreshedIndex(indexFile, read, facts!.objectFormat, spawnGit),
|
|
1537
|
+
)
|
|
1538
|
+
const running = Promise.all([
|
|
1539
|
+
listing,
|
|
1540
|
+
// `--ignored=matching` names an ignored path without descending into an
|
|
1541
|
+
// ignored directory: git applies an ignored `.gitattributes` as it does
|
|
1542
|
+
// any other, and the filter gate below must see it (A-19).
|
|
1543
|
+
// `--no-renames`: a deletion paired as an unmerged path's rename source
|
|
1544
|
+
// prints only as `UU <path>`, and the deleted file stayed trusted (A-59).
|
|
1545
|
+
statusIndex.then((index) => {
|
|
1546
|
+
const env = index === undefined ? undefined : { GIT_INDEX_FILE: index }
|
|
1547
|
+
return spawnGit(
|
|
1548
|
+
[
|
|
1549
|
+
'status',
|
|
1550
|
+
'--porcelain',
|
|
1551
|
+
'-z',
|
|
1552
|
+
'-uall',
|
|
1553
|
+
'--ignored=matching',
|
|
1554
|
+
'--no-renames',
|
|
1555
|
+
'--',
|
|
1556
|
+
...pathspecs,
|
|
1557
|
+
],
|
|
1558
|
+
undefined,
|
|
1559
|
+
env,
|
|
1560
|
+
)
|
|
1561
|
+
}),
|
|
1562
|
+
varsRun,
|
|
1563
|
+
])
|
|
1356
1564
|
const gitPrefix = facts?.prefix ?? ''
|
|
1357
1565
|
const gitDir = facts?.commonDir ?? ''
|
|
1358
1566
|
const ls = await listing
|
|
@@ -1376,7 +1584,7 @@ export async function startGitEnumeration(
|
|
|
1376
1584
|
let indexKey: string | undefined
|
|
1377
1585
|
if (
|
|
1378
1586
|
read !== undefined &&
|
|
1379
|
-
read.ctimeMs < startedAtMs - racyWindowMs(read.ctimeMs, FILE_HASH_RACY_MS)
|
|
1587
|
+
read.st.ctimeMs < startedAtMs - racyWindowMs(read.st.ctimeMs, FILE_HASH_RACY_MS)
|
|
1380
1588
|
) {
|
|
1381
1589
|
try {
|
|
1382
1590
|
const st = lstatSync(indexFile!)
|
|
@@ -1470,6 +1678,8 @@ export async function startGitEnumeration(
|
|
|
1470
1678
|
}
|
|
1471
1679
|
return {
|
|
1472
1680
|
all,
|
|
1681
|
+
tracked: gitlinks.size === 0 ? tracked : tracked.concat([...gitlinks]),
|
|
1682
|
+
scopes: pathspecs,
|
|
1473
1683
|
trusted,
|
|
1474
1684
|
dirty: worktreeDirty,
|
|
1475
1685
|
vars: vars !== null && vars.exitCode === 0 ? vars.stdout : null,
|
|
@@ -1493,7 +1703,7 @@ export async function startGitEnumeration(
|
|
|
1493
1703
|
}
|
|
1494
1704
|
return blobs
|
|
1495
1705
|
},
|
|
1496
|
-
rekey: async () => (await
|
|
1706
|
+
rekey: async () => (await readIndex())?.key,
|
|
1497
1707
|
},
|
|
1498
1708
|
// A missing blob is an answer ("missing"), never a fetch from a partial
|
|
1499
1709
|
// clone's promisor remote.
|
|
@@ -1544,6 +1754,10 @@ export async function applyGitEnumeration(
|
|
|
1544
1754
|
cache.setGitVars(enumeration.vars)
|
|
1545
1755
|
cache.enumeratedAtMs = enumeration.startedAtMs
|
|
1546
1756
|
cache.markUndecodable(enumeration.undecodable.map((rel) => path.join(workspaceRoot, rel)))
|
|
1757
|
+
cache.noteTracked(
|
|
1758
|
+
enumeration.scopes.map((rel) => path.join(workspaceRoot, rel)),
|
|
1759
|
+
enumeration.tracked.map((rel) => path.join(workspaceRoot, rel)),
|
|
1760
|
+
)
|
|
1547
1761
|
// Sort once, then each project's files are a contiguous range found
|
|
1548
1762
|
// by binary search on its `dir/` prefix — O((F+P) log F) instead of
|
|
1549
1763
|
// the O(P·F) per-project startsWith scan (54 ms at 1090 projects ×
|
package/src/cache/index.ts
CHANGED
|
@@ -5,19 +5,21 @@
|
|
|
5
5
|
// re-exported through cache.ts.
|
|
6
6
|
|
|
7
7
|
export {
|
|
8
|
+
applyCacheFlags,
|
|
8
9
|
ArtifactVanishedError,
|
|
9
10
|
Cache,
|
|
11
|
+
type CacheFlags,
|
|
10
12
|
type CacheEntry,
|
|
11
13
|
type CacheKeyInput,
|
|
12
14
|
type CacheLayer,
|
|
13
15
|
type CachePolicy,
|
|
14
16
|
cachesNothing,
|
|
15
17
|
EXECUTED_RUNS_SQL,
|
|
18
|
+
formatCachePolicy,
|
|
16
19
|
FULL_CACHE_POLICY,
|
|
17
20
|
type InvocationRecord,
|
|
18
21
|
KEYED_RUNS_SQL,
|
|
19
22
|
parseCachePolicy,
|
|
20
|
-
scopeCachePolicy,
|
|
21
23
|
type RunRecord,
|
|
22
24
|
type TaskInputRow,
|
|
23
25
|
WORKSPACE_OUTPUT_PREFIX,
|
|
@@ -28,12 +30,14 @@ export {
|
|
|
28
30
|
racyWindowMs,
|
|
29
31
|
type SchemaReset,
|
|
30
32
|
SCHEMA_VERSION,
|
|
33
|
+
vxCacheEntries,
|
|
31
34
|
} from './cache.js'
|
|
32
35
|
export { ABSENT_INPUT, absentOr, CACHE_VERSION } from './key-fold.js'
|
|
33
36
|
export {
|
|
34
37
|
addedInput,
|
|
35
38
|
type InputListing,
|
|
36
39
|
cleanOutputs,
|
|
40
|
+
committedFiles,
|
|
37
41
|
cleanWorkspaceOutputs,
|
|
38
42
|
declaresInput,
|
|
39
43
|
workspaceFilesReachInto,
|
|
@@ -65,4 +69,14 @@ export {
|
|
|
65
69
|
type BlobSizeMemo,
|
|
66
70
|
} from './git-inputs.js'
|
|
67
71
|
export { LayeredCache, type RemoteCacheLayer } from './layered-cache.js'
|
|
72
|
+
export { ScopedCache } from './scoped-cache.js'
|
|
73
|
+
export {
|
|
74
|
+
absentPluginCache,
|
|
75
|
+
pluginCache,
|
|
76
|
+
PLUGIN_ENTRY_MAX_BYTES,
|
|
77
|
+
type PluginCache,
|
|
78
|
+
type PluginCacheRemote,
|
|
79
|
+
} from './plugin-cache.js'
|
|
80
|
+
export { packRunOutputs, putRunEntry, runEntryName, writesRunStore } from './run-store.js'
|
|
68
81
|
export { ChainedCache } from './chained-cache.js'
|
|
82
|
+
export { branchSource } from './run-history.js'
|