@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.
Files changed (151) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +246 -213
  3. package/docs/cli.md +791 -705
  4. package/docs/execution.md +68 -76
  5. package/docs/features.md +47 -42
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +392 -205
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +4 -4
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +28 -13
  19. package/src/bin.ts +30 -9
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +124 -53
  22. package/src/cache/chained-cache.ts +4 -0
  23. package/src/cache/git-inputs.ts +277 -63
  24. package/src/cache/index.ts +15 -1
  25. package/src/cache/inputs.ts +102 -36
  26. package/src/cache/key-fold.ts +8 -7
  27. package/src/cache/layer.ts +15 -18
  28. package/src/cache/layered-cache.ts +20 -102
  29. package/src/cache/plugin-cache.ts +248 -0
  30. package/src/cache/policy.ts +32 -27
  31. package/src/cache/run-history.ts +104 -14
  32. package/src/cache/run-store.ts +77 -0
  33. package/src/cache/schema.ts +58 -31
  34. package/src/cache/scoped-cache.ts +237 -0
  35. package/src/cli/cache.ts +173 -34
  36. package/src/cli/completions.ts +11 -7
  37. package/src/cli/core-alias.ts +1 -1
  38. package/src/cli/foreign-flags.ts +13 -12
  39. package/src/cli/help.ts +110 -70
  40. package/src/cli/index.ts +67 -24
  41. package/src/cli/info.ts +43 -27
  42. package/src/cli/init.ts +115 -407
  43. package/src/cli/last.ts +157 -9
  44. package/src/cli/lock.ts +356 -122
  45. package/src/cli/plan-format.ts +7 -5
  46. package/src/cli/plugin-commands.ts +81 -14
  47. package/src/cli/plugin-templates.ts +11 -11
  48. package/src/cli/run.ts +116 -160
  49. package/src/cli/select.ts +50 -47
  50. package/src/cli/show.ts +83 -59
  51. package/src/cli/task-verb.ts +6 -13
  52. package/src/cli/upgrade.ts +415 -149
  53. package/src/cli/watch-cycle.ts +9 -2
  54. package/src/cli/watch-filter.ts +39 -61
  55. package/src/cli/watch-fs.ts +29 -16
  56. package/src/cli/watch-judge.ts +18 -4
  57. package/src/cli/watch-set.ts +16 -42
  58. package/src/cli/watch.ts +227 -230
  59. package/src/cli/why.ts +38 -18
  60. package/src/cli/workspace-config.ts +33 -70
  61. package/src/config.ts +169 -70
  62. package/src/exec/env.ts +1 -31
  63. package/src/exec/executor.ts +1 -5
  64. package/src/exec/kill-tree.ts +2 -1
  65. package/src/exec/sandbox-runtime.ts +12 -1
  66. package/src/exec/sandbox-violations.ts +9 -1
  67. package/src/graph/dependency-spec.ts +1 -1
  68. package/src/graph/index.ts +1 -0
  69. package/src/graph/priorities.ts +1 -1
  70. package/src/graph/scheduler.ts +27 -7
  71. package/src/graph/task-graph.ts +76 -24
  72. package/src/handoff.ts +169 -0
  73. package/src/index.ts +35 -58
  74. package/src/orchestrator/affected-tasks.ts +15 -8
  75. package/src/orchestrator/command-windows.ts +44 -0
  76. package/src/orchestrator/deferred-outputs.ts +12 -10
  77. package/src/orchestrator/doctor.ts +98 -110
  78. package/src/orchestrator/download-policy.ts +3 -3
  79. package/src/orchestrator/events.ts +19 -3
  80. package/src/orchestrator/excluded-keys.ts +0 -2
  81. package/src/orchestrator/execute-task.ts +147 -69
  82. package/src/orchestrator/failure-mode.ts +19 -10
  83. package/src/orchestrator/fingerprint-watch.ts +18 -15
  84. package/src/orchestrator/forecast.ts +10 -2
  85. package/src/orchestrator/framed-output.ts +9 -5
  86. package/src/orchestrator/graph-stage.ts +5 -5
  87. package/src/orchestrator/history.ts +126 -30
  88. package/src/orchestrator/hit-restore.ts +15 -3
  89. package/src/orchestrator/index.ts +20 -5
  90. package/src/orchestrator/local-shortcircuit.ts +2 -1
  91. package/src/orchestrator/lockfile-claim.ts +79 -20
  92. package/src/orchestrator/logger.ts +80 -41
  93. package/src/orchestrator/metrics.ts +78 -9
  94. package/src/orchestrator/miss-save.ts +3 -0
  95. package/src/orchestrator/options.ts +42 -29
  96. package/src/orchestrator/output-host.ts +72 -0
  97. package/src/orchestrator/plan.ts +6 -8
  98. package/src/orchestrator/plugin-host.ts +100 -66
  99. package/src/orchestrator/plugin.ts +173 -67
  100. package/src/orchestrator/prepare.ts +163 -78
  101. package/src/orchestrator/projects.ts +105 -99
  102. package/src/orchestrator/remote-prefetch.ts +11 -6
  103. package/src/orchestrator/run-context.ts +25 -6
  104. package/src/orchestrator/run-failures.ts +17 -8
  105. package/src/orchestrator/run-lock.ts +1 -1
  106. package/src/orchestrator/run-records.ts +1 -1
  107. package/src/orchestrator/run-report.ts +9 -9
  108. package/src/orchestrator/run-settings.ts +48 -0
  109. package/src/orchestrator/run.ts +244 -119
  110. package/src/orchestrator/sandbox-fix.ts +39 -20
  111. package/src/orchestrator/sandbox-request.ts +84 -13
  112. package/src/orchestrator/signals.ts +1 -1
  113. package/src/orchestrator/stable-keys.ts +13 -11
  114. package/src/orchestrator/status-line.ts +49 -7
  115. package/src/orchestrator/summary.ts +115 -106
  116. package/src/orchestrator/task-hash.ts +12 -83
  117. package/src/orchestrator/telemetry.ts +4 -4
  118. package/src/settings.ts +428 -0
  119. package/src/{orchestrator → util}/colors.ts +5 -0
  120. package/src/util/config-frame.ts +31 -2
  121. package/src/util/errors.ts +33 -1
  122. package/src/util/essential-env.ts +31 -0
  123. package/src/util/hide-env.ts +107 -0
  124. package/src/util/index.ts +16 -3
  125. package/src/util/paths.ts +10 -0
  126. package/src/util/refusal-notice.ts +111 -0
  127. package/src/util/report.ts +31 -0
  128. package/src/util/settle.ts +49 -30
  129. package/src/util/size.ts +23 -4
  130. package/src/util/timing.ts +3 -2
  131. package/src/util/ui.ts +186 -0
  132. package/src/util/ulid.ts +1 -1
  133. package/src/util/verbs.ts +0 -15
  134. package/src/workspace/affected.ts +84 -232
  135. package/src/workspace/config-eval.ts +15 -20
  136. package/src/workspace/config-imports.ts +78 -3
  137. package/src/workspace/config-schema.ts +209 -268
  138. package/src/workspace/dependency-rules.ts +65 -0
  139. package/src/workspace/filter.ts +2 -2
  140. package/src/workspace/fingerprint.ts +11 -121
  141. package/src/workspace/index.ts +20 -27
  142. package/src/workspace/load-reads.ts +4 -4
  143. package/src/workspace/lockfile.ts +96 -140
  144. package/src/workspace/migration.ts +307 -215
  145. package/src/workspace/package-graph.ts +30 -216
  146. package/src/workspace/project-loader.ts +80 -285
  147. package/src/workspace/repo-id.ts +2 -1
  148. package/src/workspace/workspace.ts +363 -831
  149. package/src/cache/config-evals.ts +0 -130
  150. package/src/workspace/config-cache.ts +0 -661
  151. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -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 { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
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 (rel === '.gitattributes' || rel.endsWith('/.gitattributes')) {
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 (rel === '.gitattributes' || rel.endsWith('/.gitattributes')) return true
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
- if (existsSync(path.join(dir, '.gitattributes'))) return true
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
- ...(env !== undefined ? { env: { ...process.env, ...env } } : {}),
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
- const running = Promise.all([
1288
- listing,
1289
- // `--ignored=matching` names an ignored path without descending into an
1290
- // ignored directory: git applies an ignored `.gitattributes` as it does
1291
- // any other, and the filter gate below must see it (A-19).
1292
- // `--no-renames`: a deletion paired as an unmerged path's rename source
1293
- // prints only as `UU <path>`, and the deleted file stayed trusted (A-59).
1294
- spawnGit([
1295
- 'status',
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 readIndexKey = async (): Promise<
1340
- { key: string; stamp: string; ctimeMs: number } | undefined
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
- key: `${xxh3hex(bytes)}:${pathspecKey}`,
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 = readIndexKey()
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 readIndexKey())?.key,
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 ×
@@ -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'