@vzn/vx 0.0.594 → 0.0.595

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 (54) hide show
  1. package/package.json +5 -5
  2. package/src/bin.ts +26 -11
  3. package/src/cache/archive.ts +2 -2
  4. package/src/cache/cache.ts +96 -90
  5. package/src/cache/config-evals.ts +19 -4
  6. package/src/cache/git-inputs.ts +19 -8
  7. package/src/cache/inputs.ts +110 -37
  8. package/src/cache/layered-cache.ts +25 -2
  9. package/src/cache/tar-stream.ts +1 -1
  10. package/src/cli/cache.ts +1 -11
  11. package/src/cli/completions.ts +42 -6
  12. package/src/cli/foreign-flags.ts +25 -4
  13. package/src/cli/help.ts +5 -3
  14. package/src/cli/init.ts +1 -1
  15. package/src/cli/plan-format.ts +9 -0
  16. package/src/cli/plugin-templates.ts +2 -2
  17. package/src/cli/run.ts +29 -27
  18. package/src/cli/select.ts +30 -3
  19. package/src/cli/show.ts +42 -3
  20. package/src/cli/watch-filter.ts +14 -1
  21. package/src/cli/watch-fs.ts +15 -7
  22. package/src/cli/watch-judge.ts +1 -1
  23. package/src/cli/watch.ts +18 -11
  24. package/src/config.ts +5 -5
  25. package/src/exec/env.ts +5 -0
  26. package/src/exec/index.ts +1 -0
  27. package/src/exec/proc-sample.ts +5 -1
  28. package/src/exec/runner.ts +98 -40
  29. package/src/exec/sandbox-paths.ts +11 -5
  30. package/src/exec/sandbox-runtime.ts +20 -4
  31. package/src/graph/index.ts +1 -0
  32. package/src/graph/scheduler.ts +1 -1
  33. package/src/index.ts +6 -0
  34. package/src/orchestrator/execute-task.ts +29 -7
  35. package/src/orchestrator/hit-restore.ts +6 -3
  36. package/src/orchestrator/local-shortcircuit.ts +4 -1
  37. package/src/orchestrator/lockfile-claim.ts +43 -46
  38. package/src/orchestrator/plan.ts +25 -1
  39. package/src/orchestrator/plugin-host.ts +25 -3
  40. package/src/orchestrator/plugin.ts +2 -1
  41. package/src/orchestrator/run.ts +52 -8
  42. package/src/orchestrator/signals.ts +5 -2
  43. package/src/orchestrator/summary.ts +14 -1
  44. package/src/util/index.ts +7 -1
  45. package/src/util/secret-mask.ts +36 -0
  46. package/src/util/settle.ts +15 -0
  47. package/src/workspace/affected.ts +30 -8
  48. package/src/workspace/config-cache.ts +25 -4
  49. package/src/workspace/config-imports.ts +1 -1
  50. package/src/workspace/config-schema.ts +48 -6
  51. package/src/workspace/migrate-scripts.ts +1 -1
  52. package/src/workspace/package-graph.ts +15 -3
  53. package/src/workspace/project-loader.ts +72 -0
  54. package/src/workspace/workspace.ts +82 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.594",
3
+ "version": "0.0.595",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.594",
25
- "@vzn/vx-linux-arm64": "0.0.594",
26
- "@vzn/vx-darwin-x64": "0.0.594",
27
- "@vzn/vx-darwin-arm64": "0.0.594"
24
+ "@vzn/vx-linux-x64": "0.0.595",
25
+ "@vzn/vx-linux-arm64": "0.0.595",
26
+ "@vzn/vx-darwin-x64": "0.0.595",
27
+ "@vzn/vx-darwin-arm64": "0.0.595"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/bin.ts CHANGED
@@ -24,17 +24,10 @@ async function main(): Promise<void> {
24
24
  // costs). Registered before any verb runs; the façade loads on first use.
25
25
  registerCoreAlias(() => import('./index.js') as Promise<Record<string, unknown>>)
26
26
  const code = await run(argv)
27
- // NOTHING calls `process.exit` here, and that is the fix rather than a
28
- // simplification. Bun drops what a pipe has not yet taken when
29
- // `process.exit` follows a large write: 300 KB written then exit
30
- // delivered 64 KiB (128 KiB after a tick), and `vx history --format
31
- // json` on a 300-project workspace was cut mid-string (2026-09-15).
32
- // `stdout.end(cb)` fixed that on 1.4.2 — and on 1.3.11 the callback
33
- // still fires early: 2 MiB written, 214 KB delivered (2026-09-20).
34
- // Setting the code and letting the loop drain is the one form that
35
- // does not depend on when a runtime decides a pipe is flushed; it
36
- // costs a hang if a verb leaves a handle open, which the suite's
37
- // several hundred spawns of this binary would show at once.
27
+ // Not `process.exit` here: Bun drops what a pipe has not yet taken
28
+ // when it follows a large write (300 KB written, 64 KiB delivered;
29
+ // `vx history --format json` was cut mid-string, 2026-09-15). The code
30
+ // is set, and `exitOnceFlushed` exits after both streams have ended.
38
31
  process.exitCode = code
39
32
  } catch (err) {
40
33
  const { fsRefusalHint, isFsRefusal, isOutOfFds, isUserError, OUT_OF_FDS_HINT } =
@@ -65,6 +58,28 @@ async function main(): Promise<void> {
65
58
  process.exitCode = 1
66
59
  }
67
60
  settled = true
61
+ await exitOnceFlushed()
62
+ }
63
+
64
+ /**
65
+ * Exit once stdout and stderr have ended, with the code the verb set. A
66
+ * config can leave a timer behind (a top-level `setInterval`, an await that
67
+ * hit its evaluation budget), and the loop it holds never drained: vx
68
+ * printed its verdict and hung for good. Only after `main` has settled, so
69
+ * `vx watch`, `vx mcp` and a run's kept-alive tasks end when their verb
70
+ * does. `end(cb)` delivers a large write whole on Bun 1.4.2 (4 MiB under a
71
+ * slow reader; `write('', cb)` delivered 450 KB), the floor `engines.bun`
72
+ * holds. A stream whose end never calls back leaves the loop to drain. A
73
+ * signal's handler exits by itself, with the signal's code.
74
+ */
75
+ async function exitOnceFlushed(): Promise<void> {
76
+ if ((await import('./util/index.js')).exitClaimedBySignal()) return
77
+ let open = 2
78
+ const ended = (): void => {
79
+ if (--open === 0) process.exit()
80
+ }
81
+ process.stdout.end(ended)
82
+ process.stderr.end(ended)
68
83
  }
69
84
 
70
85
  // The loop drained while the verb was still pending: something it awaits
@@ -168,7 +168,7 @@ export interface ExecUsage {
168
168
  }
169
169
 
170
170
  /** One regular file in an artifact, with its restore metadata resolved. */
171
- export interface ArchiveEntry {
171
+ interface ArchiveEntry {
172
172
  /** Full archive-relative name, e.g. `outputs/dist/index.js`. */
173
173
  name: string
174
174
  size: number
@@ -192,7 +192,7 @@ export class ArchiveSecurityError extends Error {
192
192
  }
193
193
 
194
194
  /** What `packArtifactStream` packs: stdout plus archive name → absolute source path. */
195
- export interface PackArgs {
195
+ interface PackArgs {
196
196
  /** The cache key, recorded in the sidecar (item 943); ingest refuses an artifact without it. */
197
197
  key?: string
198
198
  stdout: string
@@ -44,6 +44,7 @@ import {
44
44
  openSync,
45
45
  readdirSync,
46
46
  renameSync,
47
+ utimesSync,
47
48
  writeFileSync,
48
49
  } from 'node:fs'
49
50
  import { readdir, rm, stat, unlink, writeFile } from 'node:fs/promises'
@@ -242,6 +243,30 @@ export const SCHEMA_VERSION = 'v32'
242
243
  /** The tables a store holds: dropped from a workspace index that held them itself. */
243
244
  const STORE_TABLES = ['entry_inputs', 'output_files', 'entry_stdout', 'store_meta', 'entries']
244
245
 
246
+ /**
247
+ * An index that records a schema other than this vx's: the one an opener
248
+ * that writes resets. Reads the recorded version alone and touches
249
+ * nothing; an unreadable file is left to the open to refuse.
250
+ */
251
+ function indexOfAnotherSchema(dbFile: string): boolean {
252
+ let found: string | undefined
253
+ try {
254
+ const db = new Database(dbFile, { readonly: true })
255
+ try {
256
+ found = (
257
+ db.query("SELECT value FROM schema_meta WHERE key = 'version'").get() as {
258
+ value: string
259
+ } | null
260
+ )?.value
261
+ } finally {
262
+ closeDb(db)
263
+ }
264
+ } catch {
265
+ return false
266
+ }
267
+ return found !== undefined && found !== SCHEMA_VERSION
268
+ }
269
+
245
270
  /** An entry row with its stdout, which lives apart (v29); none stored reads as ''. */
246
271
  const SELECT_ENTRY =
247
272
  "SELECT e.*, COALESCE(s.stdout, '') AS stdout FROM entries e LEFT JOIN entry_stdout s ON s.hash = e.hash"
@@ -608,41 +633,6 @@ export class Cache implements CacheLayer {
608
633
  return new Cache(cacheDir, undefined, undefined, undefined, 'inspect')
609
634
  }
610
635
 
611
- /**
612
- * What a real prune reaps from an index of an EARLIER schema, which it
613
- * resets first, leaving every aged artifact row-less: a dry run that
614
- * refused it could not preview the biggest prune there is, the one
615
- * after an upgrade (item 1083). Reads the recorded version alone and
616
- * touches nothing. Null for an absent, current, newer or unreadable
617
- * index; `Cache.inspect` answers those.
618
- */
619
- static async orphansBeforeReset(
620
- cacheDir: string,
621
- ): Promise<{ found: string; orphans: number; orphanBytes: number } | null> {
622
- const dbFile = path.join(cacheDir, 'cache.db')
623
- if (!existsSync(dbFile)) return null
624
- let found: string | undefined
625
- try {
626
- const db = new Database(dbFile, { readonly: true })
627
- try {
628
- found = (
629
- db.prepare("SELECT value FROM schema_meta WHERE key = 'version'").get() as {
630
- value: string
631
- } | null
632
- )?.value
633
- } finally {
634
- closeDb(db)
635
- }
636
- } catch {
637
- return null
638
- }
639
- if (found === undefined || found === SCHEMA_VERSION) return null
640
- const aged = await scanOrphanFiles(cacheDir, () => new Set())
641
- let orphanBytes = 0
642
- for (const o of aged) orphanBytes += o.size
643
- return { found, orphans: aged.length, orphanBytes }
644
- }
645
-
646
636
  constructor(
647
637
  private readonly cacheDir: string,
648
638
  localPolicy: { read: boolean; write: boolean } = { read: true, write: true },
@@ -655,23 +645,25 @@ export class Cache implements CacheLayer {
655
645
  */
656
646
  private readonly artifactCeiling: number = MAX_DECOMPRESSED_ARTIFACT_BYTES,
657
647
  /**
658
- * `'inspect'`: a reading verb (`why`, `last`, `info`, a dry prune). It
659
- * never resets the index: a schema it cannot read is refused, named,
660
- * and left as it was.
648
+ * `'inspect'`: a reading verb (`why`, `last`, `info`). It never resets
649
+ * the index: a schema it cannot read is refused, named, and left as it
650
+ * was. `'preview'`: a dry prune, which reads such an index as the reset
651
+ * the real prune does first leaves it, empty, with the store still
652
+ * reached, so it names what that prune takes (item 1083).
661
653
  */
662
- mode: 'open' | 'inspect' = 'open',
654
+ mode: 'open' | 'inspect' | 'preview' = 'open',
663
655
  /**
664
656
  * The shared store's directory, unversioned: every key is seeded with
665
657
  * `CACHE_VERSION`, so two vx versions never read each other's entries,
666
658
  * and the store's own schema is `store_meta.schema` (`matchStoreSchema`). Or
667
659
  * `null` for an index that holds its entries itself (a `cacheDir`).
668
660
  * Undefined follows the layout the index records: a reading verb, a
669
- * plugin's handle. An `'inspect'` open reads it only where the index
661
+ * plugin's handle. A reading open reads it only where the index
670
662
  * records none (deleted, or never written beside a run's store).
671
663
  */
672
664
  storeRoot?: string | null,
673
665
  ) {
674
- this.inspecting = mode === 'inspect'
666
+ this.inspecting = mode !== 'open'
675
667
  this.read = localPolicy.read
676
668
  // The directory exists before the DB opens — bun:sqlite won't create
677
669
  // parent dirs for us. A directory this user cannot write into is a
@@ -687,7 +679,9 @@ export class Cache implements CacheLayer {
687
679
  // said "no recorded runs yet" (item 900).
688
680
  const dbFile = path.join(cacheDir, 'cache.db')
689
681
  this.dbFile = dbFile
690
- const absent = mode === 'inspect' && !existsSync(dbFile)
682
+ const absent =
683
+ mode !== 'open' &&
684
+ (!existsSync(dbFile) || (mode === 'preview' && indexOfAnotherSchema(dbFile)))
691
685
  this.writeBlocked = absent ? 'no index there yet' : openCacheDir(cacheDir, repoDir)
692
686
  this.write = localPolicy.write && this.writeBlocked === null
693
687
  try {
@@ -779,7 +773,7 @@ export class Cache implements CacheLayer {
779
773
  // 2026-10-06; a newer one used to be refused, item 896). A reading verb
780
774
  // leaves it as it was.
781
775
  const refuseUnreadable = (found: string): void => {
782
- if (mode === 'inspect') {
776
+ if (mode !== 'open') {
783
777
  throw new UserError(
784
778
  `the cache at ${cacheDir} holds index schema ${found} from another vx version; this vx reads ${SCHEMA_VERSION}, so nothing in it is readable here, and this verb leaves it untouched. The next \`vx run\` resets it`,
785
779
  )
@@ -833,7 +827,7 @@ export class Cache implements CacheLayer {
833
827
  )?.value,
834
828
  )
835
829
  let storeDir =
836
- mode === 'inspect'
830
+ mode !== 'open'
837
831
  ? (recorded ?? (typeof storeRoot === 'string' ? storeRoot : undefined))
838
832
  : storeRoot === undefined
839
833
  ? recorded
@@ -877,17 +871,10 @@ export class Cache implements CacheLayer {
877
871
  this.storeDir = storeDir
878
872
  this.artifactDir = storeDir ?? cacheDir
879
873
  if (storeDir !== undefined) {
880
- this.attachStore(storeDir, mode === 'inspect')
874
+ this.attachStore(storeDir, mode !== 'open')
881
875
  this.storeReset = this.matchStoreSchema(mode === 'open' && this.writeBlocked === null)
882
- }
883
-
884
- createTables(this.db, storeDir === undefined ? 'main' : 'store')
885
- if (storeDir !== undefined && mode === 'open' && this.writeBlocked === null) {
886
- this.db
887
- .prepare(
888
- "INSERT INTO store.store_meta(key, value) VALUES ('schema', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
889
- )
890
- .run(SCHEMA_VERSION)
876
+ } else {
877
+ createTables(this.db, 'main')
891
878
  }
892
879
  if (mode === 'open' && this.writeBlocked === null && storeDir !== recorded) {
893
880
  if (storeDir === undefined)
@@ -1018,50 +1005,63 @@ export class Cache implements CacheLayer {
1018
1005
  * reads it: a store another `SCHEMA_VERSION` wrote has its tables dropped
1019
1006
  * (owner, 2026-10-06), the artifacts kept. Each is indexed again from its
1020
1007
  * own bytes on its next hit (`adopt`). A reading verb reads such a store
1021
- * as empty and changes nothing.
1008
+ * as empty and changes nothing. Creates the store's tables either way.
1022
1009
  */
1023
1010
  private matchStoreSchema(writable: boolean): SchemaReset | null {
1024
- const tables = new Set(
1025
- (
1026
- this.db
1027
- .prepare("SELECT name FROM store.sqlite_master WHERE type = 'table'")
1028
- .all() as Array<{
1029
- name: string
1030
- }>
1031
- ).map((r) => r.name),
1032
- )
1033
- if (!tables.has('entries')) return null
1034
- const found = tables.has('store_meta')
1035
- ? (
1036
- this.db.prepare("SELECT value FROM store.store_meta WHERE key = 'schema'").get() as {
1037
- value: string
1038
- } | null
1039
- )?.value
1040
- : undefined
1041
- if (found === SCHEMA_VERSION) return null
1011
+ const read = (): { has: boolean; found: string | undefined } => {
1012
+ const tables = new Set(
1013
+ (
1014
+ this.db
1015
+ .prepare("SELECT name FROM store.sqlite_master WHERE type = 'table'")
1016
+ .all() as Array<{
1017
+ name: string
1018
+ }>
1019
+ ).map((r) => r.name),
1020
+ )
1021
+ const found = tables.has('store_meta')
1022
+ ? (
1023
+ this.db.prepare("SELECT value FROM store.store_meta WHERE key = 'schema'").get() as {
1024
+ value: string
1025
+ } | null
1026
+ )?.value
1027
+ : undefined
1028
+ return { has: tables.has('entries'), found }
1029
+ }
1030
+ const seen = read()
1031
+ if (seen.has && seen.found === SCHEMA_VERSION) {
1032
+ createTables(this.db, 'store')
1033
+ return null
1034
+ }
1042
1035
  if (!writable) {
1043
- this.db.exec('DETACH DATABASE store')
1044
- this.db.prepare('ATTACH DATABASE ? AS store').run(':memory:')
1036
+ if (seen.has) {
1037
+ this.db.exec('DETACH DATABASE store')
1038
+ this.db.prepare('ATTACH DATABASE ? AS store').run(':memory:')
1039
+ }
1040
+ createTables(this.db, 'store')
1045
1041
  return null
1046
1042
  }
1047
- let reset: SchemaReset | null = null
1048
- this.db
1049
- .transaction(() => {
1050
- // Re-read under the write lock: another workspace's open may have
1051
- // reset it first.
1052
- const now = (
1053
- this.db.prepare("SELECT value FROM store.store_meta WHERE key = 'schema'").get() as {
1054
- value: string
1055
- } | null
1056
- )?.value
1057
- if (now === SCHEMA_VERSION) return
1058
- for (const t of STORE_TABLES) {
1059
- if (t !== 'store_meta') this.db.exec(`DROP TABLE IF EXISTS store.${t}`)
1043
+ // Re-read, drop, create and stamp under one write lock: as separate
1044
+ // transactions, another vx's open landing after the drop made its own
1045
+ // tables, and this one's `CREATE IF NOT EXISTS` kept them under its stamp.
1046
+ return this.db
1047
+ .transaction((): SchemaReset | null => {
1048
+ const now = read()
1049
+ let reset: SchemaReset | null = null
1050
+ if (now.has && now.found !== SCHEMA_VERSION) {
1051
+ for (const t of STORE_TABLES) {
1052
+ if (t !== 'store_meta') this.db.exec(`DROP TABLE IF EXISTS store.${t}`)
1053
+ }
1054
+ reset = { from: now.found ?? 'an earlier schema', to: SCHEMA_VERSION }
1060
1055
  }
1061
- reset = { from: found ?? 'an earlier schema', to: SCHEMA_VERSION }
1056
+ createTables(this.db, 'store')
1057
+ this.db
1058
+ .prepare(
1059
+ "INSERT INTO store.store_meta(key, value) VALUES ('schema', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
1060
+ )
1061
+ .run(SCHEMA_VERSION)
1062
+ return reset
1062
1063
  })
1063
1064
  .immediate()
1064
- return reset
1065
1065
  }
1066
1066
 
1067
1067
  // --- config evaluations: `ConfigEvalStore`, delegated to `ConfigEvalTable` ---
@@ -1222,6 +1222,12 @@ export class Cache implements CacheLayer {
1222
1222
  // A second name for the same bytes: the index step renames it over
1223
1223
  // the artifact inside its write transaction, as for a save.
1224
1224
  linkSync(finalPath, tmpPath)
1225
+ // The link shares the artifact's inode and so its mtime, which an
1226
+ // artifact that needs adopting has had for hours: another process's
1227
+ // orphan sweep read the temp as a crashed save's and took it, and the
1228
+ // artifact with it, while this adopt was scanning them.
1229
+ const now = new Date()
1230
+ utimesSync(tmpPath, now, now)
1225
1231
  } catch {
1226
1232
  return false
1227
1233
  }
@@ -6,6 +6,9 @@
6
6
  import type { Database, SQLQueryBindings } from 'bun:sqlite'
7
7
  import { lazyStatement } from './schema.js'
8
8
 
9
+ const CONFIG_EVALS_SWEPT_AT = 'config_evals_swept_at'
10
+ const DAY_MS = 24 * 60 * 60 * 1000
11
+
9
12
  export class ConfigEvalTable {
10
13
  private readonly selectConfigEval: ReturnType<Database['prepare']>
11
14
  private readonly insertConfigEval: ReturnType<Database['prepare']>
@@ -106,10 +109,22 @@ export class ConfigEvalTable {
106
109
  /**
107
110
  * Retention: a row not WRITTEN since `cutoff`. A hit does not refresh it
108
111
  * (that would be a write per config on every warm run), so a config that
109
- * hit for thirty days is evaluated once more and stored again.
112
+ * hit for thirty days is evaluated once more and stored again. No index
113
+ * covers `created_at`, so each sweep reads every row with its JSON (1–6 ms
114
+ * at 8,000 rows, 2 MB); it runs once a day, on its own clock in
115
+ * `schema_meta`, as the file-hash sweep does.
110
116
  */
111
- pruneOlderThan(cutoff: number): void {
112
- this.db.prepare('DELETE FROM config_evals WHERE created_at < ?').run(cutoff)
113
- this.db.prepare('DELETE FROM config_closures WHERE created_at < ?').run(cutoff)
117
+ pruneOlderThan(cutoff: number, now: number = Date.now()): void {
118
+ const last = this.db
119
+ .prepare('SELECT value FROM schema_meta WHERE key = ?')
120
+ .get(CONFIG_EVALS_SWEPT_AT) as { value: string } | null
121
+ if (last !== null && now - Number(last.value) < DAY_MS) return
122
+ this.db.transaction(() => {
123
+ this.db.prepare('DELETE FROM config_evals WHERE created_at < ?').run(cutoff)
124
+ this.db.prepare('DELETE FROM config_closures WHERE created_at < ?').run(cutoff)
125
+ this.db
126
+ .prepare('INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)')
127
+ .run(CONFIG_EVALS_SWEPT_AT, String(now))
128
+ })()
114
129
  }
115
130
  }
@@ -1304,8 +1304,11 @@ export async function startGitEnumeration(
1304
1304
  const facts = repoFacts(workspaceRoot)
1305
1305
  // The blob-size check's verdict is a function of the index's entries, so
1306
1306
  // the index file's bytes key it: one read, where `ls-files --debug` and a
1307
- // lookup per entry cost 550 ms at 100,000 files (A-60).
1308
- const pathspecKey = xxh3hex(pathspecs.join('\0'))
1307
+ // lookup per entry cost 550 ms at 100,000 files (A-60). The verdict's
1308
+ // paths are workspace-relative, so the repo→workspace prefix keys it too:
1309
+ // a nested workspace sharing the cache read the outer one's verdict and
1310
+ // trusted a resized blob.
1311
+ const pathspecKey = xxh3hex([facts?.prefix ?? '', ...pathspecs].join('\0'))
1309
1312
  const indexFile =
1310
1313
  facts === null || facts.indexFile === ''
1311
1314
  ? undefined
@@ -1521,9 +1524,10 @@ export async function applyGitEnumeration(
1521
1524
  // Sort once, then each project's files are a contiguous range found
1522
1525
  // by binary search on its `dir/` prefix — O((F+P) log F) instead of
1523
1526
  // the O(P·F) per-project startsWith scan (54 ms at 1090 projects ×
1524
- // ~9k files; ~5 ms this way). '/' sorts below most filename chars,
1525
- // so the range [prefix, prefix+'\xff…') is contiguous in the sorted
1526
- // array; lowerBound on `prefix` and on `prefix + '￿'` bracket it.
1527
+ // ~9k files; ~5 ms this way). Every path under `dir/` sorts in
1528
+ // [`dir/`, `dir0`): '0' is the code unit after '/'. An upper bound of
1529
+ // `dir/` + U+FFFF left out a file whose name starts with U+FFFF, and
1530
+ // it never entered the key.
1527
1531
  // Git lists in order; a list that already is skips the sort.
1528
1532
  let inOrder = true
1529
1533
  for (let i = 1; i < all.length; i++) {
@@ -1557,10 +1561,15 @@ export async function applyGitEnumeration(
1557
1561
  return lo
1558
1562
  }
1559
1563
  for (const projectDir of projectDirs) {
1560
- const relPrefix =
1564
+ const spelled =
1561
1565
  base !== undefined && projectDir.startsWith(base) && path.normalize(projectDir) === projectDir
1562
1566
  ? projectDir.slice(base.length).replace(/\/$/, '')
1563
1567
  : relPosix(workspaceRoot, projectDir)
1568
+ // macOS git reports paths NFC (core.precomposeunicode) while a dir
1569
+ // discovered by readdir keeps the spelling it was created with: match
1570
+ // NFC, and key the OIDs by the project's spelling, which is how
1571
+ // `resolveFiles` looks them up.
1572
+ const relPrefix = spelled.normalize('NFC')
1564
1573
  if (relPrefix === '' || relPrefix === '.') {
1565
1574
  cache.set(projectDir, all)
1566
1575
  const rootOids = new Map<string, string>()
@@ -1570,14 +1579,16 @@ export async function applyGitEnumeration(
1570
1579
  }
1571
1580
  const prefix = `${relPrefix}/`
1572
1581
  const start = lowerBound(prefix)
1573
- const end = lowerBound(`${prefix}￿`)
1582
+ const end = lowerBound(`${relPrefix}0`)
1574
1583
  const matches: string[] = []
1575
1584
  const projOids = new Map<string, string>()
1585
+ const projAbs =
1586
+ relPrefix === spelled ? abs : (rel: string) => path.join(projectDir, rel.slice(prefix.length))
1576
1587
  for (let i = start; i < end; i++) {
1577
1588
  const rel = sorted[i]!
1578
1589
  matches.push(rel.slice(prefix.length))
1579
1590
  const oid = trusted.get(rel)
1580
- if (oid !== undefined) projOids.set(abs(rel), oid)
1591
+ if (oid !== undefined) projOids.set(projAbs(rel), oid)
1581
1592
  }
1582
1593
  // An empty slice is a directory git did not see, not an empty project:
1583
1594
  // a project has at least its package.json, tracked or untracked. A