@vzn/vx 0.0.641 → 0.0.643

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 (77) hide show
  1. package/package.json +5 -12
  2. package/skills/vx/SKILL.md +4 -4
  3. package/src/cache/archive.ts +525 -104
  4. package/src/cache/cache.ts +85 -11
  5. package/src/cache/fold-memo.ts +58 -0
  6. package/src/cache/git-inputs.ts +213 -31
  7. package/src/cache/index.ts +12 -3
  8. package/src/cache/inputs.ts +169 -79
  9. package/src/cache/key-fold.ts +90 -69
  10. package/src/cache/layer.ts +4 -6
  11. package/src/cache/layered-cache.ts +96 -10
  12. package/src/cache/output-index.ts +25 -4
  13. package/src/cache/plugin-cache.ts +23 -16
  14. package/src/cache/remote-lane.ts +25 -8
  15. package/src/cache/run-store.ts +4 -1
  16. package/src/cache/schema.ts +13 -3
  17. package/src/cache/scoped-cache.ts +74 -237
  18. package/src/cache/umask.ts +29 -0
  19. package/src/cli/docs.ts +71 -7
  20. package/src/cli/help.ts +6 -8
  21. package/src/cli/plan-format.ts +4 -1
  22. package/src/cli/plugin-commands.ts +11 -6
  23. package/src/cli/run.ts +14 -15
  24. package/src/cli/select.ts +9 -9
  25. package/src/cli/workspace-config.ts +8 -2
  26. package/src/config.ts +7 -16
  27. package/src/exec/runner.ts +5 -0
  28. package/src/exec/sandbox-runtime.ts +81 -1
  29. package/src/graph/priorities.ts +14 -1
  30. package/src/history/history.ts +43 -33
  31. package/src/index.ts +14 -0
  32. package/src/orchestrator/affected-tasks.ts +23 -2
  33. package/src/orchestrator/deferred-outputs.ts +3 -2
  34. package/src/orchestrator/execute-task.ts +20 -14
  35. package/src/orchestrator/hit-restore.ts +10 -2
  36. package/src/orchestrator/index.ts +5 -0
  37. package/src/orchestrator/lockfile-claim.ts +54 -8
  38. package/src/orchestrator/miss-save.ts +27 -17
  39. package/src/orchestrator/options.ts +9 -1
  40. package/src/orchestrator/output-host.ts +9 -1
  41. package/src/orchestrator/plan.ts +4 -0
  42. package/src/orchestrator/plugin-host.ts +38 -4
  43. package/src/orchestrator/plugin.ts +49 -5
  44. package/src/orchestrator/prepare.ts +141 -22
  45. package/src/orchestrator/projects.ts +25 -6
  46. package/src/orchestrator/run-context.ts +80 -0
  47. package/src/orchestrator/run.ts +38 -15
  48. package/src/orchestrator/sandbox-request.ts +1 -48
  49. package/src/orchestrator/save-lane.ts +17 -10
  50. package/src/orchestrator/signals.ts +7 -10
  51. package/src/orchestrator/stable-keys.ts +83 -25
  52. package/src/orchestrator/task-hash.ts +52 -12
  53. package/src/report/events.ts +12 -0
  54. package/src/report/framed-output.ts +2 -1
  55. package/src/report/logger.ts +53 -13
  56. package/src/report/status-line.ts +45 -4
  57. package/src/report/summary.ts +8 -2
  58. package/src/settings.ts +11 -11
  59. package/src/util/errors.ts +1 -1
  60. package/src/util/hidden-prompt.ts +74 -0
  61. package/src/util/index.ts +12 -2
  62. package/src/util/paths.ts +33 -14
  63. package/src/util/secrets.ts +150 -0
  64. package/src/workspace/affected.ts +182 -75
  65. package/src/workspace/config-schema.ts +81 -19
  66. package/src/workspace/index.ts +3 -0
  67. package/src/workspace/project-loader.ts +1 -0
  68. package/src/workspace/workspace.ts +17 -11
  69. package/docs/caching.md +0 -2376
  70. package/docs/cli.md +0 -3285
  71. package/docs/execution.md +0 -676
  72. package/docs/features.md +0 -178
  73. package/docs/patterns.md +0 -219
  74. package/docs/schema.md +0 -2468
  75. package/docs/security.md +0 -253
  76. package/src/cli/docs-corpus.ts +0 -21
  77. package/src/cli/docs-text.d.ts +0 -5
@@ -5,10 +5,12 @@
5
5
  // 2026-09-10: this file talks to git; `inputs.ts` decides which files a
6
6
  // task declared. Nothing here reads a config or applies a boundary.
7
7
 
8
+ import os from 'node:os'
8
9
  import path from 'node:path'
9
10
  import {
10
11
  existsSync,
11
12
  lstatSync,
13
+ mkdtempSync,
12
14
  readdirSync,
13
15
  readFileSync,
14
16
  realpathSync,
@@ -19,6 +21,7 @@ import {
19
21
  type Stats,
20
22
  } from 'node:fs'
21
23
  import {
24
+ checkIgnoreSync,
22
25
  executablePath,
23
26
  gitEnv,
24
27
  gitSpawnRefusal,
@@ -30,6 +33,9 @@ import {
30
33
  } from '../util/index.js'
31
34
  import { FILE_HASH_RACY_MS, racyWindowMs } from './layer.js'
32
35
 
36
+ /** A name no ignore rule names, standing for a file a task may create. */
37
+ const FRESH_NAME = '__vx_probe__'
38
+
33
39
  /** Three facts of the repository a directory is in: read off the disk, or one `git rev-parse`. */
34
40
  export interface RepoFacts {
35
41
  /** `--show-prefix`: the directory's path below the worktree root, `''` at the root. */
@@ -195,6 +201,47 @@ function configObjectFormat(config: string): 'sha1' | 'sha256' | undefined {
195
201
  return format
196
202
  }
197
203
 
204
+ interface PartitionDigest {
205
+ snapshot: readonly string[]
206
+ oids: OidSlot | undefined
207
+ digest: string
208
+ }
209
+
210
+ /**
211
+ * A partition's trusted OIDs, built on first read: a warm run whose keys
212
+ * come back from the fold memo reads none, and building 125,000 abs-path
213
+ * entries up front was ~90 ms of a 5,000-project warm run.
214
+ */
215
+ class OidSlot {
216
+ private built: Map<string, string> | undefined
217
+ /** Paths dropped before the map was built: taken out when it is. */
218
+ private dropped: string[] | undefined
219
+ constructor(private build: () => Map<string, string>) {}
220
+ get map(): Map<string, string> {
221
+ if (this.built === undefined) {
222
+ this.built = this.build()
223
+ for (const p of this.dropped ?? []) this.built.delete(p)
224
+ this.dropped = undefined
225
+ }
226
+ return this.built
227
+ }
228
+ /**
229
+ * Forget these paths' OIDs. An unbuilt map stays unbuilt: each restore
230
+ * marks its outputs changed, and building the map to drop them was ~28 ms
231
+ * of a 1,000-project restore whose keys no later read needed.
232
+ */
233
+ drop(paths: Iterable<string>): void {
234
+ if (this.built !== undefined) for (const p of paths) this.built.delete(p)
235
+ else (this.dropped ??= []).push(...paths)
236
+ }
237
+ }
238
+
239
+ interface IgnoredListing {
240
+ root: string
241
+ paths: readonly string[]
242
+ asked: Map<string, boolean>
243
+ }
244
+
198
245
  export class GitFilesCache extends Map<string, readonly string[]> {
199
246
  private changed = new Map<string, string[]>()
200
247
  /**
@@ -210,7 +257,9 @@ export class GitFilesCache extends Map<string, readonly string[]> {
210
257
  * (`Cache.hashFile`) computes the identical blob OID from disk, so
211
258
  * dropping is a pure perf concession, never a correctness one.
212
259
  */
213
- private oids = new Map<string, Map<string, string>>()
260
+ private oids = new Map<string, OidSlot>()
261
+ /** Per partition, `filesDigest`'s answer and the snapshot and OIDs it describes. */
262
+ private digests = new Map<string, PartitionDigest>()
214
263
  /**
215
264
  * Set when `populateGitFilesCache` stored a WORKSPACE-WIDE partition
216
265
  * (any loaded task declares `inputs.workspaceFiles`). Null when the
@@ -236,8 +285,11 @@ export class GitFilesCache extends Map<string, readonly string[]> {
236
285
  * What git tracks, by absolute path, within `trackedScopes`: a file there
237
286
  * no clean deletes (`committedIn`). Filled by the
238
287
  * enumeration, and per directory on demand for one it did not cover.
288
+ * Built on the first question: a run that cleans nothing asks none, and
289
+ * 125,000 paths joined and hashed were ~90 ms of a warm 5,000-project run.
239
290
  */
240
291
  private tracked = new Set<string>()
292
+ private trackedPending: (() => Iterable<string>)[] = []
241
293
  private trackedScopes: string[] = []
242
294
  /**
243
295
  * Under `core.ignorecase` (macOS's default volume) git's `Foo.txt` is the
@@ -256,10 +308,23 @@ export class GitFilesCache extends Map<string, readonly string[]> {
256
308
  }
257
309
 
258
310
  noteTracked(scopes: readonly string[], absPaths: readonly string[]): void {
259
- for (const p of absPaths) this.tracked.add(this.trackedKey(p))
311
+ this.noteTrackedLater(scopes, () => absPaths)
312
+ }
313
+
314
+ /** `noteTracked`, the paths asked for only when a clean asks about one. */
315
+ noteTrackedLater(scopes: readonly string[], absPaths: () => Iterable<string>): void {
316
+ this.trackedPending.push(absPaths)
260
317
  this.trackedScopes.push(...scopes)
261
318
  }
262
319
 
320
+ private isTracked(abs: string): boolean {
321
+ if (this.trackedPending.length > 0) {
322
+ for (const paths of this.trackedPending.splice(0))
323
+ for (const p of paths()) this.tracked.add(this.trackedKey(p))
324
+ }
325
+ return this.tracked.has(this.trackedKey(abs))
326
+ }
327
+
263
328
  /**
264
329
  * Whether git tracks a file under `dir`: committed (or `git add`ed)
265
330
  * files are the user's, never cleaned (owner, 2026-10-14: "never
@@ -277,7 +342,7 @@ export class GitFilesCache extends Map<string, readonly string[]> {
277
342
  if (!covered) this.noteTracked([dir], listTracked(dir))
278
343
  }
279
344
  for (let p = abs; p.length > dir.length; p = path.dirname(p))
280
- if (this.tracked.has(this.trackedKey(p))) return true
345
+ if (this.isTracked(p)) return true
281
346
  return false
282
347
  }
283
348
  }
@@ -296,6 +361,46 @@ export class GitFilesCache extends Map<string, readonly string[]> {
296
361
  */
297
362
  ignoredOutputs: ReadonlySet<string> = new Set()
298
363
 
364
+ /** What `git status --ignored=matching` named, root-relative and sorted, and the root. */
365
+ private ignored: IgnoredListing | null = null
366
+
367
+ setIgnored(root: string, paths: readonly string[]): void {
368
+ this.ignored = { root, paths, asked: new Map() }
369
+ }
370
+
371
+ /**
372
+ * Whether git ignores a new file directly in `dir` (absolute) while it
373
+ * reports an ignored path under it: `gen/*` beside a kept `gen/.gitkeep`,
374
+ * or `gen/` with a force-added file. Such a directory is ignored in all
375
+ * but the files git lists, so naming it is asking for its ignored files
376
+ * too. A pattern that ignores only some names (`*.generated.js` in a
377
+ * listed `src`) answers false. Asked once per directory per enumeration,
378
+ * and only where status named an ignored path under it.
379
+ */
380
+ newFilesIgnored(dir: string): boolean {
381
+ const ig = this.ignored
382
+ if (ig === null || ig.paths.length === 0) return false
383
+ const rel = relPosix(ig.root, dir)
384
+ if (rel === '' || rel.startsWith('../')) return false
385
+ let known = ig.asked.get(rel)
386
+ if (known !== undefined) return known
387
+ const under = `${rel}/`
388
+ let lo = 0
389
+ let hi = ig.paths.length
390
+ while (lo < hi) {
391
+ const mid = (lo + hi) >>> 1
392
+ if (ig.paths[mid]! < under) lo = mid + 1
393
+ else hi = mid
394
+ }
395
+ known = false
396
+ if (ig.paths[lo]?.startsWith(under) === true) {
397
+ const probe = `${under}${FRESH_NAME}`
398
+ known = checkIgnoreSync(ig.root, [probe])?.has(probe) === true
399
+ }
400
+ ig.asked.set(rel, known)
401
+ return known
402
+ }
403
+
299
404
  get undecodableNames(): ReadonlySet<string> {
300
405
  return this.undecodable
301
406
  }
@@ -396,19 +501,39 @@ export class GitFilesCache extends Map<string, readonly string[]> {
396
501
  else this.changed.set(partitionDir, [...relPaths])
397
502
  // These paths just changed on disk; their index OIDs (if any) no
398
503
  // longer describe the working-tree content.
399
- const partitionOids = this.oids.get(partitionDir)
400
- if (partitionOids) {
401
- for (const rel of relPaths) partitionOids.delete(path.resolve(partitionDir, rel))
402
- }
504
+ this.oids.get(partitionDir)?.drop(relPaths.map((rel) => path.resolve(partitionDir, rel)))
505
+ }
506
+
507
+ /**
508
+ * A digest of `snapshot`'s paths and index OIDs, when the enumeration
509
+ * trusted an OID for every one, none is a symlink and every name decodes:
510
+ * then the files a task resolves there, and what it folds for them, are
511
+ * a function of this digest and its declaration (`filesToken`). Undefined
512
+ * once anything moved the partition: a re-enumeration, a changed path.
513
+ */
514
+ filesDigest(projectDir: string, snapshot: readonly string[]): string | undefined {
515
+ const d = this.digests.get(projectDir)
516
+ if (d === undefined || d.snapshot !== snapshot || this.get(projectDir) !== snapshot) return
517
+ if (this.oids.get(projectDir) !== d.oids || this.changed.has(projectDir)) return
518
+ return d.digest
519
+ }
520
+
521
+ noteFilesDigest(projectDir: string, snapshot: readonly string[], digest: string): void {
522
+ this.digests.set(projectDir, { snapshot, oids: this.oids.get(projectDir), digest })
403
523
  }
404
524
 
405
525
  /** Trusted index OIDs for a project (abs path → oid), if any survive. */
406
526
  oidsFor(projectDir: string): ReadonlyMap<string, string> | undefined {
407
- return this.oids.get(projectDir)
527
+ return this.oids.get(projectDir)?.map
408
528
  }
409
529
 
410
530
  setOids(projectDir: string, oids: Map<string, string>): void {
411
- this.oids.set(projectDir, oids)
531
+ this.oids.set(projectDir, new OidSlot(() => oids))
532
+ }
533
+
534
+ /** What builds the project's OIDs when a read first asks. */
535
+ setLazyOids(projectDir: string, build: () => Map<string, string>): void {
536
+ this.oids.set(projectDir, new OidSlot(build))
412
537
  }
413
538
 
414
539
  /** Snapshot if still valid for these input globs; undefined → re-spawn. */
@@ -425,18 +550,21 @@ export class GitFilesCache extends Map<string, readonly string[]> {
425
550
  override set(key: string, value: readonly string[]): this {
426
551
  this.changed.delete(key)
427
552
  this.oids.delete(key)
553
+ this.digests.delete(key)
428
554
  return super.set(key, value)
429
555
  }
430
556
 
431
557
  override delete(key: string): boolean {
432
558
  this.changed.delete(key)
433
559
  this.oids.delete(key)
560
+ this.digests.delete(key)
434
561
  return super.delete(key)
435
562
  }
436
563
 
437
564
  override clear(): void {
438
565
  this.changed.clear()
439
566
  this.oids.clear()
567
+ this.digests.clear()
440
568
  super.clear()
441
569
  }
442
570
  }
@@ -1201,17 +1329,20 @@ function parseStatusOutput(
1201
1329
  untracked: string[]
1202
1330
  undecodable: Set<string>
1203
1331
  ignoredAttributes: boolean
1332
+ ignored: string[]
1204
1333
  } {
1205
1334
  const tokens = out.split('\0')
1206
1335
  const dirty = new Set<string>()
1207
1336
  const untracked: string[] = []
1208
1337
  const undecodable = new Set<string>()
1338
+ const ignored: string[] = []
1209
1339
  let ignoredAttributes = false
1210
1340
  for (const token of tokens) {
1211
1341
  if (token.length < 4) continue
1212
1342
  if (token[0] === '!') {
1213
1343
  if (token === '!! .gitattributes' || token.endsWith('/.gitattributes'))
1214
1344
  ignoredAttributes = true
1345
+ ignored.push(token.slice(3))
1215
1346
  continue
1216
1347
  }
1217
1348
  if (undecodableRecords.size > 0 && undecodableRecords.has(token))
@@ -1224,7 +1355,7 @@ function parseStatusOutput(
1224
1355
  // rename's source is a record of its own (items 976, A-59).
1225
1356
  dirty.add(token.slice(3))
1226
1357
  }
1227
- return { dirty, untracked, undecodable, ignoredAttributes }
1358
+ return { dirty, untracked, undecodable, ignoredAttributes, ignored }
1228
1359
  }
1229
1360
 
1230
1361
  /**
@@ -1309,6 +1440,8 @@ export interface GitEnumeration {
1309
1440
  untracked: readonly string[] | null
1310
1441
  /** Workspace-relative paths whose names are not UTF-8 (`decodeGitZ`). */
1311
1442
  undecodable: readonly string[]
1443
+ /** What `git status` named ignored, workspace-relative and sorted (a directory with its `/`). */
1444
+ ignored: readonly string[]
1312
1445
  /** `Date.now()` before the spawns: what `trusted` says is true as of no earlier. */
1313
1446
  startedAtMs: number
1314
1447
  /** The blob-size check's input (`dropResizedOids`). */
@@ -1446,8 +1579,8 @@ export async function startGitEnumeration(
1446
1579
  stdin?: string,
1447
1580
  env?: Record<string, string>,
1448
1581
  ): Promise<GitRun | null> => {
1449
- try {
1450
- const proc = Bun.spawn({
1582
+ const spawn = <Out extends 'pipe' | Bun.BunFile>(stdout: Out) =>
1583
+ Bun.spawn({
1451
1584
  // `status` refreshes the index when it can take `index.lock`, so
1452
1585
  // the user's own `git add` or `commit` failed on the lock while a
1453
1586
  // vx run held it (12 in 1,165 across 80 runs, item 880). Read-only, as
@@ -1455,19 +1588,45 @@ export async function startGitEnumeration(
1455
1588
  cmd: [executablePath('git'), '--no-optional-locks', ...args],
1456
1589
  cwd: workspaceRoot,
1457
1590
  stdin: stdin === undefined ? 'ignore' : new TextEncoder().encode(stdin),
1458
- stdout: 'pipe',
1591
+ stdout,
1459
1592
  stderr: 'pipe',
1460
1593
  env: env !== undefined ? { ...gitEnv(), ...env } : gitEnv(),
1461
1594
  })
1462
- const [stdout, stderr, exitCode] = await Promise.all([
1463
- new Response(proc.stdout).bytes(),
1464
- new Response(proc.stderr).text(),
1465
- proc.exited,
1466
- ])
1595
+ // To a file, not a pipe: the run's thread is busy discovering and
1596
+ // loading configs while git lists, and a pipe held a 10 MB listing
1597
+ // until the thread came back to drain it 64 KiB at a time (~90 ms of
1598
+ // a warm 5,000-project run). In a directory of its own, so no link
1599
+ // another user planted in a shared temp directory takes the bytes. A
1600
+ // temp directory that takes none falls back to the pipe.
1601
+ let dir: string | undefined
1602
+ try {
1603
+ dir = mkdtempSync(path.join(os.tmpdir(), 'vx-git-'))
1604
+ } catch {
1605
+ dir = undefined
1606
+ }
1607
+ try {
1608
+ let stdout: Uint8Array
1609
+ let stderr: string
1610
+ let exitCode: number
1611
+ if (dir !== undefined) {
1612
+ const out = path.join(dir, 'out')
1613
+ const proc = spawn(Bun.file(out))
1614
+ ;[stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited])
1615
+ stdout = await Bun.file(out).bytes()
1616
+ } else {
1617
+ const piped = spawn('pipe')
1618
+ ;[stdout, stderr, exitCode] = await Promise.all([
1619
+ new Response(piped.stdout).bytes(),
1620
+ new Response(piped.stderr).text(),
1621
+ piped.exited,
1622
+ ])
1623
+ }
1467
1624
  const { text, undecodable } = decodeGitZ(stdout)
1468
1625
  return { exitCode, stdout: text, stderr, undecodable }
1469
1626
  } catch {
1470
1627
  return null
1628
+ } finally {
1629
+ if (dir !== undefined) rmSync(dir, { recursive: true, force: true })
1471
1630
  }
1472
1631
  }
1473
1632
  const startedAtMs = Date.now()
@@ -1692,6 +1851,10 @@ export async function startGitEnumeration(
1692
1851
  changed: dirty === null ? null : [...dirty, ...untracked],
1693
1852
  untracked: dirty === null ? null : untracked,
1694
1853
  undecodable,
1854
+ ignored:
1855
+ parsedStatus === null
1856
+ ? []
1857
+ : [...stripPrefixFromSet(new Set(parsedStatus.ignored), gitPrefix)].sort(),
1695
1858
  startedAtMs,
1696
1859
  blobCheck: {
1697
1860
  key: indexKey,
@@ -1758,11 +1921,12 @@ export async function applyGitEnumeration(
1758
1921
  const { all, trusted } = enumeration
1759
1922
  cache.setWorktreeDirty(enumeration.dirty)
1760
1923
  cache.setGitVars(enumeration.vars)
1924
+ cache.setIgnored(workspaceRoot, enumeration.ignored)
1761
1925
  cache.enumeratedAtMs = enumeration.startedAtMs
1762
1926
  cache.markUndecodable(enumeration.undecodable.map((rel) => path.join(workspaceRoot, rel)))
1763
- cache.noteTracked(
1927
+ cache.noteTrackedLater(
1764
1928
  enumeration.scopes.map((rel) => path.join(workspaceRoot, rel)),
1765
- enumeration.tracked.map((rel) => path.join(workspaceRoot, rel)),
1929
+ () => enumeration.tracked.map((rel) => path.join(workspaceRoot, rel)),
1766
1930
  )
1767
1931
  // Sort once, then each project's files are a contiguous range found
1768
1932
  // by binary search on its `dir/` prefix — O((F+P) log F) instead of
@@ -1780,6 +1944,8 @@ export async function applyGitEnumeration(
1780
1944
  }
1781
1945
  }
1782
1946
  const sorted = inOrder ? all : [...all].sort()
1947
+ const undecodable =
1948
+ enumeration.undecodable.length === 0 ? undefined : new Set(enumeration.undecodable)
1783
1949
  // Git prints normalized relative paths, so under an absolute, normalized
1784
1950
  // root a join is a concatenation: `path.join` per tracked file and
1785
1951
  // `path.relative` per project were most of this pass (I-30's rule).
@@ -1803,6 +1969,11 @@ export async function applyGitEnumeration(
1803
1969
  }
1804
1970
  return lo
1805
1971
  }
1972
+ const rootOids = (): Map<string, string> => {
1973
+ const oids = new Map<string, string>()
1974
+ for (const [rel, oid] of trusted) oids.set(abs(rel), oid)
1975
+ return oids
1976
+ }
1806
1977
  for (const projectDir of projectDirs) {
1807
1978
  const spelled =
1808
1979
  base !== undefined && projectDir.startsWith(base) && path.normalize(projectDir) === projectDir
@@ -1815,23 +1986,35 @@ export async function applyGitEnumeration(
1815
1986
  const relPrefix = spelled.normalize('NFC')
1816
1987
  if (relPrefix === '' || relPrefix === '.') {
1817
1988
  cache.set(projectDir, all)
1818
- const rootOids = new Map<string, string>()
1819
- for (const [rel, oid] of trusted) rootOids.set(abs(rel), oid)
1820
- cache.setOids(projectDir, rootOids)
1989
+ cache.setLazyOids(projectDir, rootOids)
1821
1990
  continue
1822
1991
  }
1823
1992
  const prefix = `${relPrefix}/`
1824
1993
  const start = lowerBound(prefix)
1825
1994
  const end = lowerBound(`${relPrefix}0`)
1826
1995
  const matches: string[] = []
1827
- const projOids = new Map<string, string>()
1828
1996
  const projAbs =
1829
1997
  relPrefix === spelled ? abs : (rel: string) => path.join(projectDir, rel.slice(prefix.length))
1998
+ const projOids = (): Map<string, string> => {
1999
+ const oids = new Map<string, string>()
2000
+ for (let i = start; i < end; i++) {
2001
+ const rel = sorted[i]!
2002
+ const oid = trusted.get(rel)
2003
+ if (oid !== undefined) oids.set(projAbs(rel), oid)
2004
+ }
2005
+ return oids
2006
+ }
2007
+ // One hash of the records joined: one per record was ~25 ms of 125,000.
2008
+ let records: string | undefined = ''
1830
2009
  for (let i = start; i < end; i++) {
1831
2010
  const rel = sorted[i]!
1832
- matches.push(rel.slice(prefix.length))
2011
+ const own = rel.slice(prefix.length)
2012
+ matches.push(own)
2013
+ if (records === undefined) continue
1833
2014
  const oid = trusted.get(rel)
1834
- if (oid !== undefined) projOids.set(projAbs(rel), oid)
2015
+ if (oid === undefined || oid.startsWith('120000:') || undecodable?.has(rel) === true) {
2016
+ records = undefined
2017
+ } else records += `${own}\0${oid}\n`
1835
2018
  }
1836
2019
  // An empty slice is a directory git did not see, not an empty project:
1837
2020
  // a project has at least its package.json, tracked or untracked. A
@@ -1847,15 +2030,14 @@ export async function applyGitEnumeration(
1847
2030
  // trusted from here) — one spawn per such project per run, nothing for
1848
2031
  // a workspace without one.
1849
2032
  if (matches.length === 0) continue
1850
- // setOids AFTER set — set() drops the project's OID slot.
2033
+ // setLazyOids AFTER set — set() drops the project's OID slot.
1851
2034
  cache.set(projectDir, matches)
1852
- cache.setOids(projectDir, projOids)
2035
+ cache.setLazyOids(projectDir, projOids)
2036
+ if (records !== undefined) cache.noteFilesDigest(projectDir, matches, xxh3hex(records))
1853
2037
  }
1854
2038
  if (workspaceWide) {
1855
- const rootOids = new Map<string, string>()
1856
- for (const [rel, oid] of trusted) rootOids.set(abs(rel), oid)
1857
2039
  cache.set(workspaceRoot, all)
1858
- cache.setOids(workspaceRoot, rootOids)
2040
+ cache.setLazyOids(workspaceRoot, rootOids)
1859
2041
  cache.setWorkspaceRoot(workspaceRoot)
1860
2042
  }
1861
2043
  }
@@ -34,7 +34,8 @@ export {
34
34
  SCHEMA_VERSION,
35
35
  vxCacheEntries,
36
36
  } from './cache.js'
37
- export { ABSENT_INPUT, absentOr, CACHE_VERSION } from './key-fold.js'
37
+ export { ABSENT_INPUT, absentOr, CACHE_VERSION, foldHead } from './key-fold.js'
38
+ export type { FoldMemo } from './fold-memo.js'
38
39
  export { measureFsClockLag, setFsClockLag } from './fs-clock.js'
39
40
  export { filesBehind } from './linked-inputs.js'
40
41
  export {
@@ -46,7 +47,10 @@ export {
46
47
  declaresInput,
47
48
  namedOutright,
48
49
  workspaceFilesReachInto,
50
+ filesToken,
49
51
  resolveInputs,
52
+ type ResolveInputsArgs,
53
+ resolveOutputTree,
50
54
  resolveOutputs,
51
55
  resolveWorkspaceOutputs,
52
56
  sweepRestoreTemps,
@@ -75,9 +79,14 @@ export {
75
79
  type GitEnumeration,
76
80
  type BlobSizeMemo,
77
81
  } from './git-inputs.js'
78
- export { LayeredCache, type RemoteCacheLayer } from './layered-cache.js'
82
+ export {
83
+ LayeredCache,
84
+ type RemoteCacheLayer,
85
+ type RemoteKey,
86
+ type RemoteOpenContext,
87
+ } from './layered-cache.js'
79
88
  export type { ResolvedRemote } from './remote-lane.js'
80
- export { ScopedCache } from './scoped-cache.js'
89
+ export { BranchScope } from './scoped-cache.js'
81
90
  export {
82
91
  absentPluginCache,
83
92
  pluginCache,