@vzn/vx 0.0.652 → 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.
Files changed (167) hide show
  1. package/README.md +2 -2
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +1 -0
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +25 -3
  8. package/src/cache/archive.ts +196 -31
  9. package/src/cache/cache.ts +863 -225
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +22 -5
  16. package/src/cache/index.ts +10 -1
  17. package/src/cache/inputs.ts +573 -152
  18. package/src/cache/key-fold.ts +1 -1
  19. package/src/cache/layer.ts +23 -10
  20. package/src/cache/layered-cache.ts +28 -7
  21. package/src/cache/output-index.ts +106 -44
  22. package/src/cache/plugin-cache.ts +2 -2
  23. package/src/cache/policy.ts +6 -2
  24. package/src/cache/remote-lane.ts +3 -1
  25. package/src/cache/run-history.ts +50 -10
  26. package/src/cache/schema.ts +42 -9
  27. package/src/cli/cache.ts +147 -34
  28. package/src/cli/completions.ts +2 -2
  29. package/src/cli/core-alias.ts +9 -2
  30. package/src/cli/docs.ts +4 -2
  31. package/src/cli/help.ts +25 -2
  32. package/src/cli/index.ts +38 -7
  33. package/src/cli/info.ts +4 -3
  34. package/src/cli/init.ts +55 -20
  35. package/src/cli/last.ts +184 -41
  36. package/src/cli/lock.ts +187 -24
  37. package/src/cli/plan-format.ts +24 -21
  38. package/src/cli/plugin-commands.ts +6 -1
  39. package/src/cli/plugin-templates.ts +1 -1
  40. package/src/cli/run.ts +68 -34
  41. package/src/cli/select.ts +160 -64
  42. package/src/cli/show.ts +63 -18
  43. package/src/cli/task-verb.ts +12 -7
  44. package/src/cli/upgrade.ts +210 -76
  45. package/src/cli/watch-filter.ts +88 -15
  46. package/src/cli/watch-fs.ts +34 -47
  47. package/src/cli/watch-judge.ts +72 -8
  48. package/src/cli/watch-set.ts +6 -4
  49. package/src/cli/watch.ts +179 -25
  50. package/src/cli/why.ts +107 -33
  51. package/src/cli/workspace-config.ts +11 -1
  52. package/src/config.ts +9 -0
  53. package/src/exec/env.ts +3 -1
  54. package/src/exec/executor.ts +76 -15
  55. package/src/exec/kill-tree.ts +7 -7
  56. package/src/exec/runner.ts +270 -108
  57. package/src/exec/sandbox-binds.ts +109 -18
  58. package/src/exec/sandbox-runtime.ts +134 -37
  59. package/src/exec/sandbox-violations.ts +250 -46
  60. package/src/exec/spawn-off.ts +10 -5
  61. package/src/exec/warm-shell.ts +29 -7
  62. package/src/graph/folds.ts +89 -16
  63. package/src/graph/index.ts +8 -0
  64. package/src/graph/priorities.ts +8 -5
  65. package/src/graph/scheduler.ts +173 -38
  66. package/src/graph/stand-ins.ts +36 -0
  67. package/src/graph/task-graph.ts +327 -141
  68. package/src/graph/task-status.ts +5 -1
  69. package/src/handoff.ts +45 -10
  70. package/src/history/failure-mode.ts +15 -5
  71. package/src/history/history.ts +103 -30
  72. package/src/history/index.ts +1 -0
  73. package/src/history/metrics.ts +189 -69
  74. package/src/index.ts +2 -1
  75. package/src/orchestrator/admission.ts +13 -9
  76. package/src/orchestrator/affected-tasks.ts +35 -13
  77. package/src/orchestrator/command-windows.ts +8 -5
  78. package/src/orchestrator/deferred-outputs.ts +41 -12
  79. package/src/orchestrator/doctor.ts +21 -9
  80. package/src/orchestrator/download-policy.ts +3 -3
  81. package/src/orchestrator/excluded-keys.ts +14 -2
  82. package/src/orchestrator/execute-args.ts +9 -2
  83. package/src/orchestrator/execute-task.ts +564 -123
  84. package/src/orchestrator/graph-memo.ts +26 -4
  85. package/src/orchestrator/graph-stage.ts +27 -4
  86. package/src/orchestrator/held-servers.ts +36 -14
  87. package/src/orchestrator/hit-restore.ts +136 -87
  88. package/src/orchestrator/index.ts +4 -1
  89. package/src/orchestrator/keyed-projects.ts +25 -0
  90. package/src/orchestrator/local-floor.ts +21 -3
  91. package/src/orchestrator/lockfile-claim.ts +30 -4
  92. package/src/orchestrator/miss-save.ts +33 -53
  93. package/src/orchestrator/options.ts +13 -0
  94. package/src/orchestrator/output-host.ts +16 -4
  95. package/src/orchestrator/output-log.ts +1 -54
  96. package/src/orchestrator/persistent-task.ts +31 -17
  97. package/src/orchestrator/persistent.ts +152 -15
  98. package/src/orchestrator/placement.ts +81 -15
  99. package/src/orchestrator/plan.ts +37 -15
  100. package/src/orchestrator/plugin-host.ts +232 -108
  101. package/src/orchestrator/plugin.ts +53 -27
  102. package/src/orchestrator/prepare.ts +194 -38
  103. package/src/orchestrator/projects.ts +89 -10
  104. package/src/orchestrator/read-only.ts +115 -0
  105. package/src/orchestrator/run-context.ts +6 -2
  106. package/src/orchestrator/run-failures.ts +43 -14
  107. package/src/orchestrator/run-id.ts +13 -2
  108. package/src/orchestrator/run-keys.ts +87 -28
  109. package/src/orchestrator/run-live.ts +16 -13
  110. package/src/orchestrator/run-lock.ts +27 -4
  111. package/src/orchestrator/run-records.ts +12 -10
  112. package/src/orchestrator/run-report.ts +2 -1
  113. package/src/orchestrator/run-stop.ts +17 -9
  114. package/src/orchestrator/run.ts +443 -107
  115. package/src/orchestrator/sandbox-fix.ts +40 -12
  116. package/src/orchestrator/sandbox-request.ts +286 -59
  117. package/src/orchestrator/save-lane.ts +24 -13
  118. package/src/orchestrator/shell-verdict.ts +20 -1
  119. package/src/orchestrator/signals.ts +103 -8
  120. package/src/orchestrator/stable-keys.ts +17 -9
  121. package/src/orchestrator/task-artifacts.ts +15 -2
  122. package/src/orchestrator/task-hash.ts +187 -30
  123. package/src/orchestrator/telemetry-host.ts +25 -6
  124. package/src/orchestrator/telemetry.ts +80 -12
  125. package/src/orchestrator/upstream.ts +3 -1
  126. package/src/report/events.ts +20 -3
  127. package/src/report/forecast.ts +103 -37
  128. package/src/report/framed-output.ts +165 -157
  129. package/src/report/index.ts +2 -1
  130. package/src/report/logger.ts +107 -30
  131. package/src/report/plain-output.ts +13 -9
  132. package/src/report/status-line.ts +28 -55
  133. package/src/report/summary.ts +57 -14
  134. package/src/settings.ts +42 -6
  135. package/src/ui/components.ts +36 -42
  136. package/src/ui/index.ts +3 -1
  137. package/src/ui/live-region.ts +67 -8
  138. package/src/ui/terminal.ts +12 -0
  139. package/src/ui/theme.ts +16 -1
  140. package/src/util/config-frame.ts +17 -3
  141. package/src/util/edit-distance.ts +17 -1
  142. package/src/util/errors.ts +23 -7
  143. package/src/util/foreign-cache-dir.ts +60 -0
  144. package/src/util/git-ignored.ts +59 -5
  145. package/src/util/index.ts +15 -1
  146. package/src/util/paths.ts +50 -6
  147. package/src/util/plain-text.ts +19 -0
  148. package/src/util/secret-mask.ts +57 -1
  149. package/src/util/settle.ts +16 -0
  150. package/src/util/size.ts +12 -2
  151. package/src/util/write-file.ts +86 -9
  152. package/src/workspace/affected.ts +225 -27
  153. package/src/workspace/config-bytecode.ts +23 -4
  154. package/src/workspace/config-eval.ts +90 -16
  155. package/src/workspace/config-imports.ts +96 -41
  156. package/src/workspace/config-schema.ts +126 -25
  157. package/src/workspace/dependencies-lock.ts +7 -14
  158. package/src/workspace/filter.ts +10 -1
  159. package/src/workspace/index.ts +3 -1
  160. package/src/workspace/json-data.ts +34 -8
  161. package/src/workspace/lockfile.ts +86 -37
  162. package/src/workspace/migration.ts +46 -25
  163. package/src/workspace/module-state.ts +62 -0
  164. package/src/workspace/nested-dirs.ts +8 -0
  165. package/src/workspace/project-loader.ts +276 -32
  166. package/src/workspace/repo-id.ts +29 -6
  167. 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 partial = isPartialClone(args.workspaceRoot).catch(() => false)
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, ['ls-files', '--others', '--exclude-standard', '-z']))(),
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
- (s) =>
336
- !VX_OWN.has(s) &&
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(s) !== REVIEW_FILE || !reviewCopies.has(path.join(rootDir, s))),
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(...(await linkedChanges(args.workspaceRoot, links, changed, dirLinkSet)))
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(...(await flaggedChanges(args.workspaceRoot, flagged, await listIndex(), changed)))
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
- for (const name of await args.workspaceGlobOwners(changed, nestedDirs, dirLinks)) owned.add(name)
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
- const hash: IndexEntry[] = []
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
- const files: { rel: string; oid: string }[] = []
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
- /** A remote that serves missing objects on demand: `--filter` on a clone or a fetch. */
1098
- async function isPartialClone(workspaceRoot: string): Promise<boolean> {
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
- ['config', '--get-regexp', String.raw`^(remote\..*\.promisor|extensions\.partialclone)$`],
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
- return out.split('\n').some((l) => l.startsWith('extensions.') || l.endsWith(' true'))
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, and origin did not ` +
1276
- 'give the history between them: fetch it (`git fetch --unshallow origin`).',
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: `isPartialClone` may have answered first. */
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
- ? ' This clone is shallow and origin did not give it: fetch the base (`git fetch --depth=1 origin <base>`).'
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
  }
@@ -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 = 2
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),