@spexcode/spec-core 0.6.2 → 0.6.4

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 (58) hide show
  1. package/dist/anchors.d.ts +94 -0
  2. package/dist/anchors.js +730 -0
  3. package/dist/git.d.ts +166 -0
  4. package/dist/git.js +2736 -0
  5. package/dist/graph-delta.d.ts +44 -0
  6. package/dist/graph-delta.js +72 -0
  7. package/dist/graph.d.ts +34 -0
  8. package/dist/graph.js +237 -0
  9. package/dist/graphDelta.d.ts +3 -0
  10. package/dist/graphDelta.js +13 -0
  11. package/dist/harness-identity.d.ts +31 -0
  12. package/dist/harness-identity.js +20 -0
  13. package/dist/identity-presets.d.ts +152 -0
  14. package/dist/identity-presets.js +132 -0
  15. package/dist/index.d.ts +45 -0
  16. package/dist/index.js +19 -0
  17. package/dist/layout.d.ts +183 -0
  18. package/dist/layout.js +548 -0
  19. package/dist/process-identity.d.ts +37 -0
  20. package/dist/process-identity.js +214 -0
  21. package/dist/project-identity.d.ts +12 -0
  22. package/dist/project-identity.js +71 -0
  23. package/dist/project-store.d.ts +3 -0
  24. package/dist/project-store.js +14 -0
  25. package/dist/resilience.d.ts +2 -0
  26. package/dist/resilience.js +40 -0
  27. package/dist/review/index.d.ts +3 -0
  28. package/{src → dist}/review/index.js +3 -3
  29. package/dist/review/reviewFilters.d.ts +77 -0
  30. package/dist/review/reviewFilters.js +308 -0
  31. package/dist/review/reviewQuery.d.ts +66 -0
  32. package/dist/review/reviewQuery.js +180 -0
  33. package/dist/review/session.d.ts +4 -0
  34. package/dist/review/session.js +8 -0
  35. package/dist/reviewSnapshot.d.ts +15 -0
  36. package/dist/reviewSnapshot.js +12 -0
  37. package/dist/root-lru.d.ts +4 -0
  38. package/{src/root-lru.ts → dist/root-lru.js} +26 -30
  39. package/dist/specs.d.ts +117 -0
  40. package/dist/specs.js +489 -0
  41. package/package.json +23 -8
  42. package/src/anchors.ts +0 -728
  43. package/src/git.ts +0 -2556
  44. package/src/graph.ts +0 -251
  45. package/src/harness-identity.ts +0 -26
  46. package/src/identity-presets.d.ts +0 -13
  47. package/src/identity-presets.js +0 -138
  48. package/src/index.ts +0 -20
  49. package/src/layout.ts +0 -637
  50. package/src/process-identity.ts +0 -207
  51. package/src/project-identity.ts +0 -73
  52. package/src/project-store.ts +0 -17
  53. package/src/resilience.ts +0 -41
  54. package/src/review/reviewFilters.js +0 -324
  55. package/src/review/reviewQuery.js +0 -174
  56. package/src/review/session.js +0 -13
  57. package/src/reviewSnapshot.ts +0 -28
  58. package/src/specs.ts +0 -498
package/src/git.ts DELETED
@@ -1,2556 +0,0 @@
1
- import { execFileSync, execFile, spawn } from 'node:child_process'
2
- import { AsyncLocalStorage } from 'node:async_hooks'
3
- import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdirSync, rmSync, renameSync, openSync, closeSync, accessSync, constants } from 'node:fs'
4
- import { join, isAbsolute, resolve, delimiter } from 'node:path'
5
- import { createHash, randomBytes } from 'node:crypto'
6
- import { projectRuntimeRoot } from './project-store.js'
7
- import { rootSlots, touchRoot as touchRootLru } from './root-lru.js'
8
- import { processStartToken } from './process-identity.js'
9
-
10
- const US = '\x1f', RS = '\x1e'
11
-
12
- // @@@ bounded graph git children - a git child that never exits (wedged fs, a hijacked PATH git, a dead
13
- // network mount) must not pin its awaiter forever: [[graph-cache]]'s settle guarantee starts at this seam.
14
- // Every shared helper passes a generous timeout with SIGKILL. A graph build additionally carries one fixed
15
- // permit pool through AsyncLocalStorage, so corpus-wide Promise.all fanout queues here before spawn rather
16
- // than materializing one process per worktree/eval. Calls outside that build context remain unconstrained.
17
- const GIT_TIMEOUT_MS = Number(process.env.SPEXCODE_GIT_TIMEOUT_MS || 120000)
18
- const GIT_SYNC_MAX_BUFFER = 1 << 27
19
- export const BOARD_GIT_CONCURRENCY = 4
20
- const gitByPath = new Map<string, string>()
21
-
22
- export function gitBinary(env: NodeJS.ProcessEnv = process.env): string {
23
- const path = env.PATH || ''
24
- const known = gitByPath.get(path)
25
- if (known) {
26
- try { accessSync(known, constants.X_OK); return known } catch {}
27
- }
28
- for (const dir of path.split(delimiter)) {
29
- const candidate = resolve(dir || '.', 'git')
30
- try {
31
- accessSync(candidate, constants.X_OK)
32
- gitByPath.set(path, candidate)
33
- return candidate
34
- } catch {}
35
- }
36
- throw new Error('git executable not found on PATH')
37
- }
38
- type GitPermitPool = { acquire: (signal: AbortSignal) => Promise<() => void> }
39
- type GitBuildContext = { signal: AbortSignal; permits: GitPermitPool }
40
- const gitBuild = new AsyncLocalStorage<GitBuildContext>()
41
-
42
- // @@@ a build's git children run on a bounded pack footprint - git sizes its mmap window, its mmap ceiling
43
- // and its delta-base cache for a process that owns the machine. A graph build's heaviest walks each mapped
44
- // well over a hundred megabytes of pack for output measured in kilobytes, and those children land inside the
45
- // build's own memory platform. Capping the three makes the same walks run in a fraction of the resident set
46
- // for a fraction of a second more. This is a RESOURCE boundary, never a semantic one — output, exit status
47
- // and stderr are byte-identical under every setting — and it is scoped to the build context, so ordinary
48
- // CLI/API git keeps git's defaults. It is also content-blind: the transport knows pack sizing, never which
49
- // walk a caller is doing.
50
- const BUILD_GIT_LIMITS = [
51
- '-c', 'core.packedGitWindowSize=1m',
52
- '-c', 'core.packedGitLimit=32m',
53
- '-c', 'core.deltaBaseCacheLimit=1m',
54
- ]
55
- const withBuildLimits = (args: string[]): string[] => (gitBuild.getStore() ? [...BUILD_GIT_LIMITS, ...args] : args)
56
-
57
- export function gitAbortError(): Error {
58
- return Object.assign(new Error('The operation was aborted'), { name: 'AbortError', code: 'ABORT_ERR' })
59
- }
60
-
61
- function gitPermitPool(limit: number): GitPermitPool {
62
- type Waiter = {
63
- signal: AbortSignal
64
- resolve: (release: () => void) => void
65
- reject: (error: Error) => void
66
- onAbort: () => void
67
- }
68
- let active = 0
69
- const waiting: Waiter[] = []
70
-
71
- const releasePermit = (): (() => void) => {
72
- let released = false
73
- return () => {
74
- if (released) return
75
- released = true
76
- active--
77
- drain()
78
- }
79
- }
80
- const drain = () => {
81
- while (active < limit && waiting.length) {
82
- const waiter = waiting.shift()!
83
- waiter.signal.removeEventListener('abort', waiter.onAbort)
84
- if (waiter.signal.aborted) {
85
- waiter.reject(gitAbortError())
86
- continue
87
- }
88
- active++
89
- waiter.resolve(releasePermit())
90
- }
91
- }
92
-
93
- return {
94
- acquire(signal) {
95
- if (signal.aborted) return Promise.reject(gitAbortError())
96
- if (active < limit) {
97
- active++
98
- return Promise.resolve(releasePermit())
99
- }
100
- return new Promise((resolve, reject) => {
101
- const waiter: Waiter = {
102
- signal,
103
- resolve,
104
- reject,
105
- onAbort: () => {
106
- const index = waiting.indexOf(waiter)
107
- if (index >= 0) waiting.splice(index, 1)
108
- reject(gitAbortError())
109
- },
110
- }
111
- waiting.push(waiter)
112
- signal.addEventListener('abort', waiter.onAbort, { once: true })
113
- })
114
- },
115
- }
116
- }
117
-
118
- // A board build owns one abort signal. Async git calls inherit it without every graph layer growing a
119
- // cancellation parameter; aborting the build therefore reaches every active child and queued permit below
120
- // the graph seam. The pool is created here, so ordinary CLI/API git calls never share or wait on it.
121
- export function withGitAbortSignal<T>(signal: AbortSignal, run: () => Promise<T>): Promise<T> {
122
- return gitBuild.run({ signal, permits: gitPermitPool(BOARD_GIT_CONCURRENCY) }, run)
123
- }
124
-
125
- const inheritedContext = (): GitBuildContext | undefined => gitBuild.getStore()
126
- export function currentGitBuildAbortSignal(): AbortSignal | undefined {
127
- return inheritedContext()?.signal
128
- }
129
- function warnIfTimedOut(e: any, args: string[]): void {
130
- if (e?.code === 'ETIMEDOUT' || e?.spexcodeGitTimeout === true)
131
- console.warn(`spec-cli: git ${args.slice(0, 6).join(' ')}… killed after ${GIT_TIMEOUT_MS}ms — child never exited`)
132
- }
133
-
134
- // strip git's hook-exported env (GIT_DIR etc.) so every call discovers the repo from the filesystem.
135
- export function git(args: string[]): string {
136
- const env = { ...process.env }
137
- delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
138
- try {
139
- return execFileSync(gitBinary(env), withBuildLimits(args), {
140
- encoding: 'utf8',
141
- env,
142
- stdio: ['ignore', 'pipe', 'pipe'],
143
- timeout: GIT_TIMEOUT_MS,
144
- killSignal: 'SIGKILL',
145
- maxBuffer: GIT_SYNC_MAX_BUFFER,
146
- })
147
- } catch (e: any) { warnIfTimedOut(e, args); throw e }
148
- }
149
-
150
- function gitBuffer(args: string[], input?: string): Buffer {
151
- const env = { ...process.env }
152
- delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
153
- try {
154
- return execFileSync(gitBinary(env), withBuildLimits(args), {
155
- input,
156
- env,
157
- stdio: ['pipe', 'pipe', 'pipe'],
158
- timeout: GIT_TIMEOUT_MS,
159
- killSignal: 'SIGKILL',
160
- maxBuffer: GIT_SYNC_MAX_BUFFER,
161
- })
162
- } catch (e: any) { warnIfTimedOut(e, args); throw e }
163
- }
164
-
165
- export type GitObjectFormat = 'sha1' | 'sha256'
166
- const gitObjectFormatMemo = new Map<string, GitObjectFormat>()
167
- export function gitObjectFormat(root: string): GitObjectFormat {
168
- const key = rootKey(root)
169
- const hit = gitObjectFormatMemo.get(key)
170
- if (hit) return hit
171
- const format = git(['-C', root, 'rev-parse', '--show-object-format=storage']).trim()
172
- if (format !== 'sha1' && format !== 'sha256') throw new Error(`unsupported Git object format '${format || 'empty'}' at ${root}`)
173
- gitObjectFormatMemo.set(key, format)
174
- return format
175
- }
176
- export function isGitObjectId(root: string, value: string): boolean {
177
- const length = gitObjectFormat(root) === 'sha256' ? 64 : 40
178
- return value.length === length && /^[0-9a-f]+$/.test(value)
179
- }
180
-
181
- // Batch immutable object lookups used by the shared anchor/index path. Git accepts revision:path queries on
182
- // batch-check, so dozens of rev-parse + cat-file children collapse into two bounded processes without
183
- // changing the returned bytes or object ids.
184
- //
185
- // These are async for two reasons a synchronous execFileSync cannot serve once the caller batches its WHOLE
186
- // invocation rather than one reading at a time. A sync child is invisible to the build's permit pool and its
187
- // abort signal, so a watchdog abort could not kill it; and one build-wide `cat-file --batch` reading tens of
188
- // megabytes would be a single uninterruptible stretch — exactly the /health-blocking shape [[graph-cache]]
189
- // closed for the fs walks.
190
- //
191
- // Chunking is a real output bound, not ceremony: the async transport caps a child's stdout at GIT_MAX_BUFFER
192
- // and overflow is a loud error. Revision rows are 41 bytes each, so only the blob read (payload-sized) needs
193
- // a chunk; the cap is on COUNT because sizes are unknown until git answers, and it is set so an ordinary
194
- // source corpus never approaches the byte ceiling.
195
- const BATCH_BLOB_CHUNK = 256
196
- const BATCH_BLOB_MAX_BUFFER = 1 << 26
197
- async function batchBuffer(args: string[], input: string, maxBuffer?: number, extraEnv: Record<string, string> = {}): Promise<Buffer> {
198
- const env = { ...process.env }
199
- delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
200
- Object.assign(env, extraEnv)
201
- try { return (await execGitForCaller(args, env, maxBuffer, input)).stdout }
202
- catch (error: any) {
203
- if (error?.name === 'AbortError') throw error
204
- warnIfTimedOut(error, args)
205
- throw new Error(`git ${args.slice(2, 5).join(' ')} failed: ${String(error?.stderr || error?.message || 'unknown git error').trim()}`)
206
- }
207
- }
208
- export async function batchRevisionOids(root: string, revisions: string[], options: { replaceObjects?: boolean } = {}): Promise<(string | null)[]> {
209
- if (!revisions.length) return []
210
- const extraEnv: Record<string, string> = options.replaceObjects === false ? { GIT_NO_REPLACE_OBJECTS: '1' } : {}
211
- const out = (await batchBuffer(['-C', root, 'cat-file', '--batch-check=%(objectname)'], revisions.join('\n') + '\n', undefined, extraEnv)).toString('utf8')
212
- const lines = out.split('\n')
213
- if (lines.length - 1 !== revisions.length) throw new Error(`git cat-file --batch-check returned ${lines.length - 1} rows for ${revisions.length} revisions`)
214
- return revisions.map((revision, index) => {
215
- const value = lines[index].trim()
216
- if (isGitObjectId(root, value)) return value
217
- if (value === `${revision} missing`) return null
218
- throw new Error(`git cat-file --batch-check returned '${value}' for ${revision}`)
219
- })
220
- }
221
- export async function batchBlobTexts(root: string, oids: string[]): Promise<Map<string, string>> {
222
- const unique = [...new Set(oids.filter(Boolean))]
223
- const files = new Map<string, string>()
224
- if (!unique.length) return files
225
- for (const oid of unique) if (!isGitObjectId(root, oid)) throw new Error(`invalid object id '${oid}'`)
226
- for (let cursor = 0; cursor < unique.length; cursor += BATCH_BLOB_CHUNK) {
227
- const chunk = unique.slice(cursor, cursor + BATCH_BLOB_CHUNK)
228
- const out = await batchBuffer(['-C', root, 'cat-file', '--batch'], chunk.join('\n') + '\n', BATCH_BLOB_MAX_BUFFER)
229
- let offset = 0
230
- for (const oid of chunk) {
231
- const newline = out.indexOf(10, offset)
232
- if (newline < 0) throw new Error(`git cat-file --batch ended before ${oid}`)
233
- const header = out.subarray(offset, newline).toString('utf8')
234
- const size = Number(header.match(/ blob (\d+)$/)?.[1])
235
- if (!header.startsWith(`${oid} blob `) || !Number.isFinite(size)) throw new Error(`git cat-file --batch returned '${header}' for ${oid}`) // dead-words-ok: Git object protocol type
236
- const start = newline + 1, end = start + size
237
- if (end >= out.length || out[end] !== 0x0a) throw new Error(`git cat-file --batch truncated object ${oid}`)
238
- files.set(oid, out.subarray(start, end).toString('utf8'))
239
- offset = end + 1
240
- }
241
- if (offset !== out.length) throw new Error(`git cat-file --batch returned ${out.length - offset} unexpected trailing bytes`)
242
- }
243
- return files
244
- }
245
-
246
- type TreeBlob = { oid: string; path: string }
247
- function treeBlobs(root: string, tip: string, pathspec = '.'): TreeBlob[] {
248
- const out = git(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', tip, '--', pathspec])
249
- const blobs: TreeBlob[] = []
250
- for (const record of out.split('\0')) {
251
- if (!record) continue
252
- const m = record.match(/^\d+ blob ([0-9a-f]+)\t([\s\S]+)$/)
253
- if (m) blobs.push({ oid: m[1], path: m[2] })
254
- }
255
- return blobs
256
- }
257
-
258
- // Read a tree slice in two Git calls regardless of file count: ls-tree names immutable blob oids, then
259
- // cat-file --batch returns their exact bytes. Pending lint uses this for .spec so commit --only/partial
260
- // staging can never be judged through unrelated working-tree prose.
261
- export function treeTextFiles(root: string, tip: string, pathspec = '.'): Map<string, string> {
262
- const blobs = treeBlobs(root, tip, pathspec)
263
- const files = new Map<string, string>()
264
- if (!blobs.length) return files
265
- const out = gitBuffer(['-C', root, 'cat-file', '--batch'], blobs.map((b) => b.oid).join('\n') + '\n')
266
- let offset = 0
267
- for (const blob of blobs) {
268
- const newline = out.indexOf(10, offset)
269
- if (newline < 0) throw new Error(`git cat-file --batch ended before ${blob.path}`)
270
- const header = out.subarray(offset, newline).toString('utf8')
271
- const size = Number(header.match(/ blob (\d+)$/)?.[1])
272
- if (!Number.isFinite(size)) throw new Error(`git cat-file --batch returned '${header}' for ${blob.path}`)
273
- const start = newline + 1, end = start + size
274
- files.set(blob.path, out.subarray(start, end).toString('utf8'))
275
- offset = end + 1 // one LF follows each payload
276
- }
277
- return files
278
- }
279
-
280
- export function treeFilePaths(root: string, tip: string): Set<string> {
281
- return new Set(treeBlobs(root, tip).map((b) => b.path))
282
- }
283
-
284
- export function treeFileText(root: string, tip: string, path: string): string | null {
285
- try { return gitBuffer(['-C', root, 'cat-file', 'blob', `${tip}:${path}`]).toString('utf8') } // dead-words-ok: git plumbing
286
- catch { return null }
287
- }
288
-
289
- // stdout stays a Buffer to the transport's edge: `cat-file --batch` frames each payload by BYTE length, so
290
- // a decode before framing mis-slices every object after the first multi-byte character. Text callers decode.
291
- type GitExec = { stdout: Buffer; stderr: string }
292
-
293
- // execFile's AbortSignal kills only its direct child. A wedged adapter may have descendants (the
294
- // deterministic tests use a shell + sleep), so async git runs in their own process group and abort/timeout
295
- // kills the whole group. The callback still carries the same stdout/stderr/error shape to gitA/gitTry.
296
- const GIT_MAX_BUFFER = 1 << 24
297
- function execGit(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal, maxBuffer = GIT_MAX_BUFFER, input?: string): Promise<GitExec> {
298
- return new Promise((resolve, reject) => {
299
- if (signal?.aborted) { reject(gitAbortError()); return }
300
- const child = spawn(gitBinary(env), args, { env, detached: true, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] })
301
- const stdout: Buffer[] = [], stderr: Buffer[] = []
302
- let stdoutBytes = 0, stderrBytes = 0, aborted = false, timedOut = false, overflow = false
303
- let spawnError: Error | null = null
304
- const killTree = () => {
305
- if (!child.pid) return
306
- try { process.kill(-child.pid, 'SIGKILL') } catch { /* group may already be gone */ }
307
- try { child.kill('SIGKILL') } catch { /* already exited */ }
308
- }
309
- const onAbort = () => { aborted = true; killTree() }
310
- const append = (chunks: Buffer[], chunk: Buffer, stream: 'stdout' | 'stderr') => {
311
- const total = stream === 'stdout' ? (stdoutBytes += chunk.length) : (stderrBytes += chunk.length)
312
- if (total > maxBuffer) { overflow = true; killTree(); return }
313
- chunks.push(chunk)
314
- }
315
- child.stdout!.on('data', (chunk: Buffer) => append(stdout, chunk, 'stdout'))
316
- child.stderr!.on('data', (chunk: Buffer) => append(stderr, chunk, 'stderr'))
317
- child.once('error', (error) => { spawnError = error })
318
- if (input !== undefined) {
319
- // A command can reject its input before the pipe drains. Keep that write failure on the same
320
- // close/reject path as spawn and exit failures instead of letting Node raise an unhandled EPIPE.
321
- child.stdin!.once('error', (error) => { if (!spawnError) spawnError = error })
322
- child.stdin!.end(input)
323
- }
324
- child.once('close', (code, childSignal) => {
325
- clearTimeout(timer)
326
- signal?.removeEventListener('abort', onAbort)
327
- const result = { stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr).toString('utf8') }
328
- if (code === 0 && !aborted && !timedOut && !overflow && !spawnError) { resolve(result); return }
329
- const error: any = spawnError ?? new Error(overflow
330
- ? `git output exceeded ${maxBuffer} bytes`
331
- : `git exited with ${code ?? childSignal ?? 'unknown status'}`)
332
- // @@@ a spawn failure keeps its OWN cause - a child that never started still emits `close`, with the
333
- // negated errno as its code (EACCES arrives as -13). Overwriting the spawn error's `'EACCES'`/`'ENOENT'`
334
- // with that number turns "git could not RUN" into "git ran and exited", and every caller that separates
335
- // the two reads the second as a real git answer: the freshness batch concluded "the anchor object is
336
- // unreadable", session create concluded "the branch does not exist". A machine where git is missing or
337
- // unexecutable would have been told, silently, that the thing it asked about is absent.
338
- if (overflow) error.code = 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
339
- else if (!spawnError) error.code = code
340
- error.signal = childSignal
341
- error.stdout = result.stdout.toString('utf8')
342
- error.stderr = result.stderr
343
- if (aborted) error.name = 'AbortError'
344
- if (timedOut) error.spexcodeGitTimeout = true
345
- reject(error)
346
- })
347
- signal?.addEventListener('abort', onAbort, { once: true })
348
- if (signal?.aborted) onAbort()
349
- const timer = setTimeout(() => { timedOut = true; killTree() }, GIT_TIMEOUT_MS)
350
- timer.unref?.()
351
- })
352
- }
353
-
354
- async function execGitForCaller(args: string[], env: NodeJS.ProcessEnv, maxBuffer?: number, input?: string): Promise<GitExec> {
355
- const context = inheritedContext()
356
- if (!context) return execGit(args, env, undefined, maxBuffer, input)
357
- const release = await context.permits.acquire(context.signal)
358
- try {
359
- return await execGit(withBuildLimits(args), env, context.signal, maxBuffer, input)
360
- } finally {
361
- release()
362
- }
363
- }
364
-
365
- // Event streams are the index input itself and may legitimately exceed execFile's fixed maxBuffer. Read
366
- // them through spawn so the only bound is the index the caller is intentionally constructing; timeout,
367
- // cancellation, process-group cleanup and build permits remain identical to the ordinary async transport.
368
- function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal, input?: string): Promise<GitExec> {
369
- return new Promise((resolve, reject) => {
370
- if (signal?.aborted) { reject(gitAbortError()); return }
371
- const child = spawn(gitBinary(env), args, { env, detached: true, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] })
372
- const stdout: Buffer[] = [], stderr: Buffer[] = []
373
- let settled = false, aborted = false, timedOut = false
374
- let stdinError: any = null
375
- const killTree = () => {
376
- if (!child.pid) return
377
- try { process.kill(-child.pid, 'SIGKILL') } catch { /* group may already be gone */ }
378
- try { child.kill('SIGKILL') } catch { /* already exited */ }
379
- }
380
- const onAbort = () => { aborted = true; killTree() }
381
- const timer = setTimeout(() => { timedOut = true; killTree() }, GIT_TIMEOUT_MS)
382
- timer.unref?.()
383
- signal?.addEventListener('abort', onAbort, { once: true })
384
- child.stdout!.on('data', (chunk: Buffer) => stdout.push(chunk))
385
- child.stderr!.on('data', (chunk: Buffer) => stderr.push(chunk))
386
- if (input !== undefined) {
387
- // A command can reject its input before the pipe drains. Keep that write failure on the same close
388
- // path as exit and abort failures instead of letting Node raise an unhandled EPIPE.
389
- child.stdin!.once('error', (error) => { stdinError ??= error })
390
- child.stdin!.end(input)
391
- }
392
- child.on('error', (error: any) => {
393
- if (settled) return
394
- settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort)
395
- if (aborted) error.name = 'AbortError'
396
- if (timedOut) error.spexcodeGitTimeout = true
397
- reject(error)
398
- })
399
- child.on('close', (code, childSignal) => {
400
- if (settled) return
401
- settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort)
402
- const result = { stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr).toString('utf8') }
403
- if (code === 0 && !aborted && !timedOut && !stdinError) { resolve(result); return }
404
- const error: any = stdinError ?? new Error(`git exited with ${code ?? childSignal ?? 'unknown status'}`)
405
- error.code = stdinError ? error.code : code
406
- error.signal = childSignal
407
- error.stdout = result.stdout.toString('utf8')
408
- error.stderr = result.stderr
409
- if (aborted) error.name = 'AbortError'
410
- if (timedOut) error.spexcodeGitTimeout = true
411
- reject(error)
412
- })
413
- })
414
- }
415
- async function execGitStreamForCaller(args: string[], env: NodeJS.ProcessEnv, input?: string): Promise<GitExec> {
416
- const context = inheritedContext()
417
- if (!context) return execGitStream(args, env, undefined, input)
418
- const release = await context.permits.acquire(context.signal)
419
- try { return await execGitStream(withBuildLimits(args), env, context.signal, input) }
420
- finally { release() }
421
- }
422
-
423
- export async function gitA(args: string[], input?: string): Promise<string> {
424
- const env = { ...process.env }
425
- delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
426
- const context = inheritedContext()
427
- try {
428
- const { stdout } = await execGitForCaller(args, env, undefined, input)
429
- return stdout.toString('utf8')
430
- } catch (e: any) {
431
- if (context?.signal.aborted || e?.name === 'AbortError') throw e
432
- warnIfTimedOut(e, args); return ''
433
- }
434
- }
435
-
436
- async function gitARequired(args: string[], input?: string): Promise<string> {
437
- const env = { ...process.env }
438
- delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
439
- try { return (await execGitForCaller(args, env, undefined, input)).stdout.toString('utf8') }
440
- catch (error: any) {
441
- if (error?.name === 'AbortError') throw error
442
- warnIfTimedOut(error, args)
443
- throw new Error(`git ${args.slice(2, 6).join(' ')} failed: ${String(error?.stderr || error?.message || 'unknown git error').trim()}`)
444
- }
445
- }
446
-
447
- type TextEventRecord = { hash: string; raw: string }
448
- type IdentityEventRecord = { hash: string; identity: IdentityRawRecord }
449
- type EventRecord = TextEventRecord | IdentityEventRecord
450
- export type ImmutableHunkRanges = { after: DiffLineRange[]; before: DiffLineRange[][] }
451
- type EventCache = {
452
- streams: Map<EventStreamKind, Map<string, EventRecord>>
453
- streamTips: Map<EventStreamKind, string[]>
454
- hunks: Map<string, ImmutableHunkRanges>
455
- }
456
- type EventStreamOutput = string | IdentityRawRecord[]
457
- // The schema names both the ledger grammar and its on-disk namespace. A reader that predates a row type
458
- // must never share a ledger with its writer: it seeds the next namespace from Git instead.
459
- const EVENT_CACHE_SCHEMA = 'history-events-v16'
460
- const IMMUTABLE_HUNK_FACT = 'immutable-hunk-v1'
461
- const EVENT_STREAM_KINDS = ['merge', 'identity-raw'] as const
462
- type EventStreamKind = typeof EVENT_STREAM_KINDS[number]
463
- type EventCacheLocation = { path: string; identity: string; interpretation: string; objectFormat: GitObjectFormat }
464
- type EventLedgerSnapshot = {
465
- payload: Buffer
466
- state: EventCache
467
- }
468
- type EventLedgerBuild = {
469
- location: EventCacheLocation
470
- snapshot: EventLedgerSnapshot
471
- additions: string[]
472
- }
473
- type EventLedgerDiagnostics = { reads: number; locks: number; replaces: number }
474
- const eventLedgerBuild = new AsyncLocalStorage<EventLedgerBuild>()
475
- const eventLedgerDemandPolicy = new AsyncLocalStorage<{ path: string }>()
476
- const eventLedgerDiagnostics: EventLedgerDiagnostics = { reads: 0, locks: 0, replaces: 0 }
477
- type EventStreamRequest = {
478
- kind: EventStreamKind
479
- argsFor: (base: string) => string[]
480
- order: Map<string, number>
481
- reachable: Set<string>
482
- }
483
-
484
- type EventPathMemo = EventCacheLocation & {
485
- common: string; shallowPath: string; grafts: string; shallow: string
486
- replacementStorage: string; replacements: string
487
- }
488
- const eventPathMemo = new Map<string, EventPathMemo>()
489
- function replacementStorageIdentity(common: string): string {
490
- const hash = createHash('sha256')
491
- const addTree = (root: string, rel: string) => {
492
- if (!existsSync(root)) return
493
- const stack = [{ dir: root, rel }]
494
- while (stack.length) {
495
- const current = stack.pop()!
496
- const entries = readdirSync(current.dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))
497
- for (const entry of entries) {
498
- const path = join(current.dir, entry.name)
499
- const name = `${current.rel}/${entry.name}`
500
- if (entry.isDirectory()) stack.push({ dir: path, rel: name })
501
- else hash.update(`\0${name}\0`).update(readFileSync(path))
502
- }
503
- }
504
- }
505
- addTree(join(common, 'refs', 'replace'), 'refs/replace')
506
- for (const name of ['packed-refs']) {
507
- const path = join(common, name)
508
- if (existsSync(path)) hash.update(`\0${name}\0`).update(readFileSync(path))
509
- }
510
- // Reftable is opaque here by design: its bytes are only an invalidation signal. Git remains the one
511
- // parser and supplies the canonical refs/replace targets when those bytes change.
512
- addTree(join(common, 'reftable'), 'reftable')
513
- return hash.digest('hex')
514
- }
515
- function eventCacheLocation(root: string): EventCacheLocation {
516
- const rootId = rootKey(root), old = eventPathMemo.get(rootId)
517
- const common = old?.common ?? git(['-C', root, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
518
- const shallowPath = old?.shallowPath ?? git(['-C', root, 'rev-parse', '--path-format=absolute', '--git-path', 'shallow']).trim()
519
- const shallow = existsSync(shallowPath) ? readFileSync(shallowPath, 'utf8') : 'unshallow'
520
- const graftsPath = join(common, 'info', 'grafts')
521
- const grafts = existsSync(graftsPath) ? readFileSync(graftsPath, 'utf8') : ''
522
- const replacementStorage = replacementStorageIdentity(common)
523
- const replacements = old?.replacementStorage === replacementStorage
524
- ? old.replacements
525
- : git(['-C', root, 'for-each-ref', 'refs/replace', '--format=%(refname) %(objectname)'])
526
- const objectFormat = gitObjectFormat(root)
527
- if (old && old.shallow === shallow && old.grafts === grafts && old.replacements === replacements && old.objectFormat === objectFormat)
528
- return { path: old.path, identity: old.identity, interpretation: old.interpretation, objectFormat }
529
- const interpretation = createHash('sha256')
530
- .update(`${EVENT_CACHE_SCHEMA}\0${objectFormat}\0${shallow}\0${grafts}\0${replacements}`)
531
- .digest('hex')
532
- const identity = interpretation.slice(0, 16)
533
- // projectRuntimeRoot derives checkout identity from dirname(common). A bare repository is its own common
534
- // dir, so a synthetic `.git` suffix preserves the repository path instead of collapsing sibling bares.
535
- const gitDir = gitDirOf(root)
536
- const storeIdentity = gitDir === root && common === root ? join(common, '.git') : common
537
- const path = join(projectRuntimeRoot(storeIdentity), `${EVENT_CACHE_SCHEMA}-${identity}.ndjson`)
538
- eventPathMemo.set(rootId, { common, shallowPath, grafts, shallow, replacementStorage, replacements, path, identity, interpretation, objectFormat })
539
- return { path, identity, interpretation, objectFormat }
540
- }
541
-
542
- // One existing source-of-truth identity governs every Git reader that interprets commit images. Consumers
543
- // may retain the full digest in memory while the ledger keeps its established short directory name.
544
- export function gitObjectInterpretation(root: string): {
545
- identity: string
546
- objectFormat: GitObjectFormat
547
- replacements: ReadonlyMap<string, string>
548
- } {
549
- const location = eventCacheLocation(root)
550
- const raw = eventPathMemo.get(rootKey(root))?.replacements ?? ''
551
- const replacements = new Map<string, string>()
552
- for (const line of raw.split('\n').filter(Boolean)) {
553
- const match = line.match(/^refs\/replace\/([0-9a-f]+) ([0-9a-f]+)$/)
554
- if (!match || !isGitObjectIdForFormat(location.objectFormat, match[1]) || !isGitObjectIdForFormat(location.objectFormat, match[2]))
555
- throw new Error(`malformed refs/replace projection '${line}' at ${root}`)
556
- replacements.set(match[1], match[2])
557
- }
558
- return { identity: location.interpretation, objectFormat: location.objectFormat, replacements }
559
- }
560
- export function gitInterpretationIdentity(root: string): string { return eventCacheLocation(root).identity }
561
- function emptyEventCache(): EventCache {
562
- return { streams: new Map(), streamTips: new Map(), hunks: new Map() }
563
- }
564
- function exactKeys(value: Record<string, unknown>, expected: string[]): boolean {
565
- const actual = Object.keys(value).sort()
566
- return actual.length === expected.length && actual.every((key, index) => key === expected[index])
567
- }
568
- function isGitObjectIdForFormat(format: GitObjectFormat, value: string): boolean {
569
- return value.length === (format === 'sha256' ? 64 : 40) && /^[0-9a-f]+$/.test(value)
570
- }
571
- function eventPayloadDigest(payload: Buffer): string {
572
- return createHash('sha256').update(payload).digest('hex')
573
- }
574
- function eventIntegrityFooter(payload: Buffer, addition: Buffer): Buffer {
575
- const footer = {
576
- k: 'integrity',
577
- bytes: payload.length + addition.length,
578
- sha256: createHash('sha256').update(payload).update(addition).digest('hex'),
579
- }
580
- return Buffer.from(`${JSON.stringify(footer)}\n`)
581
- }
582
- function eventStreamKind(value: unknown): EventStreamKind | null {
583
- return typeof value === 'string' && (EVENT_STREAM_KINDS as readonly string[]).includes(value)
584
- ? value as EventStreamKind
585
- : null
586
- }
587
- function decodeHunkRanges(value: unknown): DiffLineRange[] | null {
588
- if (!Array.isArray(value)) return null
589
- const ranges: DiffLineRange[] = []
590
- for (const row of value) {
591
- if (!Array.isArray(row) || row.length !== 2 || !Number.isSafeInteger(row[0]) || !Number.isSafeInteger(row[1])
592
- || row[0] <= 0 || row[1] < row[0]) return null
593
- ranges.push([row[0], row[1]])
594
- }
595
- return ranges
596
- }
597
- function decodeImmutableHunkFact(row: Record<string, unknown>): { key: string; ranges: ImmutableHunkRanges } | null {
598
- if (!exactKeys(row, ['a', 'b', 'i', 'k']) || row.k !== IMMUTABLE_HUNK_FACT || typeof row.i !== 'string' || !row.i) return null
599
- const after = decodeHunkRanges(row.a)
600
- if (!after || !Array.isArray(row.b)) return null
601
- const before: DiffLineRange[][] = []
602
- for (const parent of row.b) {
603
- const ranges = decodeHunkRanges(parent)
604
- if (!ranges) return null
605
- before.push(ranges)
606
- }
607
- return { key: row.i, ranges: { after, before } }
608
- }
609
- function decodeEventPayload(payload: Buffer, location: EventCacheLocation): EventCache | null {
610
- const state = emptyEventCache()
611
- const text = payload.toString('utf8')
612
- for (let start = 0; start < text.length;) {
613
- const newline = text.indexOf('\n', start)
614
- const end = newline < 0 ? text.length : newline
615
- const line = text.slice(start, end)
616
- start = end + 1
617
- if (!line) continue
618
- let value: unknown
619
- try { value = JSON.parse(line) } catch { return null }
620
- if (!value || typeof value !== 'object' || Array.isArray(value)) return null
621
- const row = value as Record<string, unknown>
622
- if (row.k === IMMUTABLE_HUNK_FACT) {
623
- const fact = decodeImmutableHunkFact(row)
624
- if (!fact || state.hunks.has(fact.key)) return null
625
- state.hunks.set(fact.key, fact.ranges)
626
- continue
627
- }
628
- if (exactKeys(row, ['k', 'tip'])) {
629
- const kind = typeof row.k === 'string' && row.k.startsWith('tip:') ? eventStreamKind(row.k.slice(4)) : null
630
- if (!kind || typeof row.tip !== 'string' || !isGitObjectIdForFormat(location.objectFormat, row.tip)) return null
631
- const tips = state.streamTips.get(kind) ?? []
632
- if (tips.includes(row.tip)) return null
633
- tips.push(row.tip)
634
- state.streamTips.set(kind, tips)
635
- continue
636
- }
637
- const kind = eventStreamKind(row.k)
638
- if (!kind || typeof row.h !== 'string' || !isGitObjectIdForFormat(location.objectFormat, row.h)) return null
639
- const record: EventRecord | null = kind === 'identity-raw'
640
- ? (() => {
641
- const payload = exactKeys(row, ['a', 'c', 'd', 'h', 'k', 'r', 's'])
642
- ? { a: row.a, c: row.c, d: row.d, r: row.r, s: row.s }
643
- : null
644
- const identity = payload ? decodeIdentityRawRecord(payload, row.h, location) : null
645
- return identity ? { hash: row.h, identity } : null
646
- })()
647
- : (!exactKeys(row, ['h', 'k', 'r']) || typeof row.r !== 'string' || !row.r
648
- ? null
649
- : (() => {
650
- const rawHash = row.r.split(US, 1)[0].split('\n', 1)[0].trim()
651
- return rawHash === row.h ? { hash: row.h, raw: row.r } : null
652
- })())
653
- if (!record) return null
654
- let stream = state.streams.get(kind)
655
- if (!stream) { stream = new Map(); state.streams.set(kind, stream) }
656
- if (stream.has(row.h)) return null
657
- stream.set(row.h, record)
658
- }
659
- return state
660
- }
661
- function loadEventLedger(location: EventCacheLocation): EventLedgerSnapshot {
662
- eventLedgerDiagnostics.reads++
663
- let file: Buffer
664
- try { file = readFileSync(location.path) }
665
- catch (error: any) {
666
- if (error?.code !== 'ENOENT') throw error
667
- return { payload: Buffer.alloc(0), state: emptyEventCache() }
668
- }
669
- if (!file.length || file[file.length - 1] !== 0x0a)
670
- return { payload: Buffer.alloc(0), state: emptyEventCache() }
671
- const body = file.subarray(0, file.length - 1)
672
- const footerBreak = body.lastIndexOf(0x0a)
673
- const footerStart = footerBreak + 1
674
- const payload = file.subarray(0, footerStart)
675
- try {
676
- const value = JSON.parse(body.subarray(footerStart).toString('utf8')) as unknown
677
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid integrity row')
678
- const row = value as Record<string, unknown>
679
- if (!exactKeys(row, ['bytes', 'k', 'sha256']) || row.k !== 'integrity' || row.bytes !== payload.length
680
- || typeof row.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(row.sha256)
681
- || row.sha256 !== eventPayloadDigest(payload)) throw new Error('invalid integrity row')
682
- const state = decodeEventPayload(payload, location)
683
- if (!state) throw new Error('invalid event row')
684
- return { payload, state }
685
- } catch {
686
- return { payload: Buffer.alloc(0), state: emptyEventCache() }
687
- }
688
- }
689
-
690
- type EventLockOwner = { pid: number; startToken: string; nonce: string }
691
- function readEventLockOwner(lock: string): EventLockOwner | null {
692
- try {
693
- const value = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8')) as { pid?: unknown; startToken?: unknown; nonce?: unknown }
694
- return Number.isInteger(value.pid) && (value.pid as number) > 0
695
- && typeof value.startToken === 'string' && !!value.startToken
696
- && typeof value.nonce === 'string' && !!value.nonce
697
- ? { pid: value.pid as number, startToken: value.startToken, nonce: value.nonce }
698
- : null
699
- } catch { return null }
700
- }
701
- const sameEventLockOwner = (left: EventLockOwner | null, right: EventLockOwner | null): boolean =>
702
- !!left && !!right && left.pid === right.pid && left.startToken === right.startToken && left.nonce === right.nonce
703
-
704
- function eventLockOwnerState(owner: EventLockOwner): 'live' | 'dead' | 'unknown' {
705
- try { process.kill(owner.pid, 0) }
706
- catch (error: any) {
707
- if (error?.code === 'ESRCH') return 'dead'
708
- if (error?.code !== 'EPERM') return 'unknown'
709
- }
710
- const observed = processStartToken(owner.pid)
711
- if (!observed) return 'unknown'
712
- return observed === owner.startToken ? 'live' : 'dead'
713
- }
714
- function retireLockPath(active: string): boolean {
715
- const inert = `${active}.inert.${process.pid}.${randomBytes(8).toString('hex')}`
716
- try { renameSync(active, inert) }
717
- catch (error: any) {
718
- if (error?.code === 'ENOENT') return false
719
- throw error
720
- }
721
- try { rmSync(inert, { recursive: true, force: true }) } catch { /* no longer arbitrates ownership */ }
722
- return true
723
- }
724
- function reclaimDeadEventLock(lock: string, held: EventLockOwner, claimant: EventLockOwner): boolean {
725
- if (eventLockOwnerState(held) !== 'dead') return false
726
- const reclaim = join(lock, 'reclaim')
727
- if (existsSync(reclaim)) {
728
- const reclaimer = readEventLockOwner(reclaim)
729
- if (!reclaimer) throw new Error(`history event cache lock has an unprovable reclaimer: ${lock}`)
730
- const state = eventLockOwnerState(reclaimer)
731
- if (state === 'unknown') throw new Error(`history event cache lock reclaimer identity is unreadable: ${lock}`)
732
- if (state === 'live') return false
733
- retireLockPath(reclaim)
734
- }
735
- const prepared = join(lock, `reclaim-${claimant.pid}-${claimant.nonce}`)
736
- try {
737
- mkdirSync(prepared)
738
- writeFileSync(join(prepared, 'owner.json'), JSON.stringify(claimant))
739
- renameSync(prepared, reclaim)
740
- } catch (error: any) {
741
- rmSync(prepared, { recursive: true, force: true })
742
- if (error?.code === 'EEXIST' || error?.code === 'ENOTEMPTY' || error?.code === 'ENOENT') return false
743
- throw error
744
- }
745
- const current = readEventLockOwner(lock)
746
- if (!sameEventLockOwner(current, held) || (current && eventLockOwnerState(current) !== 'dead')) {
747
- retireLockPath(reclaim)
748
- return false
749
- }
750
- return retireLockPath(lock)
751
- }
752
- const EVENT_LEDGER_BUSY = Symbol('event ledger held by a live writer')
753
-
754
- async function withEventCacheLock<T>(
755
- path: string,
756
- run: () => Promise<T> | T,
757
- waitForWriter = true,
758
- ): Promise<T | typeof EVENT_LEDGER_BUSY> {
759
- const lock = `${path}.lock`
760
- mkdirSync(join(path, '..'), { recursive: true })
761
- const startToken = processStartToken(process.pid)
762
- if (!startToken) throw new Error(`cannot prove history event cache lock claimant identity: ${path}`)
763
- const owner: EventLockOwner = { pid: process.pid, startToken, nonce: randomBytes(16).toString('hex') }
764
- const attempts = Math.max(1, Math.ceil(GIT_TIMEOUT_MS / 5))
765
- const signal = inheritedContext()?.signal
766
- for (let attempt = 0; ; attempt++) {
767
- if (signal?.aborted) throw gitAbortError()
768
- const claimant = `${lock}.claim.${owner.pid}.${owner.nonce}`
769
- try {
770
- mkdirSync(claimant)
771
- writeFileSync(join(claimant, 'owner.json'), JSON.stringify(owner))
772
- renameSync(claimant, lock)
773
- break
774
- } catch (error: any) {
775
- rmSync(claimant, { recursive: true, force: true })
776
- if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error
777
- const held = readEventLockOwner(lock)
778
- if (!held) {
779
- if (!existsSync(lock)) continue
780
- throw new Error(`history event cache lock has no provable exact owner: ${path}`)
781
- }
782
- const heldState = eventLockOwnerState(held)
783
- if (heldState === 'unknown')
784
- throw new Error(`history event cache lock owner identity is unreadable: ${path}`)
785
- if (heldState === 'dead') {
786
- if (reclaimDeadEventLock(lock, held, owner)) continue
787
- if (!existsSync(lock)) continue
788
- }
789
- if (!waitForWriter) {
790
- const current = readEventLockOwner(lock)
791
- if (!current) {
792
- if (!existsSync(lock)) continue
793
- throw new Error(`history event cache lock has no provable exact owner: ${path}`)
794
- }
795
- const state = eventLockOwnerState(current)
796
- if (state === 'live') return EVENT_LEDGER_BUSY
797
- // A live reclaimer may be arbitrating this exact dead owner. Foreground demand can safely derive
798
- // from the atomic snapshot, but must not spin synchronously until that separate lease finishes.
799
- if (state === 'dead') return EVENT_LEDGER_BUSY
800
- throw new Error(`history event cache lock owner identity is unreadable: ${path}`)
801
- }
802
- if (attempt >= attempts) {
803
- const heldBy = readEventLockOwner(lock)?.pid
804
- throw new Error(`timed out waiting for history event cache lock held by ${heldBy ? `pid ${heldBy}` : 'unknown owner'}: ${path}`)
805
- }
806
- await new Promise((resolve) => setTimeout(resolve, 5))
807
- }
808
- }
809
- eventLedgerDiagnostics.locks++
810
- try { return await run() } finally {
811
- if (sameEventLockOwner(readEventLockOwner(lock), owner)) retireLockPath(lock)
812
- }
813
- }
814
- function removeEventTemps(path: string): void {
815
- const dir = join(path, '..'), base = path.split('/').pop() ?? ''
816
- try {
817
- for (const name of readdirSync(dir)) if (name.startsWith(`${base}.`) && name.endsWith('.tmp'))
818
- rmSync(join(dir, name), { force: true })
819
- } catch { /* the writer creates the directory immediately below */ }
820
- }
821
- function replaceEventLedger(path: string, payload: Buffer, additions: string[]): void {
822
- const addition = Buffer.from(additions.join(''))
823
- const tmp = `${path}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`
824
- let fd: number | null = null
825
- try {
826
- fd = openSync(tmp, 'wx', 0o600)
827
- writeFileSync(fd, payload)
828
- writeFileSync(fd, addition)
829
- writeFileSync(fd, eventIntegrityFooter(payload, addition))
830
- const written = fd; fd = null; closeSync(written)
831
- renameSync(tmp, path)
832
- eventLedgerDiagnostics.replaces++
833
- } catch (error) {
834
- if (fd !== null) closeSync(fd)
835
- rmSync(tmp, { force: true })
836
- throw error
837
- }
838
- }
839
- const EVENT_LEDGER_RETRY = Symbol('event ledger identity moved')
840
-
841
- function activeEventLedger(root: string): EventLedgerBuild | null {
842
- const build = eventLedgerBuild.getStore()
843
- if (!build) return null
844
- return eventCacheLocation(root).path === build.location.path ? build : null
845
- }
846
-
847
- async function runEventLedgerAttempt<T>(
848
- root: string,
849
- location: EventCacheLocation,
850
- run: () => Promise<T>,
851
- persist: boolean,
852
- ): Promise<T | typeof EVENT_LEDGER_RETRY> {
853
- const build: EventLedgerBuild = { location, snapshot: loadEventLedger(location), additions: [] }
854
- const result = await eventLedgerBuild.run(build, run)
855
- if (eventCacheLocation(root).identity !== location.identity) return EVENT_LEDGER_RETRY
856
- if (persist && build.additions.length) {
857
- removeEventTemps(location.path)
858
- mkdirSync(join(location.path, '..'), { recursive: true })
859
- replaceEventLedger(location.path, build.snapshot.payload, build.additions)
860
- }
861
- return result
862
- }
863
-
864
- async function eventLedgerTransaction<T>(
865
- root: string,
866
- run: () => Promise<T>,
867
- demand: boolean,
868
- ): Promise<T> {
869
- if (activeEventLedger(root)) return run()
870
- for (let attempt = 0; attempt < 8; attempt++) {
871
- const location = eventCacheLocation(root)
872
- const locked = await withEventCacheLock(
873
- location.path,
874
- () => runEventLedgerAttempt(root, location, run, true),
875
- !demand,
876
- )
877
- const value = locked === EVENT_LEDGER_BUSY
878
- ? await runEventLedgerAttempt(root, location, run, false)
879
- : locked
880
- if (value !== EVENT_LEDGER_RETRY) return value as T
881
- }
882
- throw new Error('history event cache identity changed repeatedly during one ledger build')
883
- }
884
-
885
- // The event ledger is one build transaction, not one transaction per consumer: stream extraction and
886
- // immutable hunk derivation share the snapshot, integrity verdict, lock, and final replacement.
887
- export function withEventLedgerBuild<T>(root: string, run: () => Promise<T>): Promise<T> {
888
- const demand = eventLedgerDemandPolicy.getStore()
889
- return eventLedgerTransaction(root, run, !!demand && demand.path === eventCacheLocation(root).path)
890
- }
891
-
892
- // Demand is an ambient acquisition POLICY, not an eager lock. Nested ledger consumers therefore use the
893
- // read-only path under a live writer, while observer waits, revision reads, and stable-cut replay take no lock.
894
- export function withEventLedgerDemand<T>(root: string, run: () => Promise<T>): Promise<T> {
895
- const path = eventCacheLocation(root).path
896
- if (eventLedgerDemandPolicy.getStore()?.path === path) return run()
897
- return eventLedgerDemandPolicy.run({ path }, run)
898
- }
899
-
900
- export function eventLedgerDiagnosticsForTests(): EventLedgerDiagnostics { return { ...eventLedgerDiagnostics } }
901
- export function resetEventLedgerDiagnosticsForTests(): void {
902
- eventLedgerDiagnostics.reads = 0; eventLedgerDiagnostics.locks = 0; eventLedgerDiagnostics.replaces = 0
903
- }
904
- function sortedEventRecords(records: Iterable<EventRecord>, request: EventStreamRequest): EventRecord[] {
905
- return [...records].filter((record) => request.reachable.has(record.hash))
906
- .sort((a, b) => (request.order.get(a.hash) ?? Number.MAX_SAFE_INTEGER) - (request.order.get(b.hash) ?? Number.MAX_SAFE_INTEGER))
907
- }
908
- function identityRecords(records: Iterable<EventRecord>, request: EventStreamRequest): IdentityRawRecord[] {
909
- return sortedEventRecords(records, request).map((record) => {
910
- if (!('identity' in record)) throw new Error('identity-raw ledger contained a text event')
911
- return record.identity
912
- })
913
- }
914
- function renderEventStream(state: EventCache, request: EventStreamRequest): EventStreamOutput {
915
- const records = sortedEventRecords(state.streams.get(request.kind)?.values() ?? [], request)
916
- if (request.kind === 'identity-raw') return records.map((record) => {
917
- if (!('identity' in record)) throw new Error('identity-raw ledger contained a text event')
918
- return record.identity
919
- })
920
- return records.map((record) => {
921
- if (!('raw' in record)) throw new Error(`history event stream '${request.kind}' contained a structured event`)
922
- return RS + record.raw
923
- }).join('')
924
- }
925
- function appendEventRecord(state: EventCache, kind: EventStreamKind, record: EventRecord, additions: string[]): void {
926
- let stream = state.streams.get(kind)
927
- if (!stream) { stream = new Map(); state.streams.set(kind, stream) }
928
- if (stream.has(record.hash)) return
929
- stream.set(record.hash, record)
930
- additions.push('identity' in record
931
- ? JSON.stringify({ k: kind, h: record.hash, d: record.identity.d, r: record.identity.r, s: record.identity.s, a: record.identity.a, c: record.identity.c.flat() }) + '\n'
932
- : JSON.stringify({ k: kind, h: record.hash, r: record.raw }) + '\n')
933
- }
934
- function appendEventTip(state: EventCache, kind: EventStreamKind, tip: string, additions: string[]): void {
935
- const tips = state.streamTips.get(kind) ?? []
936
- if (tips.includes(tip)) return
937
- tips.push(tip)
938
- state.streamTips.set(kind, tips)
939
- additions.push(JSON.stringify({ k: `tip:${kind}`, tip }) + '\n')
940
- }
941
- function appendImmutableHunkFact(state: EventCache, key: string, ranges: ImmutableHunkRanges, additions: string[]): void {
942
- const existing = state.hunks.get(key)
943
- if (existing) {
944
- if (JSON.stringify(existing) !== JSON.stringify(ranges))
945
- throw new Error(`immutable hunk ledger fact disagrees for image identity ${JSON.stringify(key)}`)
946
- return
947
- }
948
- state.hunks.set(key, ranges)
949
- additions.push(JSON.stringify({ k: IMMUTABLE_HUNK_FACT, i: key, a: ranges.after, b: ranges.before }) + '\n')
950
- }
951
- function parseEventRecords(out: string, kind: EventStreamKind, location: EventCacheLocation): EventRecord[] {
952
- if (kind === 'identity-raw') return parseIdentityRawEventRecords(out, location)
953
- const records: EventRecord[] = []
954
- for (const rec of out.split(RS)) {
955
- const raw = rec.replace(/^\n/, '')
956
- if (!raw) continue
957
- const hash = raw.split(US, 1)[0].split('\n', 1)[0].trim()
958
- if (!isGitObjectIdForFormat(location.objectFormat, hash))
959
- throw new Error(`history event stream '${kind}' returned malformed object id '${hash || 'empty'}'`)
960
- records.push({ hash, raw })
961
- }
962
- return records
963
- }
964
- function indexEventRequests(
965
- root: string,
966
- tip: string,
967
- order: Map<string, number>,
968
- reachable: Set<string>,
969
- ): Record<EventStreamKind, EventStreamRequest> {
970
- return {
971
- 'identity-raw': {
972
- kind: 'identity-raw', order, reachable,
973
- argsFor: (base) => ['-C', root, '-c', 'core.quotePath=false',
974
- 'log', '--root', '--full-history', '--date-order', '--no-diff-merges', '-M', '-l0', '--raw', '-z', '--no-abbrev', '--no-ext-diff', '--no-textconv',
975
- `--format=${RS}%H%x00%aI%x00%s%x00%b%x00%(trailers:key=Spec-OK,valueonly,separator=%x2C)%x00`,
976
- ...(base ? [`^${base}`] : []), tip],
977
- },
978
- merge: {
979
- kind: 'merge', order, reachable,
980
- argsFor: (base) => ['-C', root, '-c', 'core.quotePath=false',
981
- 'log', '--merges', '--raw', '--patch', '--cc', '--combined-all-paths', '--unified=0', '--no-color', '--no-ext-diff', '-M',
982
- `--format=${RS}%H`, ...(base ? [`^${base}`] : []), tip],
983
- },
984
- }
985
- }
986
-
987
- async function strictEventGit(args: string[]): Promise<string> {
988
- return gitRequiredA(args, 'cannot derive history events')
989
- }
990
- async function deriveEventStreams(
991
- root: string,
992
- tip: string,
993
- requests: EventStreamRequest[],
994
- persist = true,
995
- cache = true,
996
- ): Promise<Map<EventStreamKind, EventStreamOutput>> {
997
- if (!cache) {
998
- const location = eventCacheLocation(root)
999
- const outputs = await Promise.all(requests.map((request) => strictEventGit(request.argsFor(''))))
1000
- const rendered = new Map<EventStreamKind, EventStreamOutput>()
1001
- for (let index = 0; index < requests.length; index++) {
1002
- const request = requests[index]
1003
- rendered.set(request.kind, request.kind === 'identity-raw'
1004
- ? identityRecords(parseIdentityRawEventRecords(outputs[index], location), request)
1005
- : outputs[index])
1006
- }
1007
- return rendered
1008
- }
1009
- if (new Set(requests.map((request) => request.kind)).size !== requests.length)
1010
- throw new Error('one event-ledger transaction cannot request the same stream twice')
1011
- const build = activeEventLedger(root)
1012
- if (!build) return withEventLedgerBuild(root, () => deriveEventStreams(root, tip, requests, persist, cache))
1013
- const { location, snapshot, additions } = build
1014
- const missing = requests.filter((request) => !(snapshot.state.streamTips.get(request.kind) ?? []).includes(tip))
1015
- const outputs = await Promise.all(missing.map((request) => {
1016
- const base = [...(snapshot.state.streamTips.get(request.kind) ?? [])].reverse()
1017
- .find((candidate) => request.reachable.has(candidate)) ?? ''
1018
- return strictEventGit(request.argsFor(base))
1019
- }))
1020
- for (let index = 0; index < missing.length; index++) {
1021
- const request = missing[index]
1022
- for (const record of parseEventRecords(outputs[index], request.kind, location))
1023
- appendEventRecord(snapshot.state, request.kind, record, additions)
1024
- if (persist) appendEventTip(snapshot.state, request.kind, tip, additions)
1025
- }
1026
- return new Map(requests.map((request) => [request.kind, renderEventStream(snapshot.state, request)]))
1027
- }
1028
-
1029
- // Immutable hunk ranges are a ledger fact, not a second anchor cache: callers name their whole image-key
1030
- // demand, receive only facts the shared ledger already certified, and derive/persist misses in their own read.
1031
- export function readImmutableHunkFacts(root: string, keys: Iterable<string>): Map<string, ImmutableHunkRanges> {
1032
- const wanted = new Set(keys)
1033
- if (!wanted.size) return new Map()
1034
- const build = activeEventLedger(root)
1035
- if (!build) return new Map()
1036
- const found = new Map<string, ImmutableHunkRanges>()
1037
- for (const key of wanted) {
1038
- const ranges = build.snapshot.state.hunks.get(key)
1039
- if (ranges) found.set(key, ranges)
1040
- }
1041
- return found
1042
- }
1043
-
1044
- export async function persistImmutableHunkFacts(root: string, facts: ReadonlyMap<string, ImmutableHunkRanges>): Promise<void> {
1045
- if (!facts.size) return
1046
- const build = activeEventLedger(root)
1047
- if (!build) return withEventLedgerBuild(root, () => persistImmutableHunkFacts(root, facts))
1048
- for (const [key, ranges] of facts) appendImmutableHunkFact(build.snapshot.state, key, ranges, build.additions)
1049
- }
1050
-
1051
- async function eventStream(
1052
- root: string,
1053
- tip: string,
1054
- request: EventStreamRequest,
1055
- persist = true,
1056
- cache = true,
1057
- ): Promise<EventStreamOutput> {
1058
- const value = (await deriveEventStreams(root, tip, [request], persist, cache)).get(request.kind)
1059
- if (value === undefined) throw new Error(`history event stream '${request.kind}' was not rendered`)
1060
- return value
1061
- }
1062
- async function textEventStream(root: string, tip: string, request: EventStreamRequest, persist = true, cache = true): Promise<string> {
1063
- const value = await eventStream(root, tip, request, persist, cache)
1064
- if (typeof value !== 'string') throw new Error(`history event stream '${request.kind}' rendered structured data`)
1065
- return value
1066
- }
1067
- async function identityRawEventStream(root: string, tip: string, request: EventStreamRequest, persist = true, cache = true): Promise<IdentityRawRecord[]> {
1068
- const value = await eventStream(root, tip, request, persist, cache)
1069
- if (!Array.isArray(value) || value.some((record) => !('a' in record))) throw new Error(`history event stream '${request.kind}' rendered text`)
1070
- return value as IdentityRawRecord[]
1071
- }
1072
- export type GitTryFailure = 'exit' | 'spawn' | 'timeout'
1073
- export async function gitTry(args: string[], options: { indexFile?: string; extraEnv?: Record<string, string | undefined>; input?: string } = {}): Promise<{ ok: boolean; stdout: string; stderr: string; failure?: GitTryFailure }> {
1074
- const env = { ...process.env }
1075
- for (const [key, value] of Object.entries(options.extraEnv ?? {})) {
1076
- if (value === undefined) delete env[key]
1077
- else env[key] = value
1078
- }
1079
- delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
1080
- if (options.indexFile) env.GIT_INDEX_FILE = options.indexFile
1081
- const context = inheritedContext()
1082
- try {
1083
- const { stdout, stderr } = await execGitForCaller(args, env, undefined, options.input)
1084
- return { ok: true, stdout: stdout.toString('utf8'), stderr }
1085
- } catch (e: any) {
1086
- if (context?.signal.aborted || e?.name === 'AbortError') throw e
1087
- warnIfTimedOut(e, args)
1088
- const failure: GitTryFailure = e?.spexcodeGitTimeout ? 'timeout' : typeof e?.code === 'number' ? 'exit' : 'spawn'
1089
- return { ok: false, stdout: e?.stdout ?? '', stderr: e?.stderr ?? String(e?.message ?? e), failure }
1090
- }
1091
- }
1092
-
1093
- // A walk whose OUTPUT is a projection the caller is intentionally building reads through the streamed
1094
- // transport (no fixed stdout bound); `input` lets its revision roster ride stdin, so argv cannot grow with
1095
- // the roster and needs no chunking.
1096
- export async function gitRequiredA(args: string[], purpose: string, options: { input?: string; extraEnv?: Record<string, string | undefined> } = {}): Promise<string> {
1097
- const env = { ...process.env }
1098
- for (const [key, value] of Object.entries(options.extraEnv ?? {})) {
1099
- if (value === undefined) delete env[key]
1100
- else env[key] = value
1101
- }
1102
- delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
1103
- try { return (await execGitStreamForCaller(args, env, options.input)).stdout.toString('utf8') }
1104
- catch (error: any) {
1105
- if (error?.name === 'AbortError') throw error
1106
- warnIfTimedOut(error, args)
1107
- throw new Error(`${purpose}: git ${args.slice(2).join(' ')} failed: ${String(error?.stderr || error?.message || 'unknown git error').trim()}`)
1108
- }
1109
- }
1110
-
1111
- // memoized: repoRoot is constant per process, but resolveLayout() calls it per request — avoid a git fork each time.
1112
- let repoRootCache: string | null = null
1113
- export function repoRoot(): string {
1114
- if (repoRootCache !== null) return repoRootCache
1115
- try {
1116
- repoRootCache = git(['rev-parse', '--show-toplevel']).trim()
1117
- } catch {
1118
- repoRootCache = process.cwd()
1119
- }
1120
- return repoRootCache
1121
- }
1122
-
1123
- function gitDirOf(root: string): string {
1124
- // a normal checkout has a `.git` DIRECTORY; a linked worktree has a `.git` FILE: `gitdir: <path>`.
1125
- const dotgit = join(root, '.git')
1126
- if (!existsSync(dotgit)) {
1127
- try {
1128
- if (git(['-C', root, 'rev-parse', '--is-bare-repository']).trim() === 'true') return root
1129
- } catch { /* the caller will receive the same loud path error as a malformed checkout */ }
1130
- throw new Error(`headSha: no .git directory at ${dotgit}`)
1131
- }
1132
- if (statSync(dotgit).isDirectory()) return dotgit
1133
- const m = readFileSync(dotgit, 'utf8').match(/^gitdir:\s*(.+)$/m)
1134
- if (!m) throw new Error(`headSha: unparseable .git file at ${dotgit}`)
1135
- const dir = m[1].trim()
1136
- return isAbsolute(dir) ? dir : resolve(root, dir)
1137
- }
1138
- function commonDirOf(gitDir: string): string {
1139
- // a worktree's gitdir holds per-worktree state (HEAD); SHARED refs (refs/heads/*, packed-refs) live
1140
- // in the common dir, named by the `commondir` pointer. A plain checkout IS its own common dir.
1141
- const p = join(gitDir, 'commondir')
1142
- if (!existsSync(p)) return gitDir
1143
- const c = readFileSync(p, 'utf8').trim()
1144
- return isAbsolute(c) ? c : resolve(gitDir, c)
1145
- }
1146
- export function headSha(root: string): string {
1147
- const gitDir = gitDirOf(root)
1148
- const head = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim()
1149
- const ref = head.match(/^ref:\s*(.+)$/)
1150
- if (!ref) return head // detached HEAD: the file already holds the sha
1151
- const name = ref[1].trim()
1152
- // a loose ref wins over packed; per-worktree HEAD points at a branch whose ref lives in the common dir.
1153
- const looseWt = join(gitDir, name)
1154
- if (existsSync(looseWt)) return readFileSync(looseWt, 'utf8').trim()
1155
- const common = commonDirOf(gitDir)
1156
- const loose = join(common, name)
1157
- if (existsSync(loose)) return readFileSync(loose, 'utf8').trim()
1158
- const packed = join(common, 'packed-refs')
1159
- if (existsSync(packed)) {
1160
- for (const line of readFileSync(packed, 'utf8').split('\n')) {
1161
- if (!line || line[0] === '#' || line[0] === '^') continue
1162
- const sp = line.indexOf(' ')
1163
- if (sp > 0 && line.slice(sp + 1).trim() === name) return line.slice(0, sp).trim()
1164
- }
1165
- }
1166
- // an UNBORN HEAD — a fresh `git init` with no commits — points at a branch ref that doesn't exist yet.
1167
- // That is a valid EMPTY-HISTORY state, not a failure: the board renders fine from the working tree. Return
1168
- // a stable, truthy sentinel so historyIndex/driftIndex/safeHead MEMOIZE it (the head value is only ever a
1169
- // cache key, never a git ref) instead of re-forking git on every read; headOrEmpty's warning is then
1170
- // reserved for a genuinely unreadable HEAD and never fires for this routine first-run state.
1171
- return `unborn:${name}`
1172
- }
1173
-
1174
- // fingerprint of a worktree's `.spec` working tree by path + mtimeMs + size (no git); the overlay-cache
1175
- // key for its working-tree state. '' when `.spec` is absent.
1176
- export function worktreeSpecSig(wtPath: string): string {
1177
- const root = join(wtPath, '.spec')
1178
- if (!existsSync(root)) return ''
1179
- const parts: string[] = []
1180
- const stack = [root]
1181
- while (stack.length) {
1182
- const dir = stack.pop()!
1183
- let ents
1184
- try { ents = readdirSync(dir, { withFileTypes: true }) } catch { continue }
1185
- for (const e of ents) {
1186
- const p = join(dir, e.name)
1187
- if (e.isDirectory()) { stack.push(p); continue }
1188
- try { const st = statSync(p); parts.push(`${p}:${st.mtimeMs}:${st.size}`) } catch { /* vanished mid-walk */ }
1189
- }
1190
- }
1191
- return parts.sort().join('\n')
1192
- }
1193
-
1194
- export type Version = { hash: string; date: string; reason: string; session: string | null }
1195
- export type DiffStat = { additions: number; deletions: number; files: number }
1196
-
1197
- // ---- bulk spec history index ----
1198
-
1199
- export type HistoryIndex = {
1200
- versions: Map<string, Version[]> // headPath -> rows newest-first (incl. pure-rename rows)
1201
- contentVersions: Set<string> // headPath\0hash rows whose immutable blob changed
1202
- versionPaths: Map<string, string> // headPath\0hash -> path at that version commit
1203
- mergeVersions?: Set<string> // path\0hash pairs with an all-parent combined-diff line
1204
- }
1205
-
1206
- type IdentityRawRecord = { h: string; d: string; r: string; s: string | null; a: string; c: [string, string, string, string, string][] }
1207
-
1208
- function isRawObjectId(value: string, format: GitObjectFormat): boolean {
1209
- const length = format === 'sha256' ? 64 : 40
1210
- return isGitObjectIdForFormat(format, value) || (value.length === length && /^0+$/.test(value))
1211
- }
1212
-
1213
- function parseIdentityRawEventRecords(out: string, location: EventCacheLocation): EventRecord[] {
1214
- const parsed: IdentityRawRecord[] = []
1215
- let current: IdentityRawRecord | null = null
1216
- const tokens = out.split('\0')
1217
- let index = 0
1218
- const metadata = (field: string): string => {
1219
- const token = tokens[index++]
1220
- if (token === undefined) throw new Error(`raw identity stream ended before commit ${field}`)
1221
- return token
1222
- }
1223
- const begin = (token: string): IdentityRawRecord => {
1224
- const header = token.startsWith('\n') ? token.slice(1) : token
1225
- if (!header.startsWith(RS)) throw new Error(`raw identity stream expected a commit header, got '${header}'`)
1226
- const hash = header.slice(RS.length)
1227
- if (!isGitObjectIdForFormat(location.objectFormat, hash))
1228
- throw new Error(`history event stream 'identity-raw' returned malformed object id '${hash || 'empty'}'`)
1229
- if (current) parsed.push(current)
1230
- const date = metadata('date'), reason = metadata('subject'), body = metadata('body'), ack = metadata('trailers')
1231
- const session = body.match(/Session:\s*(\S+)/)
1232
- return { h: hash, d: date, r: reason, s: session ? session[1] : null, a: ack, c: [] }
1233
- }
1234
- while (index < tokens.length) {
1235
- const token = tokens[index++]
1236
- if (!current) {
1237
- if (!token) continue
1238
- current = begin(token)
1239
- continue
1240
- }
1241
- const value = token.startsWith('\n') ? token.slice(1) : token
1242
- if (!value) continue
1243
- if (value.startsWith(RS)) { current = begin(value); continue }
1244
- const raw = value.match(/^:([0-7]{6}) ([0-7]{6}) ([0-9a-f]+) ([0-9a-f]+) ([A-Z])(?:\d+)?$/)
1245
- if (!raw || !isRawObjectId(raw[3], location.objectFormat) || !isRawObjectId(raw[4], location.objectFormat))
1246
- throw new Error(`raw identity event ${current.h} has malformed raw record '${value}'`)
1247
- const from = tokens[index++]
1248
- if (from === undefined) throw new Error(`raw identity event ${current.h} ended before its path`)
1249
- const to = raw[5] === 'R' ? tokens[index++] : from
1250
- if (to === undefined) throw new Error(`raw identity event ${current.h} ended before its rename destination`)
1251
- current.c.push([raw[5], from, to, raw[3], raw[4]])
1252
- }
1253
- if (current) parsed.push(current)
1254
- return parsed.map((identity) => ({ hash: identity.h, identity }))
1255
- }
1256
-
1257
- function decodeIdentityRawRecord(value: unknown, hash: string, location: EventCacheLocation): IdentityRawRecord | null {
1258
- if (!value || typeof value !== 'object' || Array.isArray(value)) return null
1259
- const record = value as Record<string, unknown>
1260
- if (!exactKeys(record, ['a', 'c', 'd', 'r', 's']) || typeof record.a !== 'string' || typeof record.d !== 'string'
1261
- || typeof record.r !== 'string' || (typeof record.s !== 'string' && record.s !== null) || !Array.isArray(record.c) || record.c.length % 5) return null
1262
- const changes: [string, string, string, string, string][] = []
1263
- for (let index = 0; index < record.c.length; index += 5) {
1264
- if (typeof record.c[index] !== 'string' || typeof record.c[index + 1] !== 'string' || typeof record.c[index + 2] !== 'string'
1265
- || typeof record.c[index + 3] !== 'string' || typeof record.c[index + 4] !== 'string'
1266
- || !/^[A-Z](?:\d+)?$/.test(record.c[index] as string) || !isRawObjectId(record.c[index + 3] as string, location.objectFormat)
1267
- || !isRawObjectId(record.c[index + 4] as string, location.objectFormat)) return null
1268
- changes.push([record.c[index], record.c[index + 1], record.c[index + 2], record.c[index + 3], record.c[index + 4]] as [string, string, string, string, string])
1269
- }
1270
- return { h: hash, d: record.d, r: record.r, s: record.s, a: record.a, c: changes }
1271
- }
1272
-
1273
- // Both bulk indices are pure functions of a checkout's HEAD, and they are read for SEVERAL roots at
1274
- // once — the backend checkout (board, loadSpecs) plus every session worktree ([[session-eval]]'s eval
1275
- // tab roots its readings at the session's branch). A single-slot cache thrashes between those roots:
1276
- // each eval-tab request evicts the board's entry and vice versa, so every request re-runs a full-history
1277
- // `git log` and re-parses it on the event loop — which is what starves every other request (the board,
1278
- // remark posts) under load. So the cache is a small LRU keyed by interpretation identity + HEAD, holding
1279
- // the in-flight PROMISE so concurrent requests for one immutable view share a single build.
1280
- const indexCache = new Map<string, Promise<HistoryIndex>>()
1281
- const indexRoots = new Map<string, string>()
1282
- const driftRoots = new Map<string, string>()
1283
- const driftIdxCache = new Map<string, Promise<DriftIndex>>()
1284
- const INDEX_ROOT_SLOTS = rootSlots(process.env.SPEXCODE_INDEX_CACHE_ROOTS, 32)
1285
-
1286
- function rootKey(root: string): string { return resolve(root) }
1287
-
1288
- function indexCacheKey(root: string, head: string): string { return `${eventCacheLocation(root).path}\0${head}` }
1289
-
1290
- // A project-namespaced ledger path plus HEAD identifies immutable index contents. Its path is scoped to the
1291
- // common Git store and interpretation state, so linked worktrees share while independent same-HEAD clones do not.
1292
- // The checkout root owns which live view is useful; the root bound keeps closed worktrees from leaking.
1293
- // The bookkeeping itself is [[root-lru]]'s — this only names the store and the bound.
1294
- function touchRoot(roots: Map<string, string>, cache: Map<string, Promise<unknown>>, root: string, cacheKey: string): void {
1295
- touchRootLru(roots, cache, rootKey(root), cacheKey, INDEX_ROOT_SLOTS)
1296
- }
1297
-
1298
- function dropFailed(cache: Map<string, Promise<unknown>>, head: string, promise: Promise<unknown>): void {
1299
- if (cache.get(head) !== promise) return
1300
- // A rejected index is never reusable, even when its root still points at that HEAD. The next read must
1301
- // start a fresh walk after a watchdog abort or transient git failure.
1302
- cache.delete(head)
1303
- }
1304
-
1305
- function pendingOnlyIssues(root: string, tip: string): boolean {
1306
- try {
1307
- const parents = git(['-C', root, 'rev-list', '--parents', '-n1', tip]).trim().split(/\s+/).filter(Boolean)
1308
- // A first-parent issue diff can hide side-branch code debt in an ours merge. Only a single-parent
1309
- // candidate may reuse its parent's indexes; every merge must build the candidate's full reachable view.
1310
- if (parents.length !== 2) return false
1311
- const parent = git(['-C', root, 'rev-parse', `${tip}^`]).trim()
1312
- const paths = git(['-C', root, 'diff-tree', '--no-commit-id', '--name-only', '-r', parent, tip])
1313
- .split('\n').map((p) => p.trim()).filter(Boolean)
1314
- return paths.length > 0 && paths.every((p) => p.startsWith('.spec/.issues/'))
1315
- } catch { return false }
1316
- }
1317
- function pendingParent(root: string, tip: string): string | null {
1318
- try {
1319
- const parent = git(['-C', root, 'rev-parse', `${tip}^`]).trim()
1320
- return isGitObjectId(root, parent) ? parent : null
1321
- } catch { return null }
1322
- }
1323
-
1324
- export function historyIndex(root: string, tip = 'HEAD'): Promise<HistoryIndex> {
1325
- if (tip !== 'HEAD') {
1326
- const resolved = git(['-C', root, 'rev-parse', `${tip}^{commit}`]).trim()
1327
- // An issue-only candidate changes no governed content, but its lint result still includes the
1328
- // parent's existing drift/related-drift warnings. Reuse that immutable parent index instead of
1329
- // manufacturing an empty one (which silently dropped warnings at golden depths).
1330
- if (pendingOnlyIssues(root, resolved)) {
1331
- const parent = pendingParent(root, resolved)
1332
- if (parent) {
1333
- const head = headOrEmpty(root)
1334
- if (head === parent) return historyIndex(root)
1335
- return buildIndex(root, parent, true, true)
1336
- }
1337
- }
1338
- return buildIndex(root, resolved, true, true)
1339
- }
1340
- const head = headOrEmpty(root)
1341
- if (!head) return buildIndex(root, 'HEAD', false, true)
1342
- const cacheKey = indexCacheKey(root, head)
1343
- touchRoot(indexRoots, indexCache, root, cacheKey)
1344
- const hit = indexCache.get(cacheKey)
1345
- if (hit) return hit
1346
- const p = buildIndex(root, head.startsWith('unborn:') ? 'HEAD' : head, false, true)
1347
- p.catch(() => { dropFailed(indexCache, cacheKey, p) }) // don't pin a failed build
1348
- indexCache.set(cacheKey, p)
1349
- return p
1350
- }
1351
-
1352
- // resolve HEAD for cache-keying, '' if unreadable (fails the cache test → recompute); warns once.
1353
- let headWarned = false
1354
- function headOrEmpty(root: string): string {
1355
- try { return headSha(root) }
1356
- catch (e) {
1357
- if (!headWarned) { headWarned = true; console.warn(`spec-cli: headSha failed, recomputing every read: ${(e as Error).message}`) }
1358
- return ''
1359
- }
1360
- }
1361
-
1362
- // @@@ reachability memoized on the rename side, never the event side - every comparison the projector makes
1363
- // has a RENAME commit on one end, and a history holds far fewer renames than file events. Keying the memo on
1364
- // the OTHER end rebuilds a history-wide ancestor set per distinct event commit: 2.3M parent-edge visits and
1365
- // 1,219 retained bitsets served 9k one-bit questions on this repository. That end builds one closure per
1366
- // distinct event commit actually compared against a rename — C of them, O(C(H+G)) construction and Θ(CH) bits
1367
- // — which a linear history whose events all sit on one renamed path drives to Θ(H²). Asking the rename end
1368
- // instead — its descendants when it is the older commit, its ancestors when it is the newer one — moves ONLY
1369
- // the closure term onto the rename count K: at most 2K full-size closures, O(K(H+G)) construction over H
1370
- // reachable commits and G parent edges, O(KH) bits. That 2K ceiling bounds closure buffers, count and bytes,
1371
- // against C; it does NOT bound runtime or edge visits, since a rename with many unrelated descendants
1372
- // traverses ground the event-side ancestor walk never touched. The projector's own work is unchanged and is
1373
- // NOT covered by that term: one scan of the N events, plus a lineage walk whose frontier compares each step's
1374
- // applicable renames pairwise — Σ d(candidate)² O(1) queries, worst case Θ(NK²) when one path carries K
1375
- // mutually incomparable renames. So: no linear-in-history promise — at K≈H the closure term is O(H(H+G)),
1376
- // quadratic only where the DAG is sparse enough that G=O(H), and the untouched frontier term is cubic when
1377
- // N, K and H grow together. It is also why a
1378
- // reachability matrix over the rename commits is not worth it: same O(KH) bits, but eagerly.
1379
- function renameSideReachability(
1380
- renameCommits: Set<string>,
1381
- topology: TopologyProjection,
1382
- ): (older: string, newer: string) => boolean {
1383
- const { order, parents } = topology
1384
- const size = (order.size + 7) >> 3
1385
- let childEdges: Map<string, string[]> | null = null
1386
- const children = (): Map<string, string[]> => {
1387
- if (childEdges) return childEdges
1388
- childEdges = new Map()
1389
- for (const [hash] of order) for (const parent of parents.get(hash) ?? []) {
1390
- if (!order.has(parent)) continue // shallow boundary: an unwalked parent ends the chain
1391
- const kids = childEdges.get(parent)
1392
- if (kids) kids.push(hash)
1393
- else childEdges.set(parent, [hash])
1394
- }
1395
- return childEdges
1396
- }
1397
- const closure = (start: string, edges: Map<string, string[]>, memo: Map<string, Uint8Array>): Uint8Array => {
1398
- const hit = memo.get(start)
1399
- if (hit) return hit
1400
- const bits = new Uint8Array(size)
1401
- const at = order.get(start)!
1402
- bits[at >> 3] |= 1 << (at & 7)
1403
- const stack = [start]
1404
- while (stack.length) for (const next of edges.get(stack.pop()!) ?? []) {
1405
- const position = order.get(next)
1406
- if (position === undefined) continue
1407
- const mask = 1 << (position & 7)
1408
- if (bits[position >> 3] & mask) continue
1409
- bits[position >> 3] |= mask
1410
- stack.push(next)
1411
- }
1412
- memo.set(start, bits)
1413
- return bits
1414
- }
1415
- const ancestors = new Map<string, Uint8Array>(), descendants = new Map<string, Uint8Array>()
1416
- return (older, newer) => {
1417
- const from = order.get(older), to = order.get(newer)
1418
- if (from === undefined || to === undefined)
1419
- throw new Error(`rename projection cannot place ${older} against ${newer} in the current topology`)
1420
- // Whichever end is the rename owns the closure; the projector always puts one there.
1421
- return renameCommits.has(older)
1422
- ? (closure(older, children(), descendants)[to >> 3] & (1 << (to & 7))) !== 0
1423
- : (closure(newer, parents, ancestors)[from >> 3] & (1 << (from & 7))) !== 0
1424
- }
1425
- }
1426
-
1427
- type RenameProjectionEvent = { hash: string; to: string }
1428
- function canonicalPathProjector(
1429
- renamesByFrom: Map<string, RenameProjectionEvent[]>,
1430
- topology: TopologyProjection,
1431
- ): (path: string, event: string) => string[] {
1432
- const renameCommits = new Set<string>()
1433
- for (const renames of renamesByFrom.values()) for (const rename of renames) renameCommits.add(rename.hash)
1434
- const precedes = renameSideReachability(renameCommits, topology)
1435
- return (path, event) => {
1436
- // A path no rename ever left keeps its own identity at every event; there is no lineage to walk.
1437
- if (!renamesByFrom.has(path)) return [path]
1438
- const pending = [path], resolved = new Set<string>(), seen = new Set<string>()
1439
- while (pending.length) {
1440
- const candidate = pending.pop()!
1441
- if (seen.has(candidate)) { resolved.add(candidate); continue }
1442
- seen.add(candidate)
1443
- const unique = new Map<string, RenameProjectionEvent>()
1444
- for (const rename of renamesByFrom.get(candidate) ?? []) {
1445
- if (!precedes(rename.hash, event)) unique.set(`${rename.hash}\0${rename.to}`, rename)
1446
- }
1447
- const applicable = [...unique.values()]
1448
- if (!applicable.length) { resolved.add(candidate); continue }
1449
- // The earliest applicable rename starts this path's next lineage epoch. Later renames from the same
1450
- // path belong to a recreated path, not to an event that predates the first boundary. Incomparable
1451
- // rename branches are both frontier members, so their identities still fork at the merge. A merge can
1452
- // expose the same rename once per parent; equal-commit rows are peers, never ancestors of one another.
1453
- const frontier = applicable.filter((rename, index) =>
1454
- !applicable.some((other, otherIndex) => otherIndex !== index
1455
- && other.hash !== rename.hash && precedes(other.hash, rename.hash)))
1456
- if (frontier.some((rename) => !precedes(event, rename.hash))) resolved.add(candidate)
1457
- for (const rename of frontier) pending.push(rename.to)
1458
- }
1459
- return [...resolved]
1460
- }
1461
- }
1462
-
1463
- type SharedIndexInputs = {
1464
- allPaths: Set<string>
1465
- specPaths: Set<string>
1466
- topology: TopologyProjection
1467
- identityRecords: IdentityRawRecord[]
1468
- mergeIndex: MergeHistoryEvents
1469
- }
1470
-
1471
- type TopologyProjection = {
1472
- order: Map<string, number>
1473
- parents: Map<string, string[]>
1474
- reachable: Set<string>
1475
- }
1476
-
1477
- async function buildIndex(root: string, tip: string, transient: boolean, useCache = true, shared?: SharedIndexInputs): Promise<HistoryIndex> {
1478
- const versions = new Map<string, Version[]>()
1479
- const contentVersions = new Set<string>()
1480
- const versionPaths = new Map<string, string>()
1481
- const mergeVersions = new Set<string>()
1482
- const commitVersions = new Map<string, Version>()
1483
- const commitOrder = new Map<string, number>()
1484
- const rawVersions = new Map<string, Version[]>()
1485
- let currentPaths: Set<string>
1486
- let topology: TopologyProjection
1487
- if (shared) {
1488
- currentPaths = shared.specPaths
1489
- topology = shared.topology
1490
- } else {
1491
- const [tipPathsOut, topologyOut] = await Promise.all([
1492
- strictEventGit(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--name-only', tip, '--', '.spec']),
1493
- strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
1494
- ])
1495
- currentPaths = new Set(tipPathsOut.split('\0').filter((path) => path.startsWith('.spec/')))
1496
- topology = topologyProjection(topologyOut)
1497
- }
1498
- const renamesByFrom = new Map<string, { hash: string; to: string }[]>()
1499
- const topologyOrd = topology.order
1500
- const topologyParents = topology.parents
1501
- const topologyReachable = topology.reachable
1502
- const identityRecords = shared?.identityRecords ?? await identityRawEventStream(root, tip,
1503
- indexEventRequests(root, tip, topologyOrd, topologyReachable)['identity-raw'], !transient, useCache)
1504
- if (!identityRecords.length) return { versions, contentVersions, versionPaths, mergeVersions }
1505
- let commitPosition = 0
1506
- const rawContent = new Set<string>()
1507
- for (const record of identityRecords) {
1508
- const version: Version = { hash: record.h, date: record.d, reason: record.r, session: record.s }
1509
- commitVersions.set(record.h, version)
1510
- if (!commitOrder.has(record.h)) commitOrder.set(record.h, commitPosition++)
1511
- for (const [, from, to, oldOid, newOid] of record.c) {
1512
- if (!from.startsWith('.spec/') && !to.startsWith('.spec/')) continue
1513
- if (!rawVersions.has(to)) rawVersions.set(to, [])
1514
- rawVersions.get(to)!.push(version)
1515
- if (oldOid !== newOid) rawContent.add(`${to}\0${record.h}`)
1516
- if (from !== to) {
1517
- const renames = renamesByFrom.get(from) ?? []
1518
- renames.push({ hash: record.h, to })
1519
- renamesByFrom.set(from, renames)
1520
- }
1521
- }
1522
- }
1523
-
1524
- // Re-establish Git's date-order contract after records are parsed from a stream. An independent commit
1525
- // can appear before its child in a date-ordered result; repair that partial-order constraint with a stable
1526
- // topological pass. A comparator that
1527
- // materializes an ancestor Set/bitset for every sort comparison turns a 4k-commit history into an
1528
- // accidental quadratic walk; this queue visits each commit and parent edge once.
1529
- const originalOrder = new Map(commitOrder)
1530
- const childrenLeft = new Map<string, number>()
1531
- for (const hash of commitVersions.keys()) childrenLeft.set(hash, 0)
1532
- for (const hash of commitVersions.keys()) for (const parent of topologyParents.get(hash) ?? [])
1533
- if (childrenLeft.has(parent)) childrenLeft.set(parent, childrenLeft.get(parent)! + 1)
1534
- const heap: string[] = []
1535
- const before = (a: string, b: string) => (originalOrder.get(a) ?? Number.MAX_SAFE_INTEGER) - (originalOrder.get(b) ?? Number.MAX_SAFE_INTEGER)
1536
- const push = (hash: string) => {
1537
- let i = heap.push(hash) - 1
1538
- while (i > 0) {
1539
- const parent = (i - 1) >> 1
1540
- if (before(heap[parent], hash) <= 0) break
1541
- heap[i] = heap[parent]; i = parent
1542
- }
1543
- heap[i] = hash
1544
- }
1545
- const pop = (): string => {
1546
- const first = heap[0], last = heap.pop()!
1547
- if (heap.length) {
1548
- let i = 0
1549
- while (true) {
1550
- const left = i * 2 + 1
1551
- if (left >= heap.length) break
1552
- let child = left
1553
- const right = left + 1
1554
- if (right < heap.length && before(heap[right], heap[left]) < 0) child = right
1555
- if (before(heap[child], last) >= 0) break
1556
- heap[i] = heap[child]; i = child
1557
- }
1558
- heap[i] = last
1559
- }
1560
- return first
1561
- }
1562
- for (const [hash, left] of childrenLeft) if (left === 0) push(hash)
1563
- const ordered: string[] = []
1564
- while (heap.length) {
1565
- const hash = pop(); ordered.push(hash)
1566
- for (const parent of topologyParents.get(hash) ?? []) {
1567
- if (!childrenLeft.has(parent)) continue
1568
- const left = childrenLeft.get(parent)! - 1
1569
- childrenLeft.set(parent, left)
1570
- if (left === 0) push(parent)
1571
- }
1572
- }
1573
- // A shallow or malformed topology should fail closed rather than silently dropping a version row.
1574
- if (ordered.length !== commitVersions.size)
1575
- for (const hash of commitVersions.keys()) if (!ordered.includes(hash)) ordered.push(hash)
1576
- commitOrder.clear(); ordered.forEach((hash, i) => commitOrder.set(hash, i))
1577
-
1578
- const mergeIndex = shared?.mergeIndex
1579
- ?? await mergeHistoryEvents(root, tip, transient, topologyOrd, topologyReachable, useCache)
1580
- for (const rename of mergeIndex.renames) {
1581
- const renames = renamesByFrom.get(rename.from) ?? []
1582
- renames.push({ hash: rename.hash, to: rename.to })
1583
- renamesByFrom.set(rename.from, renames)
1584
- }
1585
- const canonical = canonicalPathProjector(renamesByFrom, topology)
1586
- for (const [path, pathRows] of rawVersions) {
1587
- for (const row of pathRows) for (const head of canonical(path, row.hash).filter((candidate) => currentPaths.has(candidate))) {
1588
- const rows = versions.get(head) ?? []
1589
- if (!rows.some((existing) => existing.hash === row.hash)) rows.push(row)
1590
- versions.set(head, rows)
1591
- const key = `${head}\0${row.hash}`
1592
- if (rawContent.has(`${path}\0${row.hash}`)) contentVersions.add(key)
1593
- if (!versionPaths.has(key)) versionPaths.set(key, path)
1594
- }
1595
- }
1596
- for (const [path, mergeEvents] of mergeIndex.resolutions) {
1597
- if (!isSpecMd(path)) continue
1598
- for (const { hash } of mergeEvents) for (const head of canonical(path, hash)) {
1599
- const rows = versions.get(head) ?? []
1600
- const version = commitVersions.get(hash)
1601
- if (!version || rows.some((row) => row.hash === hash)) continue
1602
- rows.push(version)
1603
- const key = `${head}\0${hash}`
1604
- mergeVersions.add(key)
1605
- if (!versionPaths.has(key)) versionPaths.set(key, path)
1606
- versions.set(head, rows)
1607
- }
1608
- }
1609
- for (const rows of versions.values()) {
1610
- rows.sort((a, b) => (commitOrder.get(a.hash) ?? Number.MAX_SAFE_INTEGER) - (commitOrder.get(b.hash) ?? Number.MAX_SAFE_INTEGER))
1611
- }
1612
- return { versions, contentVersions, versionPaths, mergeVersions }
1613
- }
1614
-
1615
- // Pure lookups over a prebuilt index. Blob identity decides whether a one-parent row is a content version;
1616
- // numstat remains display data and must not let attributes erase a version window.
1617
- export function rowsFor(idx: HistoryIndex, relPath: string): Version[] {
1618
- const rows = idx.versions.get(relPath) ?? []
1619
- return rows.filter((v) => idx.contentVersions.has(`${relPath}\0${v.hash}`) || idx.mergeVersions?.has(`${relPath}\0${v.hash}`))
1620
- }
1621
-
1622
- // per-commit numstat summed over a SET of paths in one `git log` walk. No `--follow` (it takes a single
1623
- // path), so no rename-tracking — same as the old `git show -- paths`; spec.md gets renames via the bulk index.
1624
- export async function pathsStats(root: string, paths: string[]): Promise<Map<string, DiffStat>> {
1625
- const m = new Map<string, DiffStat>()
1626
- if (!paths.length) return m
1627
- const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'log', '--format=%H', '--numstat', '--', ...paths])
1628
- if (!out) return m
1629
- let cur = ''
1630
- for (const line of out.split('\n')) {
1631
- const t = line.trim()
1632
- if (isGitObjectId(root, t)) { cur = t; continue }
1633
- const n = line.match(/^(\d+|-)\t(\d+|-)\t/)
1634
- if (n && cur) {
1635
- const s = m.get(cur) ?? { additions: 0, deletions: 0, files: 0 }
1636
- s.files++; s.additions += n[1] === '-' ? 0 : +n[1]; s.deletions += n[2] === '-' ? 0 : +n[2]
1637
- m.set(cur, s)
1638
- }
1639
- }
1640
- return m
1641
- }
1642
-
1643
- // History display stats are intentionally read only for the selected node: their text interpretation may
1644
- // depend on working-tree attributes, so they cannot live in the shared immutable event ledger. One diff-tree
1645
- // batch preserves every selected commit's historical path without turning a history page into N processes.
1646
- export async function historyStats(root: string, idx: HistoryIndex, relPath: string): Promise<Map<string, DiffStat>> {
1647
- const rows = rowsFor(idx, relPath)
1648
- if (!rows.length) return new Map()
1649
- const paths = new Map(rows.map((row) => [row.hash, idx.versionPaths.get(`${relPath}\0${row.hash}`) ?? relPath]))
1650
- const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'diff-tree', '--stdin', '--root', '-r', '--numstat', '-M', '-l0', `--format=${RS}%H`], `${rows.map((row) => row.hash).join('\n')}\n`)
1651
- const stats = new Map<string, DiffStat>()
1652
- for (const record of out.split(RS)) {
1653
- const lines = record.replace(/^\n/, '').split('\n')
1654
- const hash = lines.shift()?.trim() ?? ''
1655
- const path = paths.get(hash)
1656
- if (!path) continue
1657
- const stat = { additions: 0, deletions: 0, files: 0 }
1658
- for (const line of lines) {
1659
- const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/)
1660
- if (!match || parseStatPath(match[3]).to !== path) continue
1661
- stat.files++; stat.additions += match[1] === '-' ? 0 : Number(match[1]); stat.deletions += match[2] === '-' ? 0 : Number(match[2])
1662
- }
1663
- stats.set(hash, stat)
1664
- }
1665
- return stats
1666
- }
1667
-
1668
- // the patch a spec.md got in one commit (vs parent); resolve its path AT that commit (reparents move it)
1669
- // via the stable leaf dir `…/<id>/spec.md`, then `git show` that path. `-M` keeps a rename+edit's body. '' on error.
1670
- export async function fileDiffAt(root: string, relPath: string, hash: string): Promise<string> {
1671
- if (!hash || !relPath.endsWith('/spec.md')) return ''
1672
- const leaf = relPath.slice(relPath.lastIndexOf('/', relPath.length - '/spec.md'.length - 1) + 1) // `<id>/spec.md`
1673
- const names = await gitA(['-C', root, '-c', 'core.quotePath=false', 'show', '--name-only', '--format=', '-M', hash])
1674
- const at = names.split('\n').map((s) => s.trim()).find((p) => p.endsWith('/' + leaf) || p === leaf) ?? relPath
1675
- return gitA(['-C', root, '-c', 'core.quotePath=false', 'show', '-M', '--format=', hash, '--', at])
1676
- }
1677
-
1678
- // A `git log` over HEAD, enriched with parent edges so "newer than
1679
- // the spec" is answered by true DAG reachability, never by a log-position/date compare (a linear
1680
- // order can't encode a branching history's partial order and silently under-reports — back-dated
1681
- // branches, adoption). driftFor()/ancestorsOf() are then pure in-memory lookups. `acks`/`specNodes`
1682
- // carry the Spec-OK convention (see driftFor): acks[hash] = node ids declared still-valid via
1683
- // `Spec-OK:` trailers; specNodes[hash] = node ids whose spec.md it touched.
1684
- export type DriftIndex = {
1685
- tip?: string
1686
- ord: Map<string, number> // hash -> dense id from the walk: a bitset slot, NEVER an order to compare
1687
- parents: Map<string, string[]> // hash -> parent hashes (the DAG edges, from the same walk)
1688
- fileEvents: Map<string, DriftPathEvent[]>
1689
- lineageEvents: Map<string, DriftPathEvent[]> // immutable events keyed by terminal rename identity, present or deleted
1690
- lineageKeys: (path: string, revision: string) => string[]
1691
- resolutionEvents?: Map<string, DriftPathEvent[]> // merge-authored all-parent lines projected to this path
1692
- acks: Map<string, Set<string>> // tree-unchanged checkpoint hash -> node ids acknowledged via `Spec-OK:`
1693
- selfAcks?: Map<string, Set<string>> // content commit hash -> node ids acknowledged for that commit only
1694
- specNodes: Map<string, Set<string>> // commit hash -> node ids whose spec.md it touched (its versions)
1695
- anc: Map<string, Uint8Array> // memoized reachability bitsets, lazily built per queried sha
1696
- }
1697
- export type DriftPathEvent = {
1698
- commit: string
1699
- historicalPath: string
1700
- parents: { commit: string; historicalPath: string }[]
1701
- }
1702
-
1703
- // The ancestry substrate the reachability functions below actually read: a topology projection plus its
1704
- // memoized closures. `DriftIndex` is one instance of it (HEAD's history); `unionTopology` builds another for
1705
- // revisions HEAD cannot reach. Sharing the type is what keeps ONE reachability rule for both.
1706
- export type Reachability = {
1707
- ord: Map<string, number>
1708
- parents: Map<string, string[]>
1709
- anc: Map<string, Uint8Array>
1710
- }
1711
-
1712
- export type DiffLineRange = [number, number]
1713
- export type CombinedDiffOwnedChanges = { after: DiffLineRange[]; before: DiffLineRange[][]; parentPaths: string[] }
1714
-
1715
- function decodeGitCPath(value: string): string {
1716
- if (!value.startsWith('"')) return value
1717
- if (value.length < 2 || !value.endsWith('"')) throw new Error(`malformed Git C-quoted path '${value}'`)
1718
- const bytes: number[] = []
1719
- const plain = (part: string) => bytes.push(...Buffer.from(part, 'utf8'))
1720
- for (let index = 1; index < value.length - 1;) {
1721
- const point = value.codePointAt(index)!
1722
- const char = String.fromCodePoint(point)
1723
- index += char.length
1724
- if (char !== '\\') { plain(char); continue }
1725
- const escaped = value[index++]
1726
- if (escaped === undefined) throw new Error(`malformed Git C-quoted path '${value}'`)
1727
- const simple: Record<string, number> = { a: 7, b: 8, f: 12, n: 10, r: 13, t: 9, v: 11, '"': 34, '\\': 92 }
1728
- if (escaped in simple) { bytes.push(simple[escaped]); continue }
1729
- const octal = `${escaped}${value.slice(index, index + 2)}`
1730
- if (!/^[0-7]{3}$/.test(octal)) throw new Error(`malformed Git C-quoted path '${value}'`)
1731
- bytes.push(Number.parseInt(octal, 8)); index += 2
1732
- }
1733
- return Buffer.from(bytes).toString('utf8')
1734
- }
1735
-
1736
- // Dense combined diff prefixes have one column per parent. Ownership is a LINE fact, not a hunk fact:
1737
- // all `+` means the result authored a line absent from every parent; all `-` means it deleted a line present
1738
- // in every parent. Mixed columns inherit from at least one parent. Track every cursor through all displayed
1739
- // lines so adjacent mixed/owned rows never widen one another.
1740
- export function combinedDiffOwnedChanges(patch: string): Map<string, CombinedDiffOwnedChanges> {
1741
- const byPath = new Map<string, CombinedDiffOwnedChanges>()
1742
- let path: string | null = null
1743
- let parents = 0
1744
- let parentLines: number[] = []
1745
- let parentPaths: string[] = []
1746
- let resultLine = 0
1747
- let inHunk = false
1748
-
1749
- for (const line of patch.split('\n')) {
1750
- if (line.startsWith('diff --cc ')) {
1751
- path = decodeGitCPath(line.slice('diff --cc '.length))
1752
- parents = 0
1753
- parentPaths = []
1754
- inHunk = false
1755
- continue
1756
- }
1757
- if (!inHunk && path !== null && line.startsWith('--- ')) {
1758
- const raw = decodeGitCPath(line.slice(4))
1759
- parentPaths.push(raw === '/dev/null' ? path : raw.replace(/^a\//, ''))
1760
- continue
1761
- }
1762
- const header = line.match(/^(@{3,}) ((?:-\d+(?:,\d+)? )+)\+(\d+)(?:,\d+)? @+/)
1763
- if (header) {
1764
- parents = header[1].length - 1
1765
- parentLines = [...header[2].matchAll(/-(\d+)/g)].map((match) => Number(match[1]))
1766
- resultLine = Number(header[3])
1767
- inHunk = path !== null && parents >= 2 && parentLines.length === parents
1768
- continue
1769
- }
1770
- if (!inHunk || path === null || line.startsWith('\')) continue
1771
- const prefix = line.slice(0, parents)
1772
- if (prefix.length !== parents || !/^[ +\-]+$/.test(prefix)) {
1773
- inHunk = false
1774
- continue
1775
- }
1776
-
1777
- const authoredAfter = [...prefix].every((c) => c === '+')
1778
- const authoredBefore = [...prefix].every((c) => c === '-')
1779
- if ((authoredAfter || authoredBefore) && parentPaths.length !== parents)
1780
- throw new Error(`combined diff for '${path}' declared ${parents} parents but exposed ${parentPaths.length} parent paths`)
1781
- const changes = byPath.get(path) ?? {
1782
- after: [],
1783
- before: Array.from({ length: parents }, () => []),
1784
- parentPaths: [...parentPaths],
1785
- }
1786
- if (authoredAfter) changes.after.push([Math.max(1, resultLine), Math.max(1, resultLine)])
1787
- if (authoredBefore) {
1788
- for (let parent = 0; parent < parents; parent++) {
1789
- const point = Math.max(1, parentLines[parent])
1790
- changes.before[parent].push([point, point])
1791
- }
1792
- }
1793
- if (authoredAfter || authoredBefore) byPath.set(path, changes)
1794
- const resultExists = prefix.includes('+') || [...prefix].every((c) => c === ' ')
1795
- for (let parent = 0; parent < parents; parent++)
1796
- if (prefix[parent] === '-' || (prefix[parent] === ' ' && resultExists)) parentLines[parent]++
1797
- if (resultExists) resultLine++
1798
- }
1799
- return byPath
1800
- }
1801
- type MergeResolutionEvent = { hash: string; parentPaths: string[] }
1802
- type MergeRenameEvent = { hash: string; from: string; to: string }
1803
- type MergeHistoryEvents = { resolutions: Map<string, MergeResolutionEvent[]>; renames: MergeRenameEvent[] }
1804
- function parseMergeHistoryEvents(out: string): MergeHistoryEvents {
1805
- const resolutions = new Map<string, MergeResolutionEvent[]>()
1806
- const renames: MergeRenameEvent[] = []
1807
- for (const rec of out.split(RS)) {
1808
- const normalized = rec.replace(/^\n/, '')
1809
- const newline = normalized.indexOf('\n')
1810
- const hash = (newline < 0 ? normalized : normalized.slice(0, newline)).trim()
1811
- if (!hash) continue
1812
- const patch = newline < 0 ? '' : normalized.slice(newline + 1)
1813
- for (const line of patch.split('\n')) {
1814
- const raw = line.match(/^(:{2,})[0-7]{6}(?: [0-7]{6})+ (?:[0-9a-f]+ )+[A-Z]+\t/)
1815
- if (!raw) continue
1816
- const parentCount = raw[1].length
1817
- const fields = line.split('\t')
1818
- const status = fields[0].trim().split(/\s+/).at(-1) ?? ''
1819
- const paths = fields.slice(1).map(decodeGitCPath)
1820
- if (status.length !== parentCount || paths.length !== parentCount + 1)
1821
- throw new Error(`combined raw diff for ${hash} exposed ${status.length} statuses and ${paths.length} paths for ${parentCount} parents`)
1822
- const resultPath = paths[parentCount]
1823
- for (let parent = 0; parent < parentCount; parent++) {
1824
- if (status[parent] !== 'R' || paths[parent] === resultPath) continue
1825
- renames.push({ hash, from: paths[parent], to: resultPath })
1826
- }
1827
- }
1828
- for (const [path, changes] of combinedDiffOwnedChanges(patch)) {
1829
- const events = resolutions.get(path) ?? []
1830
- events.push({ hash, parentPaths: changes.parentPaths })
1831
- resolutions.set(path, events)
1832
- }
1833
- }
1834
- return { resolutions, renames }
1835
- }
1836
-
1837
- async function mergeHistoryEvents(
1838
- root: string,
1839
- tip: string,
1840
- transient: boolean,
1841
- order: Map<string, number>,
1842
- reachable: Set<string>,
1843
- useCache = true,
1844
- ): Promise<MergeHistoryEvents> {
1845
- if (!useCache) return parseMergeHistoryEvents(await strictEventGit(['-C', root, '-c', 'core.quotePath=false',
1846
- 'log', '--merges', '--raw', '--patch', '--cc', '--combined-all-paths', '--unified=0', '--no-color', '--no-ext-diff', '-M', `--format=${RS}%H`, tip]))
1847
- return parseMergeHistoryEvents(await textEventStream(root, tip,
1848
- indexEventRequests(root, tip, order, reachable).merge, !transient, useCache))
1849
- }
1850
-
1851
- async function buildDriftIndex(root: string, tip: string, transient: boolean, useCache = true, shared?: SharedIndexInputs): Promise<DriftIndex> {
1852
- const ord = new Map<string, number>(), parents = new Map<string, string[]>()
1853
- const fileEvents = new Map<string, DriftPathEvent[]>()
1854
- const lineageEvents = new Map<string, DriftPathEvent[]>()
1855
- const acks = new Map<string, Set<string>>(), selfAcks = new Map<string, Set<string>>(), specNodes = new Map<string, Set<string>>()
1856
- const ackCandidates = new Map<string, Set<string>>(), ackCheckpoints = new Map<string, boolean>()
1857
- const idx: DriftIndex = { tip, ord, parents, fileEvents, lineageEvents, lineageKeys: (path) => [path], resolutionEvents: new Map(), acks, selfAcks, specNodes, anc: new Map() }
1858
- let currentPaths: Set<string>
1859
- let topology: TopologyProjection
1860
- if (shared) {
1861
- currentPaths = shared.allPaths
1862
- topology = shared.topology
1863
- } else {
1864
- const [tipPathsOut, topologyOut] = await Promise.all([
1865
- strictEventGit(['-C', root, 'ls-tree', '-r', '-z', '--name-only', tip]),
1866
- strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
1867
- ])
1868
- currentPaths = new Set(tipPathsOut.split('\0').filter(Boolean))
1869
- topology = topologyProjection(topologyOut)
1870
- }
1871
- const topologyOrder = topology.order
1872
- const topologyParents = topology.parents
1873
- const topologyReachable = topology.reachable
1874
- for (const [hash, position] of topologyOrder) {
1875
- ord.set(hash, position)
1876
- parents.set(hash, topologyParents.get(hash) ?? [])
1877
- }
1878
- // Raw identity records path pairs and immutable object ids once; projection owns forks and reuse.
1879
- const records = shared?.identityRecords ?? await identityRawEventStream(root, tip,
1880
- indexEventRequests(root, tip, topologyOrder, topologyReachable)['identity-raw'], !transient, useCache)
1881
- if (!records.length) return idx
1882
- const rawFileEvents = new Map<string, DriftPathEvent[]>()
1883
- const renamesByFrom = new Map<string, RenameProjectionEvent[]>()
1884
- for (const record of records) {
1885
- const { h: hash, a: ackStr, c: changes } = record
1886
- const ackSet = new Set(ackStr.split(',').map((s) => s.trim()).filter(Boolean))
1887
- if (ackSet.size) {
1888
- ackCandidates.set(hash, ackSet)
1889
- ackCheckpoints.set(hash, changes.length === 0)
1890
- }
1891
- const merge = (parents.get(hash) ?? []).length > 1
1892
- for (const [, from, to] of changes) {
1893
- if (!merge) {
1894
- const events = rawFileEvents.get(to) ?? []
1895
- const event: DriftPathEvent = {
1896
- commit: hash,
1897
- historicalPath: to,
1898
- parents: (parents.get(hash) ?? []).slice(0, 1).map((commit) => ({ commit, historicalPath: from })),
1899
- }
1900
- events.push(event)
1901
- rawFileEvents.set(to, events)
1902
- }
1903
- if (from !== to) {
1904
- const renames = renamesByFrom.get(from) ?? []
1905
- renames.push({ hash, to })
1906
- renamesByFrom.set(from, renames)
1907
- }
1908
- }
1909
- }
1910
- const mergeIndex = shared?.mergeIndex
1911
- ?? await mergeHistoryEvents(root, tip, transient, topologyOrder, topologyReachable, useCache)
1912
- for (const rename of mergeIndex.renames) {
1913
- const renames = renamesByFrom.get(rename.from) ?? []
1914
- renames.push({ hash: rename.hash, to: rename.to })
1915
- renamesByFrom.set(rename.from, renames)
1916
- }
1917
- const canonical = canonicalPathProjector(renamesByFrom, topology)
1918
- idx.lineageKeys = canonical
1919
- // One projection per event serves both the lineage index and the current-path index; asking the
1920
- // projector the same question twice per event only pays for it twice.
1921
- const addEvent = (keys: string[], event: DriftPathEvent, target: Map<string, DriftPathEvent[]>) => {
1922
- for (const key of keys) {
1923
- const events = target.get(key) ?? []
1924
- if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath)) events.push(event)
1925
- target.set(key, events)
1926
- }
1927
- }
1928
- for (const [path, rawEvents] of rawFileEvents) for (const event of rawEvents) {
1929
- const keys = canonical(path, event.commit)
1930
- addEvent(keys, event, lineageEvents)
1931
- for (const head of keys.filter((candidate) => currentPaths.has(candidate))) {
1932
- const events = fileEvents.get(head) ?? []
1933
- if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath)) events.push(event)
1934
- fileEvents.set(head, events)
1935
- if (isSpecMd(head)) {
1936
- const nodes = specNodes.get(event.commit) ?? new Set<string>()
1937
- nodes.add(nodeIdOf(head))
1938
- specNodes.set(event.commit, nodes)
1939
- }
1940
- }
1941
- }
1942
- for (const [hash, nodes] of ackCandidates) {
1943
- const parentList = parents.get(hash) ?? []
1944
- const firstParent = parentList[0]
1945
- // A non-merge commit with no raw identity entries has the same tree as its sole parent.
1946
- const checkpoint = parentList.length === 1 && !!firstParent && ackCheckpoints.get(hash) === true
1947
- ;(checkpoint ? acks : selfAcks).set(hash, nodes)
1948
- }
1949
- for (const [path, mergeEvents] of mergeIndex.resolutions) for (const mergeEvent of mergeEvents) {
1950
- const mergeParents = topologyParents.get(mergeEvent.hash) ?? []
1951
- if (mergeParents.length !== mergeEvent.parentPaths.length)
1952
- throw new Error(`merge ${mergeEvent.hash} has ${mergeParents.length} topology parents but ${mergeEvent.parentPaths.length} combined parent paths`)
1953
- const event: DriftPathEvent = {
1954
- commit: mergeEvent.hash,
1955
- historicalPath: path,
1956
- parents: mergeParents.map((commit, index) => ({ commit, historicalPath: mergeEvent.parentPaths[index] })),
1957
- }
1958
- const keys = canonical(path, mergeEvent.hash)
1959
- addEvent(keys, event, lineageEvents)
1960
- for (const head of keys.filter((candidate) => currentPaths.has(candidate))) {
1961
- const events = idx.resolutionEvents!.get(head) ?? []
1962
- if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath)) events.push(event)
1963
- idx.resolutionEvents!.set(head, events)
1964
- if (isSpecMd(head)) {
1965
- const nodes = specNodes.get(mergeEvent.hash) ?? new Set<string>()
1966
- nodes.add(nodeIdOf(head))
1967
- specNodes.set(mergeEvent.hash, nodes)
1968
- }
1969
- }
1970
- }
1971
- return idx
1972
- }
1973
- export function driftIndex(root: string, tip = 'HEAD'): Promise<DriftIndex> {
1974
- if (tip !== 'HEAD') {
1975
- const resolved = git(['-C', root, 'rev-parse', `${tip}^{commit}`]).trim()
1976
- if (pendingOnlyIssues(root, resolved)) {
1977
- const parent = pendingParent(root, resolved)
1978
- if (parent) {
1979
- const head = headOrEmpty(root)
1980
- if (head === parent) return driftIndex(root)
1981
- return buildDriftIndex(root, parent, true, true)
1982
- }
1983
- }
1984
- return buildDriftIndex(root, resolved, true, true)
1985
- }
1986
- const head = headOrEmpty(root) // filesystem HEAD, no subprocess — see historyIndex
1987
- if (!head) return buildDriftIndex(root, 'HEAD', false, true)
1988
- const cacheKey = indexCacheKey(root, head)
1989
- touchRoot(driftRoots, driftIdxCache, root, cacheKey)
1990
- const hit = driftIdxCache.get(cacheKey)
1991
- if (hit) return hit
1992
- const p = buildDriftIndex(root, head.startsWith('unborn:') ? 'HEAD' : head, false, true)
1993
- p.catch(() => { dropFailed(driftIdxCache, cacheKey, p) })
1994
- driftIdxCache.set(cacheKey, p)
1995
- return p
1996
- }
1997
-
1998
- function topologyProjection(out: string): TopologyProjection {
1999
- const order = new Map<string, number>(), parents = new Map<string, string[]>(), reachable = new Set<string>()
2000
- let position = 0
2001
- for (const line of out.trim().split('\n')) {
2002
- const [hash, ...parentList] = line.split(' ')
2003
- if (!hash) continue
2004
- order.set(hash, position++)
2005
- parents.set(hash, parentList)
2006
- reachable.add(hash)
2007
- }
2008
- return { order, parents, reachable }
2009
- }
2010
-
2011
- function textStream(value: EventStreamOutput | undefined, kind: EventStreamKind): string {
2012
- if (typeof value !== 'string') throw new Error(`history event stream '${kind}' did not render text`)
2013
- return value
2014
- }
2015
- function identityRawStream(value: EventStreamOutput | undefined, kind: EventStreamKind): IdentityRawRecord[] {
2016
- if (!Array.isArray(value) || value.some((record) => !('a' in record))) throw new Error(`history event stream '${kind}' did not render identity records`)
2017
- return value as IdentityRawRecord[]
2018
- }
2019
- async function buildIndexPair(root: string, tip: string, transient: boolean, useCache = true): Promise<[HistoryIndex, DriftIndex]> {
2020
- const [allPathsOut, topologyOut] = await Promise.all([
2021
- strictEventGit(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--name-only', tip]),
2022
- strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
2023
- ])
2024
- const topology = topologyProjection(topologyOut)
2025
- const allPaths = new Set(allPathsOut.split('\0').filter(Boolean))
2026
- const specPaths = new Set([...allPaths].filter((path) => path.startsWith('.spec/')))
2027
- const requests = indexEventRequests(root, tip, topology.order, topology.reachable)
2028
- const streams = await deriveEventStreams(root, tip, EVENT_STREAM_KINDS.map((kind) => requests[kind]), !transient, useCache)
2029
- const shared: SharedIndexInputs = {
2030
- allPaths,
2031
- specPaths,
2032
- topology,
2033
- identityRecords: identityRawStream(streams.get('identity-raw'), 'identity-raw'),
2034
- mergeIndex: parseMergeHistoryEvents(textStream(streams.get('merge'), 'merge')),
2035
- }
2036
- streams.clear()
2037
- return Promise.all([
2038
- buildIndex(root, tip, transient, useCache, shared),
2039
- buildDriftIndex(root, tip, transient, useCache, shared),
2040
- ])
2041
- }
2042
-
2043
- // Standing correctness oracle: same projection, but every immutable stream comes from Git root history.
2044
- export function sourceIndexesFull(root: string, tip = 'HEAD'): Promise<[HistoryIndex, DriftIndex]> {
2045
- const head = tip === 'HEAD' ? headOrEmpty(root) : ''
2046
- const resolved = tip === 'HEAD'
2047
- ? (!head || head.startsWith('unborn:') ? 'HEAD' : head)
2048
- : git(['-C', root, 'rev-parse', `${tip}^{commit}`]).trim()
2049
- return buildIndexPair(root, resolved, true, false)
2050
- }
2051
-
2052
- // History and drift are one product projection. Building them together is what gives one lint one ledger
2053
- // transaction; the public single-index functions remain for consumers that genuinely need only one side.
2054
- export function sourceIndexes(root: string, tip = 'HEAD'): Promise<[HistoryIndex, DriftIndex]> {
2055
- if (tip !== 'HEAD') {
2056
- const resolved = git(['-C', root, 'rev-parse', `${tip}^{commit}`]).trim()
2057
- if (pendingOnlyIssues(root, resolved)) {
2058
- const parent = pendingParent(root, resolved)
2059
- if (parent) return headOrEmpty(root) === parent
2060
- ? sourceIndexes(root)
2061
- : buildIndexPair(root, parent, true, true)
2062
- }
2063
- return buildIndexPair(root, resolved, true, true)
2064
- }
2065
- const head = headOrEmpty(root)
2066
- if (!head) return buildIndexPair(root, 'HEAD', false, true)
2067
- const cacheKey = indexCacheKey(root, head)
2068
- touchRoot(indexRoots, indexCache, root, cacheKey)
2069
- touchRoot(driftRoots, driftIdxCache, root, cacheKey)
2070
- const historyHit = indexCache.get(cacheKey), driftHit = driftIdxCache.get(cacheKey)
2071
- if (historyHit && driftHit) return Promise.all([historyHit, driftHit])
2072
-
2073
- const pair = buildIndexPair(root, head.startsWith('unborn:') ? 'HEAD' : head, false, true)
2074
- const historyPromise = pair.then(([history]) => history)
2075
- const driftPromise = pair.then(([, drift]) => drift)
2076
- historyPromise.catch(() => { dropFailed(indexCache, cacheKey, historyPromise) })
2077
- driftPromise.catch(() => { dropFailed(driftIdxCache, cacheKey, driftPromise) })
2078
- indexCache.set(cacheKey, historyPromise)
2079
- driftIdxCache.set(cacheKey, driftPromise)
2080
- return pair
2081
- }
2082
-
2083
- export function historyCacheStats(): { historyHeads: number; driftHeads: number; historyRoots: number; driftRoots: number } {
2084
- return { historyHeads: indexCache.size, driftHeads: driftIdxCache.size, historyRoots: indexRoots.size, driftRoots: driftRoots.size }
2085
- }
2086
- // Tests deliberately clear process-local promises and path identity to exercise both cold and read-back paths.
2087
- // Production callers retain the bounded index caches; this is not a correctness escape hatch.
2088
- export function resetHistoryCachesForTests(): void {
2089
- indexCache.clear(); driftIdxCache.clear(); indexRoots.clear(); driftRoots.clear()
2090
- eventPathMemo.clear(); gitObjectFormatMemo.clear()
2091
- }
2092
- export function historyEventCachePathForTests(root: string): string { return eventCacheLocation(root).path }
2093
- // @@@ one ordinary ancestry memo, with a batch entrance - every closure below has exactly the same dense
2094
- // bitset shape in idx.anc. A caller that already knows several bases can prime them in one child-to-parent
2095
- // topology pass; the short-lived frontier is released before returning. Unknown or ad-hoc revisions retain
2096
- // this direct parent DFS, so neither path introduces a second reachability representation or persistent fact.
2097
- // undefined when `sha` is not reachable from HEAD (rebased away, an unmerged branch, or never on any
2098
- // ref) — callers apply their own conservative rule to that "can't prove" case.
2099
- export function ancestorsOf(idx: Reachability, sha: string): Uint8Array | undefined {
2100
- const hit = idx.anc.get(sha)
2101
- if (hit) return hit
2102
- const start = idx.ord.get(sha)
2103
- if (start === undefined) return undefined
2104
- const bits = new Uint8Array((idx.ord.size + 7) >> 3)
2105
- bits[start >> 3] |= 1 << (start & 7)
2106
- const stack = [sha]
2107
- while (stack.length) {
2108
- for (const p of idx.parents.get(stack.pop()!) ?? []) {
2109
- const o = idx.ord.get(p)
2110
- if (o === undefined) continue // shallow-clone boundary: an unwalked parent ends the chain
2111
- const m = 1 << (o & 7)
2112
- if (bits[o >> 3] & m) continue
2113
- bits[o >> 3] |= m
2114
- stack.push(p)
2115
- }
2116
- }
2117
- idx.anc.set(sha, bits)
2118
- return bits
2119
- }
2120
-
2121
- // Fill the existing ancestry memo for a known roster in one child-before-parent topology pass. At each
2122
- // commit the transient row names requested descendants; emitting those bits into the ordinary closures makes
2123
- // the resulting bytes identical to calling ancestorsOf() independently for every requested SHA.
2124
- export function primeAncestorClosures(idx: Reachability, shas: Iterable<string>): void {
2125
- const endpoints = [...new Set(shas)].filter((sha) => !idx.anc.has(sha) && idx.ord.has(sha))
2126
- if (!endpoints.length) return
2127
- const count = idx.ord.size
2128
- const width = (endpoints.length + 7) >> 3
2129
- const closureWidth = (count + 7) >> 3
2130
- const endpointAt = new Map(endpoints.map((sha, position) => [sha, position]))
2131
- const hashAt = new Array<string>(count)
2132
- for (const [hash, position] of idx.ord) hashAt[position] = hash
2133
-
2134
- // Every row waits until all of its children have contributed. Parents outside the walked topology are
2135
- // deliberately absent: they are the same shallow boundary ancestorsOf() stops at.
2136
- const childrenLeft = new Int32Array(count)
2137
- for (const parents of idx.parents.values()) for (const parent of parents) {
2138
- const position = idx.ord.get(parent)
2139
- if (position !== undefined) childrenLeft[position]++
2140
- }
2141
- const ready: number[] = []
2142
- for (let position = 0; position < count; position++) if (childrenLeft[position] === 0) ready.push(position)
2143
-
2144
- const closures = endpoints.map(() => new Uint8Array(closureWidth))
2145
- const rows = new Map<number, Uint8Array>(), pool: Uint8Array[] = []
2146
- const rowFor = (position: number): Uint8Array => {
2147
- const existing = rows.get(position)
2148
- if (existing) return existing
2149
- const row = pool.pop() ?? new Uint8Array(width)
2150
- rows.set(position, row)
2151
- return row
2152
- }
2153
- let visited = 0
2154
- while (ready.length) {
2155
- const position = ready.pop()!
2156
- visited++
2157
- const row = rowFor(position)
2158
- rows.delete(position)
2159
- const endpoint = endpointAt.get(hashAt[position])
2160
- if (endpoint !== undefined) row[endpoint >> 3] |= 1 << (endpoint & 7)
2161
- for (let byte = 0; byte < width; byte++) {
2162
- let pending = row[byte]
2163
- while (pending) {
2164
- const bit = 31 - Math.clz32(pending & -pending)
2165
- closures[(byte << 3) + bit][position >> 3] |= 1 << (position & 7)
2166
- pending &= pending - 1
2167
- }
2168
- }
2169
- for (const parent of idx.parents.get(hashAt[position]) ?? []) {
2170
- const parentPosition = idx.ord.get(parent)
2171
- if (parentPosition === undefined) continue
2172
- const target = rowFor(parentPosition)
2173
- for (let byte = 0; byte < width; byte++) target[byte] |= row[byte]
2174
- if (--childrenLeft[parentPosition] === 0) ready.push(parentPosition)
2175
- }
2176
- row.fill(0)
2177
- pool.push(row)
2178
- }
2179
- if (visited !== count)
2180
- throw new Error(`cannot prime ancestry closures: topology yielded ${visited} of ${count} reachable commits`)
2181
- for (let position = 0; position < endpoints.length; position++) idx.anc.set(endpoints[position], closures[position])
2182
- }
2183
- export function inAncestors(idx: Reachability, bits: Uint8Array, sha: string): boolean {
2184
- const o = idx.ord.get(sha)
2185
- return o !== undefined && (bits[o >> 3] & (1 << (o & 7))) !== 0
2186
- }
2187
-
2188
- // @@@ one walk for a whole roster of revisions, HEAD-reachable or not - the index above projects HEAD's
2189
- // history, so a revision HEAD cannot reach (an unmerged branch, a rebased-away measurement anchor) has NO
2190
- // ancestry there and `ancestorsOf` correctly answers undefined. A caller that needs those revisions' own
2191
- // past gets it from the SAME projection shape, built by one `rev-list --parents` walk over the union of the
2192
- // roster's histories, with the roster on stdin so argv never grows with it. Deliberately a SEPARATE
2193
- // structure, never a graft into the shared index: making off-history tips reachable there would silently
2194
- // switch every ancestry-vs-content decision that keys on `undefined`.
2195
- export async function unionTopology(root: string, revisions: readonly string[]): Promise<Reachability> {
2196
- const roster = [...new Set(revisions)].filter(Boolean)
2197
- const reach: Reachability = { ord: new Map(), parents: new Map(), anc: new Map() }
2198
- if (!roster.length) return reach
2199
- const out = await gitRequiredA(['-C', root, 'rev-list', '--parents', '--stdin'], 'cannot walk revision topology', {
2200
- input: roster.map((revision) => `${revision}\n`).join(''),
2201
- // the roster is already exact object ids; do not reinterpret them if refs/replace moves mid-read.
2202
- extraEnv: { GIT_NO_REPLACE_OBJECTS: '1' },
2203
- })
2204
- const projection = topologyProjection(out)
2205
- return { ord: projection.order, parents: projection.parents, anc: new Map() }
2206
- }
2207
-
2208
- // @@@ reachability is membership, not a closure - `ancestorsOf` returns undefined for EXACTLY the shas
2209
- // absent from `idx.ord` (both writers of `idx.anc` gate on ord: the single-sha path after its ord lookup
2210
- // succeeds, the batch after an explicit `ord.has` filter), so asking it here answered a hash-table
2211
- // question by walking the whole parent DAG and allocating an ord.size-wide bitset per distinct sha —
2212
- // per eval reading, on every board build.
2213
- export function commitReachable(idx: DriftIndex, sha: string): boolean {
2214
- return idx.ord.has(sha)
2215
- }
2216
-
2217
- // the valid Spec-OK coverage for a node's version commit: `sinceHash` is the node's OWN latest version,
2218
- // so the node(s) it's a version of (specNodes[sinceHash]) name the node being measured; an ack counts
2219
- // only if its `Spec-OK:` set names one of those — `Spec-OK: A` quiets A's drift, never B's. An ack that
2220
- // is itself an ancestor of the version can't speak for it (a re-version invalidates older acks); a valid
2221
- // ack quiets exactly the commits reachable from it. Shared by driftFor (the count) and the anchor
2222
- // engine's windowCommits (the commit set) so both read ONE ack rule.
2223
- function targetNodes(idx: DriftIndex, sinceHash: string, nodeId?: string): Set<string> | undefined {
2224
- return nodeId ? new Set([nodeId]) : idx.specNodes.get(sinceHash)
2225
- }
2226
-
2227
- export function ackCoverFor(idx: DriftIndex, sinceHash: string, nodeId?: string): Uint8Array[] {
2228
- const base = ancestorsOf(idx, sinceHash)
2229
- if (!base) return []
2230
- const targets = targetNodes(idx, sinceHash, nodeId)
2231
- const cover: Uint8Array[] = []
2232
- if (targets) {
2233
- for (const [h, ackSet] of idx.acks) {
2234
- if (inAncestors(idx, base, h)) continue
2235
- if (![...targets].some((t) => ackSet.has(t))) continue
2236
- const a = ancestorsOf(idx, h)
2237
- if (a) cover.push(a)
2238
- }
2239
- }
2240
- return cover
2241
- }
2242
-
2243
- export function selfAckCovers(idx: DriftIndex, sinceHash: string, hash: string, nodeId?: string): boolean {
2244
- const targets = targetNodes(idx, sinceHash, nodeId)
2245
- const declared = idx.selfAcks?.get(hash)
2246
- return !!targets && !!declared && [...targets].some((node) => declared.has(node))
2247
- }
2248
-
2249
- export function pathEvents(idx: DriftIndex, path: string): DriftPathEvent[] {
2250
- return [...(idx.fileEvents.get(path) ?? []), ...(idx.resolutionEvents?.get(path) ?? [])]
2251
- }
2252
-
2253
- // Exact base..tip window for consumers whose subject is not a spec version. The path is an identity at
2254
- // `identityRevision`; projecting it and every immutable event to the same terminal lineage keys preserves
2255
- // rename chains, parallel forks, path reuse, and lineages deleted before the tip.
2256
- export function pathRangeEvents(idx: DriftIndex, sinceHash: string, path: string, identityRevision = idx.tip ?? sinceHash): DriftPathEvent[] | null {
2257
- const base = ancestorsOf(idx, sinceHash)
2258
- if (!base) return null
2259
- const seen = new Set<string>()
2260
- const events = idx.lineageKeys(path, identityRevision).flatMap((key) => idx.lineageEvents.get(key) ?? [])
2261
- return events.filter((event) => {
2262
- const key = `${event.commit}\0${event.historicalPath}\0${event.parents.map((parent) => `${parent.commit}:${parent.historicalPath}`).join('\x1e')}`
2263
- if (seen.has(key) || inAncestors(idx, base, event.commit)) return false
2264
- seen.add(key)
2265
- return true
2266
- })
2267
- }
2268
-
2269
- // @@@ eventsSince - THE meaning of "this path changed since <sha>", in one place. A commit touching `path`
2270
- // lies in `sha..HEAD` exactly when it is NOT an ancestor of `sha` — true DAG reachability, wherever a
2271
- // date-ordered log happens to place it. `null` is the honest third answer: the anchor commit is not reachable
2272
- // (folded, rebased, cherry-picked away), so ancestry cannot testify at all and the caller must decide what to
2273
- // do about that — the spec layer reports no window, the eval layer falls back to comparing content.
2274
- // Callers add their OWN layer's decoration on top (ack cover is spec-only; the content probe is eval-only);
2275
- // what none of them may do is restate the reachability rule, which is how it came to exist four times.
2276
- export function eventsSince(idx: DriftIndex, sinceHash: string, path: string): DriftPathEvent[] | null {
2277
- const base = ancestorsOf(idx, sinceHash)
2278
- if (!base) return null
2279
- return pathEvents(idx, path).filter((event) => !inAncestors(idx, base, event.commit))
2280
- }
2281
-
2282
- export function driftPathWindow(idx: DriftIndex, sinceHash: string, path: string, nodeId?: string): DriftPathEvent[] | null {
2283
- const events = eventsSince(idx, sinceHash, path)
2284
- if (!events) return null
2285
- const cover = ackCoverFor(idx, sinceHash, nodeId)
2286
- return events.filter((event) => !cover.some((a) => inAncestors(idx, a, event.commit))
2287
- && !selfAckCovers(idx, sinceHash, event.commit, nodeId))
2288
- }
2289
-
2290
- // pure lookup, no git: a commit to `path` is drift iff it is NOT an ancestor of `sinceHash` — it lies
2291
- // in `sinceHash..HEAD` by true DAG reachability, wherever a date-ordered log happens to place it.
2292
- // An off-history `sinceHash` → 0: no basis on HEAD to measure from.
2293
- export function driftFor(idx: DriftIndex, sinceHash: string, path: string, nodeId?: string): number {
2294
- if (!sinceHash) return 0
2295
- return new Set(driftPathWindow(idx, sinceHash, path, nodeId)?.map((event) => event.commit) ?? []).size
2296
- }
2297
-
2298
- // the paths git is about to commit (index vs HEAD), scoping the pre-commit drift gate to this commit's files.
2299
- export function stagedFiles(root: string): string[] {
2300
- try {
2301
- return git(['-C', root, '-c', 'core.quotePath=false', 'diff', '--cached', '--name-only'])
2302
- .split('\n').map((s) => s.trim()).filter(Boolean)
2303
- } catch { return [] }
2304
- }
2305
-
2306
- // ---- pending worktree changes (the board's runtime overlay) ----
2307
-
2308
- // one pending change a worktree makes to a spec node vs main; committed = on the branch, dirty = uncommitted edits.
2309
- export type NodeOp = {
2310
- nodeId: string
2311
- op: 'added' | 'edited' | 'deleted' | 'moved'
2312
- path: string // the node's spec.md path (new path for moved/added, old for deleted)
2313
- fromPath?: string; toPath?: string // set for 'moved' (a reparent renames the spec.md path)
2314
- committed: boolean; dirty: boolean
2315
- }
2316
-
2317
- // node id = the directory holding the spec.md (basename of its parent dir). git always emits
2318
- // forward-slash paths, so split rather than node:path (which is backslash-y on Windows).
2319
- const nodeIdOf = (p: string): string => { const s = p.split('/'); return s[s.length - 2] ?? p }
2320
- const isSpecMd = (p: string): boolean => p.endsWith('/spec.md')
2321
-
2322
- // `git ... --name-status -M` rows: `A\tpath`, `M\tpath`, `D\tpath`, `R100\told\tnew`. Recover the
2323
- // status letter plus from/to (to === from for non-renames) so callers map letter -> op uniformly.
2324
- function parseNameStatus(out: string): { code: string; from: string; to: string }[] {
2325
- const rows: { code: string; from: string; to: string }[] = []
2326
- for (const line of out.split('\n')) {
2327
- if (!line) continue
2328
- const parts = line.split('\t')
2329
- const code = parts[0][0]
2330
- if ((code === 'R' || code === 'C') && parts.length >= 3) rows.push({ code, from: parts[1], to: parts[2] })
2331
- else rows.push({ code, from: parts[1], to: parts[1] })
2332
- }
2333
- return rows
2334
- }
2335
-
2336
- export type ReviewDiffFile = { path: string; oldPath?: string; status: string; additions: number; deletions: number }
2337
- const DIFF_STATUS: Record<string, string> = { A: 'added', M: 'modified', D: 'deleted', R: 'renamed', C: 'copied', T: 'type-changed' }
2338
- function parseStatPath(token: string): { from: string; to: string } {
2339
- const b = token.indexOf('{'), arrow = token.indexOf(' => ', b), close = token.indexOf('}', arrow)
2340
- if (b >= 0 && arrow > b && close > arrow) {
2341
- const pre = token.slice(0, b), post = token.slice(close + 1)
2342
- return { from: `${pre}${token.slice(b + 1, arrow)}${post}`.replace(/\/\//g, '/'), to: `${pre}${token.slice(arrow + 4, close)}${post}`.replace(/\/\//g, '/') }
2343
- }
2344
- const arrowAt = token.indexOf(' => ')
2345
- return arrowAt >= 0 ? { from: token.slice(0, arrowAt), to: token.slice(arrowAt + 4) } : { from: token, to: token }
2346
- }
2347
- export async function mergeBaseDiff(wtPath: string, mainRef = 'main', headRef = 'HEAD'): Promise<ReviewDiffFile[]> {
2348
- const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
2349
- const base = (await run(['merge-base', mainRef, headRef])).trim()
2350
- if (!base) return []
2351
- const [numstatOut, statusOut] = await Promise.all([
2352
- run(['diff', '--numstat', '-M', `${base}..${headRef}`]),
2353
- run(['diff', '--name-status', '-M', `${base}..${headRef}`]),
2354
- ])
2355
- const status = new Map<string, { status: string; from: string }>()
2356
- for (const r of parseNameStatus(statusOut)) status.set(r.to, { status: DIFF_STATUS[r.code] ?? r.code, from: r.from })
2357
- const files: ReviewDiffFile[] = []
2358
- for (const line of numstatOut.split('\n')) {
2359
- const m = line.match(/^(-|\d+)\t(-|\d+)\t(.+)$/)
2360
- if (!m) continue
2361
- const { from, to } = parseStatPath(m[3])
2362
- const detail = status.get(to)
2363
- files.push({
2364
- path: to,
2365
- ...(from !== to ? { oldPath: detail?.from ?? from } : {}),
2366
- status: detail?.status ?? 'modified',
2367
- additions: m[1] === '-' ? 0 : +m[1],
2368
- deletions: m[2] === '-' ? 0 : +m[2],
2369
- })
2370
- }
2371
- return files
2372
- }
2373
-
2374
- export function mergeConflicts(wtPath: string, mainRef = 'main', headRef = 'HEAD'): Promise<boolean> {
2375
- return new Promise((resolve) => {
2376
- const env = { ...process.env }
2377
- delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
2378
- execFile(gitBinary(env), ['-C', wtPath, 'merge-tree', '--write-tree', '--no-messages', mainRef, headRef],
2379
- { encoding: 'utf8', env, maxBuffer: 1 << 24 },
2380
- // execFile sets err.code to the numeric EXIT code on a non-zero exit (1 = conflicts), or a string
2381
- // errno (e.g. 'ENOENT') if git can't be spawned — only the exit-1 case is a real conflict verdict.
2382
- (err) => resolve(!!err && err.code === 1))
2383
- })
2384
- }
2385
-
2386
- // this worktree's spec ops vs main ([[worktree-linker]]): an op must BOTH differ from main's current tip
2387
- // (the proposal — the vs-main working diff supplies the op set and each op's TYPE, so merge terms are
2388
- // spoken: content equal to main is no op, an existing node reads `edited` never `added`) AND have been
2389
- // touched by this branch since its fork point (attribution — main's own post-fork movement is not this
2390
- // worktree's op). A `status --porcelain` pass adds untracked spec.md, a third diff vs HEAD marks committed.
2391
- function projectWorktreeSpecDelta(mainOut: string, workOut: string, commOut: string, statusOut: string): NodeOp[] {
2392
- const proposals = parseNameStatus(mainOut)
2393
- // the branch's own footprint since its fork point — both sides of every row, so a rename matches
2394
- // whichever side the vs-main diff names.
2395
- const touched = new Set<string>()
2396
- for (const r of parseNameStatus(workOut)) { touched.add(r.to); if (r.from) touched.add(r.from) }
2397
- const committed = new Set(parseNameStatus(commOut).map((r) => r.to))
2398
- // --untracked-files=all: list every untracked spec.md individually (the default collapses a wholly
2399
- // new node's directory to `.spec/.../node/`, which we'd never recognise as a spec.md add).
2400
- const dirty = new Set<string>(), untracked: string[] = []
2401
- for (const line of statusOut.split('\n')) {
2402
- if (!line) continue
2403
- const xy = line.slice(0, 2)
2404
- let path = line.slice(3)
2405
- const arrow = path.indexOf(' -> '); if (arrow >= 0) path = path.slice(arrow + 4)
2406
- dirty.add(path)
2407
- if (xy === '??' && isSpecMd(path)) untracked.push(path)
2408
- }
2409
-
2410
- const codeFor: Record<string, NodeOp['op']> = { A: 'added', M: 'edited', D: 'deleted', R: 'moved', C: 'added', T: 'edited' }
2411
- const ops: NodeOp[] = [], seen = new Set<string>()
2412
- for (const r of proposals) {
2413
- const path = r.code === 'D' ? r.from : r.to
2414
- if (!isSpecMd(path)) continue
2415
- if (!touched.has(r.to) && !touched.has(r.from)) continue
2416
- seen.add(path)
2417
- const op = codeFor[r.code] ?? 'edited'
2418
- ops.push({
2419
- nodeId: nodeIdOf(path), op, path,
2420
- ...(op === 'moved' ? { fromPath: r.from, toPath: r.to } : {}),
2421
- committed: committed.has(r.to) || committed.has(r.from),
2422
- dirty: dirty.has(path) || dirty.has(r.from),
2423
- })
2424
- }
2425
- for (const path of untracked) {
2426
- if (seen.has(path)) continue
2427
- ops.push({ nodeId: nodeIdOf(path), op: 'added', path, committed: false, dirty: true })
2428
- }
2429
- return ops
2430
- }
2431
-
2432
- type WorktreeSpecDemand = { path: string; head: string }
2433
- export type WorktreeSpecDeltaOutcome = { base: string; ops: NodeOp[] } | { error: unknown }
2434
-
2435
- async function boundedMap<T, R>(values: T[], concurrency: number, fn: (value: T) => Promise<R>): Promise<R[]> {
2436
- const results = new Array<R>(values.length)
2437
- let next = 0
2438
- await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, async () => {
2439
- while (true) {
2440
- const index = next++
2441
- if (index >= values.length) return
2442
- results[index] = await fn(values[index])
2443
- }
2444
- }))
2445
- return results
2446
- }
2447
-
2448
- // One cold layout demand across many linked worktrees. Merge-base and working-status are path facts, so they
2449
- // stay per worktree under a fixed concurrency. Clean rows then collapse to immutable main→HEAD and base→HEAD
2450
- // pairs in one framed child; dirty rows retain the ordinary worktree-aware projection.
2451
- export async function worktreeSpecDeltas(root: string, mainSha: string, demands: WorktreeSpecDemand[], interpretation = gitInterpretationIdentity(root)): Promise<Map<string, WorktreeSpecDeltaOutcome>> {
2452
- const results = new Map<string, WorktreeSpecDeltaOutcome>()
2453
- if (!demands.length) return results
2454
- if (gitInterpretationIdentity(root) !== interpretation) {
2455
- const error = new Error('Git interpretation changed before the worktree overlay batch')
2456
- for (const demand of demands) results.set(demand.path, { error })
2457
- return results
2458
- }
2459
- const prepared = await boundedMap(demands, 4, async (demand) => {
2460
- try {
2461
- const run = (args: string[]) => gitARequired(['-C', demand.path, '-c', 'core.quotePath=false', ...args])
2462
- const [baseOut, statusOut] = await Promise.all([
2463
- run(['merge-base', mainSha, demand.head]),
2464
- run(['status', '--porcelain', '--untracked-files=all', '--', '.spec']),
2465
- ])
2466
- const base = baseOut.trim()
2467
- if (!base) throw new Error(`git merge-base returned no base for ${demand.path}`)
2468
- return { ...demand, base, statusOut }
2469
- } catch (error) {
2470
- results.set(demand.path, { error })
2471
- return null
2472
- }
2473
- })
2474
-
2475
- const clean = prepared.filter((row): row is NonNullable<typeof row> => !!row && row.statusOut.trim() === '')
2476
- const dirty = prepared.filter((row): row is NonNullable<typeof row> => !!row && row.statusOut.trim() !== '')
2477
- const pairs: Array<{ from: string; to: string }> = []
2478
- const pairIndex = new Map<string, number>()
2479
- const addPair = (from: string, to: string): number => {
2480
- const key = `${from}\0${to}`
2481
- const hit = pairIndex.get(key)
2482
- if (hit != null) return hit
2483
- const index = pairs.length
2484
- pairIndex.set(key, index)
2485
- pairs.push({ from, to })
2486
- return index
2487
- }
2488
- const cleanIndexes = clean.map((row) => ({
2489
- row,
2490
- main: addPair(mainSha, row.head),
2491
- branch: addPair(row.base, row.head),
2492
- }))
2493
-
2494
- if (pairs.length) {
2495
- try {
2496
- // diff-tree's stdin pair syntax is `<new> <old>`; reverse each ordinary from→to pair deliberately.
2497
- // --always is load-bearing: it emits a frame for an empty pair, preserving positional ownership.
2498
- const input = pairs.map(({ from, to }) => `${to} ${from}`).join('\n') + '\n'
2499
- const out = await gitARequired([
2500
- '-C', root, '-c', 'core.quotePath=false', 'diff-tree', '--stdin', '--no-commit-id', '--always',
2501
- '-r', '--name-status', '-M', `--format=${RS}%H`, '--', '.spec',
2502
- ], input)
2503
- // For two-tree stdin rows, diff-tree writes that row's name-status payload BEFORE its --format marker.
2504
- // The first split segment is therefore pair 0; each later pair lives after the previous marker, while
2505
- // the final marker carries no following pair. Treating markers as openers shifts every non-empty result.
2506
- const records = out.split(RS)
2507
- if (records.length - 1 !== pairs.length) throw new Error(`git diff-tree --stdin returned ${records.length - 1} frames for ${pairs.length} pairs`)
2508
- const frames = pairs.map((_, index) => {
2509
- if (index === 0) return records[0].split('\n').filter(Boolean).join('\n')
2510
- const lines = records[index].replace(/^\n/, '').split('\n')
2511
- lines.shift()
2512
- return lines.filter(Boolean).join('\n')
2513
- })
2514
- for (const { row, main, branch } of cleanIndexes) {
2515
- results.set(row.path, { base: row.base, ops: projectWorktreeSpecDelta(frames[main], frames[branch], frames[branch], '') })
2516
- }
2517
- } catch (error) {
2518
- for (const { row } of cleanIndexes) results.set(row.path, { error })
2519
- }
2520
- }
2521
-
2522
- await Promise.all(dirty.map(async (row) => {
2523
- try {
2524
- const run = (args: string[]) => gitARequired(['-C', row.path, '-c', 'core.quotePath=false', ...args])
2525
- const [mainOut, workOut, commOut] = await Promise.all([
2526
- run(['diff', '--name-status', '-M', mainSha, '--', '.spec']),
2527
- run(['diff', '--name-status', '-M', row.base, '--', '.spec']),
2528
- run(['diff', '--name-status', '-M', `${row.base}...${row.head}`, '--', '.spec']),
2529
- ])
2530
- results.set(row.path, { base: row.base, ops: projectWorktreeSpecDelta(mainOut, workOut, commOut, row.statusOut) })
2531
- } catch (error) {
2532
- results.set(row.path, { error })
2533
- }
2534
- }))
2535
- if (gitInterpretationIdentity(root) !== interpretation) {
2536
- const error = new Error('Git interpretation changed during the worktree overlay batch')
2537
- for (const demand of demands) results.set(demand.path, { error })
2538
- }
2539
- return results
2540
- }
2541
-
2542
- export async function worktreeSpecDelta(wtPath: string, mainRef: string, baseHint?: string): Promise<NodeOp[]> {
2543
- const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
2544
- // fork point = where this worktree branched from main; '' (no common ancestor / unreadable ref) falls
2545
- // back to mainRef so we still surface changes rather than going silent. The caller (cachedDelta) already
2546
- // computes this same merge-base to key its cache, so it passes it in to avoid a redundant subprocess.
2547
- const base = baseHint || (await run(['merge-base', mainRef, 'HEAD'])).trim() || mainRef
2548
- // the four queries are independent — run them in parallel.
2549
- const [mainOut, workOut, commOut, statusOut] = await Promise.all([
2550
- run(['diff', '--name-status', '-M', mainRef, '--', '.spec']),
2551
- run(['diff', '--name-status', '-M', base, '--', '.spec']),
2552
- run(['diff', '--name-status', '-M', `${base}...HEAD`, '--', '.spec']),
2553
- run(['status', '--porcelain', '--untracked-files=all', '--', '.spec']),
2554
- ])
2555
- return projectWorktreeSpecDelta(mainOut, workOut, commOut, statusOut)
2556
- }