@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -43,6 +43,15 @@ export class ChainedCache implements CacheLayer {
43
43
  }
44
44
 
45
45
  async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
46
+ // The layer that pulled this hash in a prefetch answers first. Layers
47
+ // may share one local store, and an earlier layer found the pulled copy
48
+ // there and called it a local hit: a second remote's hit was counted as
49
+ // saved locally, in the outcome, the summary and telemetry (item 889).
50
+ const owner = this.hitLayer.get(hash)
51
+ if (owner !== undefined) {
52
+ const entry = await owner.get(hash, ctx)
53
+ if (entry !== null) return entry
54
+ }
46
55
  for (const layer of this.layers) {
47
56
  const entry = await layer.get(hash, ctx)
48
57
  if (entry !== null) {
@@ -127,6 +136,9 @@ export class ChainedCache implements CacheLayer {
127
136
  recordOutputDirs(hash: string, projectDir: string, prefixes: readonly string[]): Promise<void> {
128
137
  return this.layers[0]!.recordOutputDirs?.(hash, projectDir, prefixes) ?? Promise.resolve()
129
138
  }
139
+ recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
140
+ this.layers[0]!.recordOutputStamps?.(hash, projectDir, workspaceRoot)
141
+ }
130
142
 
131
143
  loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
132
144
  return this.layers[0]!.loadOutputDirsBatch?.(hashes) ?? new Map()
@@ -155,16 +167,8 @@ export class ChainedCache implements CacheLayer {
155
167
  }
156
168
  }
157
169
 
158
- ingest(hash: string, compressed: Uint8Array, meta: IngestMeta): Promise<void> {
159
- return this.layers[0]!.ingest(hash, compressed, meta)
160
- }
161
-
162
- recordRun(run: RunRecord): void {
163
- this.layers[0]!.recordRun(run)
164
- }
165
-
166
- recordRuns(runs: readonly RunRecord[]): void {
167
- this.layers[0]!.recordRuns(runs)
170
+ ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
171
+ return this.layers[0]!.ingest(hash, body, meta)
168
172
  }
169
173
 
170
174
  recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
@@ -0,0 +1,112 @@
1
+ // The config-evaluation cache tables (`config_evals`, `config_closures`):
2
+ // what `workspace/config-cache.ts` reads and writes through the
3
+ // `ConfigEvalStore` contract, honouring the run's local read/write axes.
4
+ // Owns its statements over the store's handle; `Cache` delegates.
5
+
6
+ import type { Database, SQLQueryBindings } from 'bun:sqlite'
7
+
8
+ export class ConfigEvalTable {
9
+ private readonly selectConfigEval: ReturnType<Database['prepare']>
10
+ private readonly insertConfigEval: ReturnType<Database['prepare']>
11
+ private readonly upsertConfigClosure: ReturnType<Database['prepare']>
12
+ private readonly read: boolean
13
+ private readonly write: boolean
14
+
15
+ constructor(
16
+ private readonly db: Database,
17
+ policy: { read: boolean; write: boolean },
18
+ ) {
19
+ this.read = policy.read
20
+ this.write = policy.write
21
+ this.selectConfigEval = this.db.prepare('SELECT json FROM config_evals WHERE key = ?')
22
+ this.insertConfigEval = this.db.prepare(
23
+ 'INSERT OR REPLACE INTO config_evals(key, json, created_at) VALUES (?, ?, ?)',
24
+ )
25
+ this.upsertConfigClosure = this.db.prepare(
26
+ 'INSERT INTO config_closures(config_path, files_json, created_at) VALUES (?, ?, ?) ON CONFLICT(config_path) DO UPDATE SET files_json = excluded.files_json, created_at = excluded.created_at',
27
+ )
28
+ }
29
+
30
+ /** `ConfigEvalStore`: a cached config evaluation, honouring the local READ axis. */
31
+ getConfigEval(key: string): string | null {
32
+ if (!this.read) return null
33
+ const row = this.selectConfigEval.get(key) as { json: string } | null
34
+ return row?.json ?? null
35
+ }
36
+
37
+ /** `ConfigEvalStore`: each config's ordered closure, one `IN` query per 900 paths. */
38
+ getConfigClosures(configPaths: readonly string[]): Map<string, string[]> {
39
+ const out = new Map<string, string[]>()
40
+ if (!this.read || configPaths.length === 0) return out
41
+ for (let i = 0; i < configPaths.length; i += 900) {
42
+ const chunk = configPaths.slice(i, i + 900)
43
+ const rows = this.db
44
+ .query(
45
+ `SELECT config_path, files_json FROM config_closures WHERE config_path IN (${chunk.map(() => '?').join(',')})`,
46
+ )
47
+ .all(...(chunk as readonly SQLQueryBindings[])) as Array<{
48
+ config_path: string
49
+ files_json: string
50
+ }>
51
+ for (const r of rows) out.set(r.config_path, JSON.parse(r.files_json) as string[])
52
+ }
53
+ return out
54
+ }
55
+
56
+ /** `ConfigEvalStore`: remember a config's ordered closure, honouring the local WRITE axis. */
57
+ putConfigClosure(configPath: string, files: readonly string[]): void {
58
+ if (!this.write) return
59
+ this.upsertConfigClosure.run(configPath, JSON.stringify(files), Date.now())
60
+ }
61
+
62
+ /** `ConfigEvalStore`: a round's closures in ONE transaction (each upsert was its own, item 615). */
63
+ putConfigClosures(entries: ReadonlyArray<readonly [string, readonly string[]]>): void {
64
+ if (!this.write || entries.length === 0) return
65
+ const now = Date.now()
66
+ this.db.transaction(() => {
67
+ for (const [configPath, files] of entries)
68
+ this.upsertConfigClosure.run(configPath, JSON.stringify(files), now)
69
+ })()
70
+ }
71
+
72
+ /** `ConfigEvalStore`: the batched read — one `IN` query per 900 keys, honouring the local READ axis. */
73
+ getConfigEvals(keys: readonly string[]): Map<string, string> {
74
+ const out = new Map<string, string>()
75
+ if (!this.read || keys.length === 0) return out
76
+ for (let i = 0; i < keys.length; i += 900) {
77
+ const chunk = keys.slice(i, i + 900)
78
+ const rows = this.db
79
+ .query(
80
+ `SELECT key, json FROM config_evals WHERE key IN (${chunk.map(() => '?').join(',')})`,
81
+ )
82
+ .all(...(chunk as readonly SQLQueryBindings[])) as Array<{ key: string; json: string }>
83
+ for (const r of rows) out.set(r.key, r.json)
84
+ }
85
+ return out
86
+ }
87
+
88
+ /** `ConfigEvalStore`: remember a validated evaluation, honouring the local WRITE axis. */
89
+ putConfigEval(key: string, json: string): void {
90
+ if (!this.write) return
91
+ this.insertConfigEval.run(key, json, Date.now())
92
+ }
93
+
94
+ /** `ConfigEvalStore`: a round's evaluations in ONE transaction. */
95
+ putConfigEvals(entries: ReadonlyArray<readonly [string, string]>): void {
96
+ if (!this.write || entries.length === 0) return
97
+ const now = Date.now()
98
+ this.db.transaction(() => {
99
+ for (const [key, json] of entries) this.insertConfigEval.run(key, json, now)
100
+ })()
101
+ }
102
+
103
+ /**
104
+ * Retention: a row not WRITTEN since `cutoff`. A hit does not refresh it
105
+ * (that would be a write per config on every warm run), so a config that
106
+ * hit for thirty days is evaluated once more and stored again.
107
+ */
108
+ pruneOlderThan(cutoff: number): void {
109
+ this.db.prepare('DELETE FROM config_evals WHERE created_at < ?').run(cutoff)
110
+ this.db.prepare('DELETE FROM config_closures WHERE created_at < ?').run(cutoff)
111
+ }
112
+ }
@@ -0,0 +1,290 @@
1
+ // The per-file content-hash memo. A file's cache-key contribution is its
2
+ // git blob OID; a clean tracked file takes it from the index (inputs.ts),
3
+ // everything else is hashed here from the worktree bytes — memoized on
4
+ // (mtime, size, ctime, ino) in `file_hashes`, the same four fields git's
5
+ // own index keys on — and a symlink hashes as the blob of its target
6
+ // string, exactly as the index stores it. Owns its two statements over
7
+ // the store's handle; `Cache` delegates.
8
+
9
+ import type { Database } from 'bun:sqlite'
10
+ import { lstatSync, readlinkSync } from 'node:fs'
11
+ import path from 'node:path'
12
+ import { fileIdentity, repoFacts } from './git-inputs.js'
13
+ import { FILE_HASH_RACY_MS } from './layer.js'
14
+
15
+ export class FileHashStore {
16
+ private readonly selectFileHash: ReturnType<Database['prepare']>
17
+ private readonly upsertFileHash: ReturnType<Database['prepare']>
18
+ private objectFormat: 'sha1' | 'sha256' | null = null
19
+
20
+ constructor(
21
+ private readonly db: Database,
22
+ /** Where the object format is asked when no `repoDir` is given and no file is named. */
23
+ private readonly cacheDir: string,
24
+ /** The local WRITE axis: off, and a miss is hashed but not remembered (a read-only cache). */
25
+ private readonly write: boolean = true,
26
+ /**
27
+ * The directory whose repository answers the object format — the
28
+ * workspace root, in a run: the enumeration asks git there too, and
29
+ * `repoFacts` answers both from one spawn. Absent, the directory of the
30
+ * first file hashed answers.
31
+ */
32
+ private readonly repoDir?: string,
33
+ ) {
34
+ this.selectFileHash = this.db.prepare(
35
+ 'SELECT mtime_ms, size_bytes, ctime_ms, ino, content_hash FROM file_hashes WHERE path = ?',
36
+ )
37
+ this.upsertFileHash = this.db.prepare(`
38
+ INSERT INTO file_hashes(path, mtime_ms, size_bytes, ctime_ms, ino, content_hash, seen_at)
39
+ VALUES (?, ?, ?, ?, ?, ?, ?)
40
+ ON CONFLICT(path) DO UPDATE SET
41
+ mtime_ms = excluded.mtime_ms,
42
+ size_bytes = excluded.size_bytes,
43
+ ctime_ms = excluded.ctime_ms,
44
+ ino = excluded.ino,
45
+ content_hash = excluded.content_hash,
46
+ seen_at = excluded.seen_at
47
+ `)
48
+ }
49
+
50
+ /**
51
+ * Content-hash a file (as a git blob OID, v20) with an mtime+size
52
+ * fast path. If the `file_hashes` table has a row for `path` whose
53
+ * `(mtime_ms, size_bytes, ctime_ms, ino)` all match the current
54
+ * stat, we reuse the stored content_hash (a memory + SQLite lookup,
55
+ * no disk read). Otherwise we read + hash + upsert. All four fields
56
+ * are load-bearing — see the comment at the comparison.
57
+ *
58
+ * The OID is byte-identical to what `git hash-object` (and the git
59
+ * index) computes for the same content — for a symlink, the index's
60
+ * mode-120000 blob of the link text — so this fallback and the
61
+ * `CacheKeyInput.fileHashes` index-OID fast path agree on any file
62
+ * git stores verbatim. They do NOT agree when a clean filter
63
+ * (`text`/`eol`/`ident`/`filter`/`working-tree-encoding`) is active: the index blob is the filtered
64
+ * form while this hashes the worktree bytes, so `inputs.ts` drops
65
+ * the index OID for those paths and routes them here.
66
+ */
67
+ async hashFile(filePath: string): Promise<string> {
68
+ // lstatSync intentional: a single stat is ~1.6µs (Bun 1.3); the
69
+ // async-stat equivalent adds ~75µs of Promise machinery per call.
70
+ // Promise.all over the batched callers (key derivation) gives no
71
+ // I/O parallelism benefit because the stat is faster than the
72
+ // threadpool dispatch overhead. lstat, not stat, so a symlink is
73
+ // seen as one.
74
+ let st
75
+ try {
76
+ st = lstatSync(filePath)
77
+ } catch {
78
+ // Caller is responsible for skipping files that don't exist;
79
+ // fall through to the content-hash path which will throw with
80
+ // a more useful error.
81
+ return await this.hashFileFromDisk(filePath)
82
+ }
83
+ // A symlink folds as git folds it: the blob of its TARGET STRING, which
84
+ // is its mode-120000 index OID. Not the bytes behind it — a link to a
85
+ // directory has none, a dangling one has none, and a link to a file
86
+ // outside the project would fold bytes `git diff` and `--affected`
87
+ // cannot see. A link to a file inside the project tracks that file's
88
+ // content only when the file matches the task's globs too. No
89
+ // memo: readlink is one syscall, and the row would be keyed on the
90
+ // link's own stat, not its target's.
91
+ if (st.isSymbolicLink()) {
92
+ return fileIdentity(
93
+ '120000',
94
+ this.hashBytes(new TextEncoder().encode(readlinkSync(filePath))),
95
+ )
96
+ }
97
+ // git's mode for a regular file: 100755 when the owner may execute it.
98
+ // The memo below holds the content alone; the mode rides this stat.
99
+ const mode = (st.mode & 0o100) !== 0 ? '100755' : '100644'
100
+ const mtimeMs = Math.floor(st.mtimeMs)
101
+ const size = st.size
102
+ // ctime + ino are what make this memo SAFE, not merely fast. mtime is
103
+ // caller-settable, so (mtime, size) alone hands back the previous run's
104
+ // digest for genuinely different bytes whenever a producer preserves
105
+ // mtime — `tar -x`, `unzip`, `cp -p`, `rsync --times`, any
106
+ // SOURCE_DATE_EPOCH generator — which is a stale cache hit. `utimes`
107
+ // cannot suppress ctime without root, and an atomic write-then-rename
108
+ // changes the inode; git's own index keys on ctime+ino+dev for exactly
109
+ // this reason. Both fields come from the stat we already took.
110
+ const ctimeMs = Math.floor(st.ctimeMs)
111
+ const ino = Number(st.ino)
112
+ const row = this.selectFileHash.get(filePath) as
113
+ | {
114
+ mtime_ms: number
115
+ size_bytes: number
116
+ ctime_ms: number
117
+ ino: number
118
+ content_hash: string
119
+ }
120
+ | undefined
121
+ if (
122
+ row &&
123
+ row.mtime_ms === mtimeMs &&
124
+ row.size_bytes === size &&
125
+ row.ctime_ms === ctimeMs &&
126
+ row.ino === ino
127
+ ) {
128
+ return fileIdentity(mode, row.content_hash)
129
+ }
130
+ const ch = await this.hashFileFromDisk(filePath)
131
+ // git's racy-clean rule, applied to the memo: a stat taken in the same
132
+ // tick as the file's last change cannot be trusted next time, because a
133
+ // rewrite landing in that tick keeps ctime (and, with mtime restored,
134
+ // every other field) equal and would hand back THIS digest for other
135
+ // bytes — seen once on ubuntu CI, on a docs-only commit. So a file
136
+ // changed within the window is hashed again on its next call rather
137
+ // than memoised; the warm path never meets it (keys are derived long
138
+ // after the files were written).
139
+ if (this.write && Date.now() - ctimeMs >= FILE_HASH_RACY_MS) {
140
+ this.upsertFileHash.run(filePath, mtimeMs, size, ctimeMs, ino, ch, Date.now())
141
+ }
142
+ return fileIdentity(mode, ch)
143
+ }
144
+
145
+ /**
146
+ * `hashFile` for many paths at once, with ONE memo query. The per-file
147
+ * form costs a SQLite point lookup each; a warm config load keys 1,000
148
+ * closures that way (7.7 ms of `get` in a 2026-09-09 profile), where one
149
+ * `IN` query over all of them costs well under a millisecond. Same stat
150
+ * fields, same racy-clean rule, same digest. A path that cannot be
151
+ * stat'ed is absent from the result (the caller decides what a missing
152
+ * identity means) rather than thrown, so one vanished preset does not
153
+ * fail the batch for every other config.
154
+ */
155
+ async hashFiles(paths: readonly string[]): Promise<Map<string, string>> {
156
+ const out = new Map<string, string>()
157
+ if (paths.length === 0) return out
158
+ interface Stat {
159
+ /** git's mode for it, as `hashFile` spells the identity. */
160
+ mode: string
161
+ mtimeMs: number
162
+ size: number
163
+ ctimeMs: number
164
+ ino: number
165
+ }
166
+ const stats = new Map<string, Stat>()
167
+ for (const p of paths) {
168
+ if (stats.has(p) || out.has(p)) continue
169
+ try {
170
+ // lstat, not stat: `hashFile` folds a symlink as git folds it —
171
+ // the blob of its TARGET STRING — and this form promises the same
172
+ // digest. Following the link here instead hashed the target's
173
+ // BYTES, so the same path identified differently depending on
174
+ // which entry point the caller used, and the batch folded bytes
175
+ // `git diff` and `--affected` cannot see.
176
+ const st = lstatSync(p)
177
+ if (st.isSymbolicLink()) {
178
+ // No memo, for `hashFile`'s reason: the row would be keyed on
179
+ // the link's own stat, not its target's.
180
+ out.set(
181
+ p,
182
+ fileIdentity('120000', this.hashBytes(new TextEncoder().encode(readlinkSync(p)))),
183
+ )
184
+ continue
185
+ }
186
+ stats.set(p, {
187
+ mode: (st.mode & 0o100) !== 0 ? '100755' : '100644',
188
+ mtimeMs: Math.floor(st.mtimeMs),
189
+ size: st.size,
190
+ ctimeMs: Math.floor(st.ctimeMs),
191
+ ino: Number(st.ino),
192
+ })
193
+ } catch {
194
+ // absent from the result
195
+ }
196
+ }
197
+ const wanted = [...stats.keys()]
198
+ const misses: string[] = []
199
+ // SQLite's bound-variable ceiling is 32,766 (999 on old builds); 500
200
+ // keeps a closure list of any size inside either.
201
+ for (let i = 0; i < wanted.length; i += 500) {
202
+ const chunk = wanted.slice(i, i + 500)
203
+ const rows = this.db
204
+ .query(
205
+ `SELECT path, mtime_ms, size_bytes, ctime_ms, ino, content_hash FROM file_hashes WHERE path IN (${chunk.map(() => '?').join(',')})`,
206
+ )
207
+ .all(...chunk) as Array<{
208
+ path: string
209
+ mtime_ms: number
210
+ size_bytes: number
211
+ ctime_ms: number
212
+ ino: number
213
+ content_hash: string
214
+ }>
215
+ const byPath = new Map(rows.map((r) => [r.path, r]))
216
+ for (const p of chunk) {
217
+ const st = stats.get(p)!
218
+ const row = byPath.get(p)
219
+ if (
220
+ row &&
221
+ row.mtime_ms === st.mtimeMs &&
222
+ row.size_bytes === st.size &&
223
+ row.ctime_ms === st.ctimeMs &&
224
+ row.ino === st.ino
225
+ ) {
226
+ out.set(p, fileIdentity(st.mode, row.content_hash))
227
+ } else misses.push(p)
228
+ }
229
+ }
230
+ if (misses.length === 0) return out
231
+ const digests = await Promise.all(
232
+ misses.map((p) => this.hashFileFromDisk(p).catch(() => undefined)),
233
+ )
234
+ const now = Date.now()
235
+ this.db.transaction(() => {
236
+ for (let i = 0; i < misses.length; i++) {
237
+ const digest = digests[i]
238
+ if (digest === undefined) continue
239
+ const p = misses[i]!
240
+ const st = stats.get(p)!
241
+ out.set(p, fileIdentity(st.mode, digest))
242
+ // The same racy-clean rule as `hashFile`: a stat taken within the
243
+ // window of the file's last change is not memoised.
244
+ if (this.write && now - st.ctimeMs >= FILE_HASH_RACY_MS) {
245
+ this.upsertFileHash.run(p, st.mtimeMs, st.size, st.ctimeMs, st.ino, digest, now)
246
+ }
247
+ }
248
+ })()
249
+ return out
250
+ }
251
+
252
+ /**
253
+ * Git blob OID of the file's bytes:
254
+ * `hex(HASH("blob " + byteLength + "\0" + content))`, where HASH is
255
+ * the repo's object format. Same value `git hash-object` prints and
256
+ * the same value the index stores. Computed in-process — no git
257
+ * spawn per file.
258
+ */
259
+ private async hashFileFromDisk(filePath: string): Promise<string> {
260
+ return this.hashBytes(await Bun.file(filePath).bytes(), filePath)
261
+ }
262
+
263
+ /**
264
+ * `git hash-object` of `bytes`: the blob OID in the repo's object format —
265
+ * what `hashFile` returns for a file holding them, with no stat and no
266
+ * memo row. `nearPath` says which repo's format applies (the cache dir's
267
+ * when absent).
268
+ */
269
+ hashBytes(bytes: Uint8Array, nearPath?: string): string {
270
+ const hasher = new Bun.CryptoHasher(
271
+ this.objectFormat ?? this.detectObjectFormat(nearPath ?? this.cacheDir),
272
+ )
273
+ hasher.update(`blob ${bytes.byteLength}\0`)
274
+ hasher.update(bytes)
275
+ return hasher.digest('hex')
276
+ }
277
+
278
+ /**
279
+ * Repo object format — sha1 unless the repo was created with
280
+ * `--object-format=sha256`. Asked once per store, and only when at least
281
+ * one file misses the mtime+size memo; `repoFacts` shares the spawn with
282
+ * the input enumeration. Outside a repo (unit fixtures), or with no git,
283
+ * sha1, which is still a deterministic blob-OID domain.
284
+ */
285
+ private detectObjectFormat(nearPath: string): 'sha1' | 'sha256' {
286
+ const detected = repoFacts(this.repoDir ?? path.dirname(nearPath))?.objectFormat ?? 'sha1'
287
+ this.objectFormat = detected
288
+ return detected
289
+ }
290
+ }