spexcode 0.5.5 → 0.5.7

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 (38) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/src/cli.ts +18 -1
  3. package/spec-cli/src/client.ts +21 -0
  4. package/spec-cli/src/git.ts +460 -238
  5. package/spec-cli/src/harness.ts +116 -24
  6. package/spec-cli/src/help.ts +3 -1
  7. package/spec-cli/src/index.ts +48 -9
  8. package/spec-cli/src/layout.ts +2 -0
  9. package/spec-cli/src/mentions.ts +10 -5
  10. package/spec-cli/src/process-identity.ts +20 -0
  11. package/spec-cli/src/reviewSnapshot.ts +4 -0
  12. package/spec-cli/src/reviews.ts +10 -5
  13. package/spec-cli/src/session-maintenance.ts +2 -1
  14. package/spec-cli/src/sessions.ts +898 -140
  15. package/spec-cli/src/specs.ts +3 -3
  16. package/spec-dashboard/dist/assets/{App-C5vbTw8Q.js → App-u2P7KdSg.js} +2 -2
  17. package/spec-dashboard/dist/assets/Dashboard-B8wp5_61.js +27 -0
  18. package/spec-dashboard/dist/assets/{EvalsPage-BS7ITcNo.js → EvalsPage-Bq1Tkb8y.js} +2 -2
  19. package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +1 -0
  20. package/spec-dashboard/dist/assets/{MobileApp-DVLnk9hz.js → MobileApp-B1GxRZXK.js} +2 -2
  21. package/spec-dashboard/dist/assets/{Modal-6mHq6fbZ.js → Modal-bAkq9IIT.js} +1 -1
  22. package/spec-dashboard/dist/assets/{PageScroll-CAY4S4g4.js → PageScroll-px_rUZVJ.js} +1 -1
  23. package/spec-dashboard/dist/assets/{ProjectsPage-UQyzsTWN.js → ProjectsPage-8uGqYM12.js} +1 -1
  24. package/spec-dashboard/dist/assets/SessionInterface-CswwbewF.js +39 -0
  25. package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +1 -0
  26. package/spec-dashboard/dist/assets/{Settings-igR17pns.js → Settings-bpAbfnmS.js} +1 -1
  27. package/spec-dashboard/dist/assets/{Thread-B-ZUarN1.js → Thread-BpL3N3kw.js} +11 -11
  28. package/spec-dashboard/dist/assets/{TimelineChat-sc49Qj5d.js → TimelineChat-Ckmb1Ez2.js} +1 -1
  29. package/spec-dashboard/dist/assets/{data-B1ot4PF0.js → data-CQFbQEMH.js} +1 -1
  30. package/spec-dashboard/dist/assets/index-CixSnz1H.css +1 -0
  31. package/spec-dashboard/dist/assets/index-Di1ch5dd.js +41 -0
  32. package/spec-dashboard/dist/index.html +2 -2
  33. package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +0 -27
  34. package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +0 -1
  35. package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +0 -39
  36. package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +0 -1
  37. package/spec-dashboard/dist/assets/index-BqBNCa1V.js +0 -41
  38. package/spec-dashboard/dist/assets/index-CzutlTDf.css +0 -1
@@ -235,44 +235,62 @@ type GitExec = { stdout: string; stderr: string }
235
235
  // deterministic tests use a shell + sleep), so async git runs in their own process group and abort/timeout
236
236
  // kills the whole group. The callback still carries the same stdout/stderr/error shape to gitA/gitTry.
237
237
  const GIT_MAX_BUFFER = 1 << 24
238
- function execGit(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal, maxBuffer = GIT_MAX_BUFFER): Promise<GitExec> {
238
+ function execGit(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal, maxBuffer = GIT_MAX_BUFFER, input?: string): Promise<GitExec> {
239
239
  return new Promise((resolve, reject) => {
240
- let child: ReturnType<typeof execFile> | null = null
241
- let timer: ReturnType<typeof setTimeout> | undefined
242
- let aborted = false
243
- let timedOut = false
240
+ if (signal?.aborted) { reject(gitAbortError()); return }
241
+ const child = spawn('git', args, { env, detached: true, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] })
242
+ const stdout: Buffer[] = [], stderr: Buffer[] = []
243
+ let stdoutBytes = 0, stderrBytes = 0, aborted = false, timedOut = false, overflow = false
244
+ let spawnError: Error | null = null
244
245
  const killTree = () => {
245
- if (!child?.pid) return
246
+ if (!child.pid) return
246
247
  try { process.kill(-child.pid, 'SIGKILL') } catch { /* group may already be gone */ }
247
248
  try { child.kill('SIGKILL') } catch { /* already exited */ }
248
249
  }
249
250
  const onAbort = () => { aborted = true; killTree() }
250
- child = execFile('git', args, {
251
- encoding: 'utf8', env, maxBuffer, detached: true,
252
- ...(signal ? { signal, killSignal: 'SIGKILL' } : {}),
253
- } as any, (error: any, stdout: string, stderr: string) => {
254
- if (timer) clearTimeout(timer)
251
+ const append = (chunks: Buffer[], chunk: Buffer, stream: 'stdout' | 'stderr') => {
252
+ const total = stream === 'stdout' ? (stdoutBytes += chunk.length) : (stderrBytes += chunk.length)
253
+ if (total > maxBuffer) { overflow = true; killTree(); return }
254
+ chunks.push(chunk)
255
+ }
256
+ child.stdout!.on('data', (chunk: Buffer) => append(stdout, chunk, 'stdout'))
257
+ child.stderr!.on('data', (chunk: Buffer) => append(stderr, chunk, 'stderr'))
258
+ child.once('error', (error) => { spawnError = error })
259
+ if (input !== undefined) {
260
+ // A command can reject its input before the pipe drains. Keep that write failure on the same
261
+ // close/reject path as spawn and exit failures instead of letting Node raise an unhandled EPIPE.
262
+ child.stdin!.once('error', (error) => { if (!spawnError) spawnError = error })
263
+ child.stdin!.end(input)
264
+ }
265
+ child.once('close', (code, childSignal) => {
266
+ clearTimeout(timer)
255
267
  signal?.removeEventListener('abort', onAbort)
256
- if (error) {
257
- error.stdout = stdout ?? ''
258
- error.stderr = stderr ?? ''
259
- if (aborted) error.name = 'AbortError'
260
- if (timedOut) error.spexcodeGitTimeout = true
261
- reject(error)
262
- } else resolve({ stdout, stderr })
268
+ const result = { stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8') }
269
+ if (code === 0 && !aborted && !timedOut && !overflow && !spawnError) { resolve(result); return }
270
+ const error: any = spawnError ?? new Error(overflow
271
+ ? `git output exceeded ${maxBuffer} bytes`
272
+ : `git exited with ${code ?? childSignal ?? 'unknown status'}`)
273
+ error.code = overflow ? 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' : code
274
+ error.signal = childSignal
275
+ error.stdout = result.stdout
276
+ error.stderr = result.stderr
277
+ if (aborted) error.name = 'AbortError'
278
+ if (timedOut) error.spexcodeGitTimeout = true
279
+ reject(error)
263
280
  })
264
281
  signal?.addEventListener('abort', onAbort, { once: true })
265
- timer = setTimeout(() => { timedOut = true; killTree() }, GIT_TIMEOUT_MS)
282
+ if (signal?.aborted) onAbort()
283
+ const timer = setTimeout(() => { timedOut = true; killTree() }, GIT_TIMEOUT_MS)
266
284
  timer.unref?.()
267
285
  })
268
286
  }
269
287
 
270
- async function execGitForCaller(args: string[], env: NodeJS.ProcessEnv, maxBuffer?: number): Promise<GitExec> {
288
+ async function execGitForCaller(args: string[], env: NodeJS.ProcessEnv, maxBuffer?: number, input?: string): Promise<GitExec> {
271
289
  const context = inheritedContext()
272
- if (!context) return execGit(args, env, undefined, maxBuffer)
290
+ if (!context) return execGit(args, env, undefined, maxBuffer, input)
273
291
  const release = await context.permits.acquire(context.signal)
274
292
  try {
275
- return await execGit(withBuildLimits(args), env, context.signal, maxBuffer)
293
+ return await execGit(withBuildLimits(args), env, context.signal, maxBuffer, input)
276
294
  } finally {
277
295
  release()
278
296
  }
@@ -329,12 +347,12 @@ async function execGitStreamForCaller(args: string[], env: NodeJS.ProcessEnv): P
329
347
  finally { release() }
330
348
  }
331
349
 
332
- export async function gitA(args: string[]): Promise<string> {
350
+ export async function gitA(args: string[], input?: string): Promise<string> {
333
351
  const env = { ...process.env }
334
352
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
335
353
  const context = inheritedContext()
336
354
  try {
337
- const { stdout } = await execGitForCaller(args, env)
355
+ const { stdout } = await execGitForCaller(args, env, undefined, input)
338
356
  return stdout
339
357
  } catch (e: any) {
340
358
  if (context?.signal.aborted || e?.name === 'AbortError') throw e
@@ -342,10 +360,13 @@ export async function gitA(args: string[]): Promise<string> {
342
360
  }
343
361
  }
344
362
 
345
- type EventRecord = { hash: string; raw: string }
363
+ type TextEventRecord = { hash: string; raw: string }
364
+ type IdentityEventRecord = { hash: string; identity: IdentityRawRecord }
365
+ type EventRecord = TextEventRecord | IdentityEventRecord
346
366
  type EventCache = { streams: Map<EventStreamKind, Map<string, EventRecord>>; streamTips: Map<EventStreamKind, string[]> }
347
- const EVENT_CACHE_SCHEMA = 'history-events-v7'
348
- const EVENT_STREAM_KINDS = ['numstat', 'merge', 'drift-numstat'] as const
367
+ type EventStreamOutput = string | IdentityRawRecord[]
368
+ const EVENT_CACHE_SCHEMA = 'history-events-v15'
369
+ const EVENT_STREAM_KINDS = ['merge', 'identity-raw'] as const
349
370
  type EventStreamKind = typeof EVENT_STREAM_KINDS[number]
350
371
  type EventCacheLocation = { path: string; identity: string; objectFormat: GitObjectFormat }
351
372
  type EventLedgerSnapshot = {
@@ -360,9 +381,36 @@ type EventStreamRequest = {
360
381
  }
361
382
 
362
383
  type EventPathMemo = EventCacheLocation & {
363
- common: string; shallowPath: string; grafts: string; shallow: string; replacements: string
384
+ common: string; shallowPath: string; grafts: string; shallow: string
385
+ replacementStorage: string; replacements: string
364
386
  }
365
387
  const eventPathMemo = new Map<string, EventPathMemo>()
388
+ function replacementStorageIdentity(common: string): string {
389
+ const hash = createHash('sha256')
390
+ const addTree = (root: string, rel: string) => {
391
+ if (!existsSync(root)) return
392
+ const stack = [{ dir: root, rel }]
393
+ while (stack.length) {
394
+ const current = stack.pop()!
395
+ const entries = readdirSync(current.dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))
396
+ for (const entry of entries) {
397
+ const path = join(current.dir, entry.name)
398
+ const name = `${current.rel}/${entry.name}`
399
+ if (entry.isDirectory()) stack.push({ dir: path, rel: name })
400
+ else hash.update(`\0${name}\0`).update(readFileSync(path))
401
+ }
402
+ }
403
+ }
404
+ addTree(join(common, 'refs', 'replace'), 'refs/replace')
405
+ for (const name of ['packed-refs']) {
406
+ const path = join(common, name)
407
+ if (existsSync(path)) hash.update(`\0${name}\0`).update(readFileSync(path))
408
+ }
409
+ // Reftable is opaque here by design: its bytes are only an invalidation signal. Git remains the one
410
+ // parser and supplies the canonical refs/replace targets when those bytes change.
411
+ addTree(join(common, 'reftable'), 'reftable')
412
+ return hash.digest('hex')
413
+ }
366
414
  function eventCacheLocation(root: string): EventCacheLocation {
367
415
  const rootId = rootKey(root), old = eventPathMemo.get(rootId)
368
416
  const common = old?.common ?? git(['-C', root, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
@@ -370,7 +418,10 @@ function eventCacheLocation(root: string): EventCacheLocation {
370
418
  const shallow = existsSync(shallowPath) ? readFileSync(shallowPath, 'utf8') : 'unshallow'
371
419
  const graftsPath = join(common, 'info', 'grafts')
372
420
  const grafts = existsSync(graftsPath) ? readFileSync(graftsPath, 'utf8') : ''
373
- const replacements = git(['-C', root, 'for-each-ref', 'refs/replace', '--format=%(refname) %(objectname)'])
421
+ const replacementStorage = replacementStorageIdentity(common)
422
+ const replacements = old?.replacementStorage === replacementStorage
423
+ ? old.replacements
424
+ : git(['-C', root, 'for-each-ref', 'refs/replace', '--format=%(refname) %(objectname)'])
374
425
  const objectFormat = gitObjectFormat(root)
375
426
  if (old && old.shallow === shallow && old.grafts === grafts && old.replacements === replacements && old.objectFormat === objectFormat)
376
427
  return { path: old.path, identity: old.identity, objectFormat }
@@ -382,7 +433,7 @@ function eventCacheLocation(root: string): EventCacheLocation {
382
433
  const gitDir = gitDirOf(root)
383
434
  const storeIdentity = gitDir === root && common === root ? join(common, '.git') : common
384
435
  const path = join(projectRuntimeRoot(storeIdentity), `${EVENT_CACHE_SCHEMA}-${identity}.ndjson`)
385
- eventPathMemo.set(rootId, { common, shallowPath, grafts, shallow, replacements, path, identity, objectFormat })
436
+ eventPathMemo.set(rootId, { common, shallowPath, grafts, shallow, replacementStorage, replacements, path, identity, objectFormat })
386
437
  return { path, identity, objectFormat }
387
438
  }
388
439
  function emptyEventCache(): EventCache {
@@ -434,15 +485,26 @@ function decodeEventPayload(payload: Buffer, location: EventCacheLocation): Even
434
485
  continue
435
486
  }
436
487
  const kind = eventStreamKind(row.k)
437
- if (!exactKeys(row, ['h', 'k', 'r']) || !kind
438
- || typeof row.h !== 'string' || !isGitObjectIdForFormat(location.objectFormat, row.h)
439
- || typeof row.r !== 'string' || !row.r) return null
440
- const rawHash = row.r.split(US, 1)[0].split('\n', 1)[0].trim()
441
- if (rawHash !== row.h) return null
488
+ if (!kind || typeof row.h !== 'string' || !isGitObjectIdForFormat(location.objectFormat, row.h)) return null
489
+ const record: EventRecord | null = kind === 'identity-raw'
490
+ ? (() => {
491
+ const payload = exactKeys(row, ['a', 'c', 'd', 'h', 'k', 'r', 's'])
492
+ ? { a: row.a, c: row.c, d: row.d, r: row.r, s: row.s }
493
+ : null
494
+ const identity = payload ? decodeIdentityRawRecord(payload, row.h, location) : null
495
+ return identity ? { hash: row.h, identity } : null
496
+ })()
497
+ : (!exactKeys(row, ['h', 'k', 'r']) || typeof row.r !== 'string' || !row.r
498
+ ? null
499
+ : (() => {
500
+ const rawHash = row.r.split(US, 1)[0].split('\n', 1)[0].trim()
501
+ return rawHash === row.h ? { hash: row.h, raw: row.r } : null
502
+ })())
503
+ if (!record) return null
442
504
  let stream = state.streams.get(kind)
443
505
  if (!stream) { stream = new Map(); state.streams.set(kind, stream) }
444
506
  if (stream.has(row.h)) return null
445
- stream.set(row.h, { hash: row.h, raw: row.r })
507
+ stream.set(row.h, record)
446
508
  }
447
509
  return state
448
510
  }
@@ -576,22 +638,35 @@ function replaceEventLedger(path: string, payload: Buffer, additions: string[]):
576
638
  throw error
577
639
  }
578
640
  }
579
- function renderEventStream(state: EventCache, request: EventStreamRequest): string {
580
- const stream = state.streams.get(request.kind) ?? new Map<string, EventRecord>()
581
- return [...stream.values()].filter((record) => request.reachable.has(record.hash)).sort((a, b) => {
582
- if (request.kind === 'numstat') {
583
- const ad = Date.parse(a.raw.split(US)[1] ?? ''), bd = Date.parse(b.raw.split(US)[1] ?? '')
584
- if (Number.isFinite(ad) && Number.isFinite(bd) && ad !== bd) return bd - ad
585
- }
586
- return (request.order.get(a.hash) ?? Number.MAX_SAFE_INTEGER) - (request.order.get(b.hash) ?? Number.MAX_SAFE_INTEGER)
587
- }).map((record) => RS + record.raw).join('')
641
+ function sortedEventRecords(records: Iterable<EventRecord>, request: EventStreamRequest): EventRecord[] {
642
+ return [...records].filter((record) => request.reachable.has(record.hash))
643
+ .sort((a, b) => (request.order.get(a.hash) ?? Number.MAX_SAFE_INTEGER) - (request.order.get(b.hash) ?? Number.MAX_SAFE_INTEGER))
644
+ }
645
+ function identityRecords(records: Iterable<EventRecord>, request: EventStreamRequest): IdentityRawRecord[] {
646
+ return sortedEventRecords(records, request).map((record) => {
647
+ if (!('identity' in record)) throw new Error('identity-raw ledger contained a text event')
648
+ return record.identity
649
+ })
650
+ }
651
+ function renderEventStream(state: EventCache, request: EventStreamRequest): EventStreamOutput {
652
+ const records = sortedEventRecords(state.streams.get(request.kind)?.values() ?? [], request)
653
+ if (request.kind === 'identity-raw') return records.map((record) => {
654
+ if (!('identity' in record)) throw new Error('identity-raw ledger contained a text event')
655
+ return record.identity
656
+ })
657
+ return records.map((record) => {
658
+ if (!('raw' in record)) throw new Error(`history event stream '${request.kind}' contained a structured event`)
659
+ return RS + record.raw
660
+ }).join('')
588
661
  }
589
662
  function appendEventRecord(state: EventCache, kind: EventStreamKind, record: EventRecord, additions: string[]): void {
590
663
  let stream = state.streams.get(kind)
591
664
  if (!stream) { stream = new Map(); state.streams.set(kind, stream) }
592
665
  if (stream.has(record.hash)) return
593
666
  stream.set(record.hash, record)
594
- additions.push(JSON.stringify({ k: kind, h: record.hash, r: record.raw }) + '\n')
667
+ additions.push('identity' in record
668
+ ? 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'
669
+ : JSON.stringify({ k: kind, h: record.hash, r: record.raw }) + '\n')
595
670
  }
596
671
  function appendEventTip(state: EventCache, kind: EventStreamKind, tip: string, additions: string[]): void {
597
672
  const tips = state.streamTips.get(kind) ?? []
@@ -601,6 +676,7 @@ function appendEventTip(state: EventCache, kind: EventStreamKind, tip: string, a
601
676
  additions.push(JSON.stringify({ k: `tip:${kind}`, tip }) + '\n')
602
677
  }
603
678
  function parseEventRecords(out: string, kind: EventStreamKind, location: EventCacheLocation): EventRecord[] {
679
+ if (kind === 'identity-raw') return parseIdentityRawEventRecords(out, location)
604
680
  const records: EventRecord[] = []
605
681
  for (const rec of out.split(RS)) {
606
682
  const raw = rec.replace(/^\n/, '')
@@ -619,17 +695,11 @@ function indexEventRequests(
619
695
  reachable: Set<string>,
620
696
  ): Record<EventStreamKind, EventStreamRequest> {
621
697
  return {
622
- numstat: {
623
- kind: 'numstat', order, reachable,
624
- argsFor: (base) => ['-C', root, '-c', 'core.quotePath=false',
625
- 'log', '--full-history', '--date-order', '--no-diff-merges', '-M', '--numstat',
626
- `--format=${RS}%H${US}%aI${US}%s${US}%b`, ...(base ? [`^${base}`] : []), tip, '--', '.spec'],
627
- },
628
- 'drift-numstat': {
629
- kind: 'drift-numstat', order, reachable,
698
+ 'identity-raw': {
699
+ kind: 'identity-raw', order, reachable,
630
700
  argsFor: (base) => ['-C', root, '-c', 'core.quotePath=false',
631
- 'log', '--full-history', '--date-order', '--no-diff-merges', '-M', '--numstat',
632
- `--format=${RS}%H${US}%P${US}%T${US}%(trailers:key=Spec-OK,valueonly,separator=%x2C)`,
701
+ 'log', '--root', '--full-history', '--date-order', '--no-diff-merges', '-M', '-l0', '--raw', '-z', '--no-abbrev', '--no-ext-diff', '--no-textconv',
702
+ `--format=${RS}%H%x00%aI%x00%s%x00%b%x00%(trailers:key=Spec-OK,valueonly,separator=%x2C)%x00`,
633
703
  ...(base ? [`^${base}`] : []), tip],
634
704
  },
635
705
  merge: {
@@ -650,15 +720,23 @@ async function deriveEventStreams(
650
720
  requests: EventStreamRequest[],
651
721
  persist = true,
652
722
  cache = true,
653
- ): Promise<Map<EventStreamKind, string>> {
723
+ ): Promise<Map<EventStreamKind, EventStreamOutput>> {
654
724
  if (!cache) {
725
+ const location = eventCacheLocation(root)
655
726
  const outputs = await Promise.all(requests.map((request) => strictEventGit(request.argsFor(''))))
656
- return new Map(requests.map((request, index) => [request.kind, outputs[index]]))
727
+ const rendered = new Map<EventStreamKind, EventStreamOutput>()
728
+ for (let index = 0; index < requests.length; index++) {
729
+ const request = requests[index]
730
+ rendered.set(request.kind, request.kind === 'identity-raw'
731
+ ? identityRecords(parseIdentityRawEventRecords(outputs[index], location), request)
732
+ : outputs[index])
733
+ }
734
+ return rendered
657
735
  }
658
736
  if (new Set(requests.map((request) => request.kind)).size !== requests.length)
659
737
  throw new Error('one event-ledger transaction cannot request the same stream twice')
660
738
 
661
- const run = async (location: EventCacheLocation): Promise<Map<EventStreamKind, string> | null> => {
739
+ const run = async (location: EventCacheLocation): Promise<Map<EventStreamKind, EventStreamOutput> | null> => {
662
740
  const snapshot = loadEventLedger(location)
663
741
  const missing = requests.filter((request) => !(snapshot.state.streamTips.get(request.kind) ?? []).includes(tip))
664
742
  const outputs = await Promise.all(missing.map((request) => {
@@ -696,8 +774,20 @@ async function eventStream(
696
774
  request: EventStreamRequest,
697
775
  persist = true,
698
776
  cache = true,
699
- ): Promise<string> {
700
- return (await deriveEventStreams(root, tip, [request], persist, cache)).get(request.kind) ?? ''
777
+ ): Promise<EventStreamOutput> {
778
+ const value = (await deriveEventStreams(root, tip, [request], persist, cache)).get(request.kind)
779
+ if (value === undefined) throw new Error(`history event stream '${request.kind}' was not rendered`)
780
+ return value
781
+ }
782
+ async function textEventStream(root: string, tip: string, request: EventStreamRequest, persist = true, cache = true): Promise<string> {
783
+ const value = await eventStream(root, tip, request, persist, cache)
784
+ if (typeof value !== 'string') throw new Error(`history event stream '${request.kind}' rendered structured data`)
785
+ return value
786
+ }
787
+ async function identityRawEventStream(root: string, tip: string, request: EventStreamRequest, persist = true, cache = true): Promise<IdentityRawRecord[]> {
788
+ const value = await eventStream(root, tip, request, persist, cache)
789
+ if (!Array.isArray(value) || value.some((record) => !('a' in record))) throw new Error(`history event stream '${request.kind}' rendered text`)
790
+ return value as IdentityRawRecord[]
701
791
  }
702
792
  export type GitTryFailure = 'exit' | 'spawn' | 'timeout'
703
793
  export async function gitTry(args: string[], options: { indexFile?: string } = {}): Promise<{ ok: boolean; stdout: string; stderr: string; failure?: GitTryFailure }> {
@@ -817,27 +907,76 @@ export type DiffStat = { additions: number; deletions: number; files: number }
817
907
 
818
908
  export type HistoryIndex = {
819
909
  versions: Map<string, Version[]> // headPath -> rows newest-first (incl. pure-rename rows)
820
- stats: Map<string, Map<string, DiffStat>> // headPath -> (commit hash -> this file's diffstat there)
910
+ contentVersions: Set<string> // headPath\0hash rows whose immutable blob changed
911
+ versionPaths: Map<string, string> // headPath\0hash -> path at that version commit
821
912
  mergeVersions?: Set<string> // path\0hash pairs with an all-parent combined-diff line
822
913
  }
823
914
 
824
- // git numstat encodes a rename as `dir/{old => new}/file` (either side may be empty) or `old => new`;
825
- // recover both endpoints. Spec paths are brace/space-free here, so the textual parse is unambiguous.
826
- function parseStatPath(token: string): { from: string; to: string } {
827
- const b = token.indexOf('{')
828
- if (b >= 0) {
829
- const arrow = token.indexOf(' => ', b)
830
- const close = token.indexOf('}', arrow)
831
- if (arrow > b && close > arrow) {
832
- const pre = token.slice(0, b), post = token.slice(close + 1)
833
- const from = (pre + token.slice(b + 1, arrow) + post).replace(/\/\//g, '/')
834
- const to = (pre + token.slice(arrow + 4, close) + post).replace(/\/\//g, '/')
835
- return { from, to }
836
- }
915
+ type IdentityRawRecord = { h: string; d: string; r: string; s: string | null; a: string; c: [string, string, string, string, string][] }
916
+
917
+ function isRawObjectId(value: string, format: GitObjectFormat): boolean {
918
+ const length = format === 'sha256' ? 64 : 40
919
+ return isGitObjectIdForFormat(format, value) || (value.length === length && /^0+$/.test(value))
920
+ }
921
+
922
+ function parseIdentityRawEventRecords(out: string, location: EventCacheLocation): EventRecord[] {
923
+ const parsed: IdentityRawRecord[] = []
924
+ let current: IdentityRawRecord | null = null
925
+ const tokens = out.split('\0')
926
+ let index = 0
927
+ const metadata = (field: string): string => {
928
+ const token = tokens[index++]
929
+ if (token === undefined) throw new Error(`raw identity stream ended before commit ${field}`)
930
+ return token
837
931
  }
838
- const i = token.indexOf(' => ')
839
- if (i >= 0) return { from: token.slice(0, i), to: token.slice(i + 4) }
840
- return { from: token, to: token }
932
+ const begin = (token: string): IdentityRawRecord => {
933
+ const header = token.startsWith('\n') ? token.slice(1) : token
934
+ if (!header.startsWith(RS)) throw new Error(`raw identity stream expected a commit header, got '${header}'`)
935
+ const hash = header.slice(RS.length)
936
+ if (!isGitObjectIdForFormat(location.objectFormat, hash))
937
+ throw new Error(`history event stream 'identity-raw' returned malformed object id '${hash || 'empty'}'`)
938
+ if (current) parsed.push(current)
939
+ const date = metadata('date'), reason = metadata('subject'), body = metadata('body'), ack = metadata('trailers')
940
+ const session = body.match(/Session:\s*(\S+)/)
941
+ return { h: hash, d: date, r: reason, s: session ? session[1] : null, a: ack, c: [] }
942
+ }
943
+ while (index < tokens.length) {
944
+ const token = tokens[index++]
945
+ if (!current) {
946
+ if (!token) continue
947
+ current = begin(token)
948
+ continue
949
+ }
950
+ const value = token.startsWith('\n') ? token.slice(1) : token
951
+ if (!value) continue
952
+ if (value.startsWith(RS)) { current = begin(value); continue }
953
+ const raw = value.match(/^:([0-7]{6}) ([0-7]{6}) ([0-9a-f]+) ([0-9a-f]+) ([A-Z])(?:\d+)?$/)
954
+ if (!raw || !isRawObjectId(raw[3], location.objectFormat) || !isRawObjectId(raw[4], location.objectFormat))
955
+ throw new Error(`raw identity event ${current.h} has malformed raw record '${value}'`)
956
+ const from = tokens[index++]
957
+ if (from === undefined) throw new Error(`raw identity event ${current.h} ended before its path`)
958
+ const to = raw[5] === 'R' ? tokens[index++] : from
959
+ if (to === undefined) throw new Error(`raw identity event ${current.h} ended before its rename destination`)
960
+ current.c.push([raw[5], from, to, raw[3], raw[4]])
961
+ }
962
+ if (current) parsed.push(current)
963
+ return parsed.map((identity) => ({ hash: identity.h, identity }))
964
+ }
965
+
966
+ function decodeIdentityRawRecord(value: unknown, hash: string, location: EventCacheLocation): IdentityRawRecord | null {
967
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
968
+ const record = value as Record<string, unknown>
969
+ if (!exactKeys(record, ['a', 'c', 'd', 'r', 's']) || typeof record.a !== 'string' || typeof record.d !== 'string'
970
+ || typeof record.r !== 'string' || (typeof record.s !== 'string' && record.s !== null) || !Array.isArray(record.c) || record.c.length % 5) return null
971
+ const changes: [string, string, string, string, string][] = []
972
+ for (let index = 0; index < record.c.length; index += 5) {
973
+ if (typeof record.c[index] !== 'string' || typeof record.c[index + 1] !== 'string' || typeof record.c[index + 2] !== 'string'
974
+ || typeof record.c[index + 3] !== 'string' || typeof record.c[index + 4] !== 'string'
975
+ || !/^[A-Z](?:\d+)?$/.test(record.c[index] as string) || !isRawObjectId(record.c[index + 3] as string, location.objectFormat)
976
+ || !isRawObjectId(record.c[index + 4] as string, location.objectFormat)) return null
977
+ 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])
978
+ }
979
+ return { h: hash, d: record.d, r: record.r, s: record.s, a: record.a, c: changes }
841
980
  }
842
981
 
843
982
  // Both bulk indices are pure functions of a checkout's HEAD, and they are read for SEVERAL roots at
@@ -855,11 +994,11 @@ const INDEX_ROOT_SLOTS = Math.max(4, Number(process.env.SPEXCODE_INDEX_CACHE_ROO
855
994
 
856
995
  function rootKey(root: string): string { return resolve(root) }
857
996
 
858
- function gitInterpretationKey(root: string): string { return eventCacheLocation(root).identity }
997
+ function indexCacheKey(root: string, head: string): string { return `${eventCacheLocation(root).path}\0${head}` }
859
998
 
860
- // HEAD plus Git's object-interpretation state identifies the immutable index contents; the root owns which
861
- // view is still useful. Moving a checkout or changing replace/shallow/graft state drops its old history,
862
- // while equal views across live roots share one promise. The root bound keeps closed worktrees from leaking.
999
+ // A project-namespaced ledger path plus HEAD identifies immutable index contents. Its path is scoped to the
1000
+ // common Git store and interpretation state, so linked worktrees share while independent same-HEAD clones do not.
1001
+ // The checkout root owns which live view is useful; the root bound keeps closed worktrees from leaking.
863
1002
  function touchRoot(roots: Map<string, string>, cache: Map<string, Promise<unknown>>, root: string, cacheKey: string): void {
864
1003
  const key = rootKey(root)
865
1004
  const previous = roots.get(key)
@@ -923,7 +1062,7 @@ export function historyIndex(root: string, tip = 'HEAD'): Promise<HistoryIndex>
923
1062
  }
924
1063
  const head = headOrEmpty(root)
925
1064
  if (!head) return buildIndex(root, 'HEAD', false, true)
926
- const cacheKey = `${rootKey(root)}\0${head}\0${gitInterpretationKey(root)}`
1065
+ const cacheKey = indexCacheKey(root, head)
927
1066
  touchRoot(indexRoots, indexCache, root, cacheKey)
928
1067
  const hit = indexCache.get(cacheKey)
929
1068
  if (hit) return hit
@@ -943,39 +1082,82 @@ function headOrEmpty(root: string): string {
943
1082
  }
944
1083
  }
945
1084
 
946
- type RenameProjectionEvent = { hash: string; to: string }
947
- function canonicalPathProjector(
948
- renamesByFrom: Map<string, RenameProjectionEvent[]>,
949
- topologyOrd: Map<string, number>,
950
- topologyParents: Map<string, string[]>,
951
- ): (path: string, event: string) => string[] {
952
- const ancestryCache = new Map<string, Uint8Array>()
953
- const ancestryOf = (hash: string): Uint8Array | undefined => {
954
- const hit = ancestryCache.get(hash)
1085
+ // @@@ reachability memoized on the rename side, never the event side - every comparison the projector makes
1086
+ // has a RENAME commit on one end, and a history holds far fewer renames than file events. Keying the memo on
1087
+ // the OTHER end rebuilds a history-wide ancestor set per distinct event commit: 2.3M parent-edge visits and
1088
+ // 1,219 retained bitsets served 9k one-bit questions on this repository. That end builds one closure per
1089
+ // distinct event commit actually compared against a rename — C of them, O(C(H+G)) construction and Θ(CH) bits
1090
+ // — which a linear history whose events all sit on one renamed path drives to Θ(H²). Asking the rename end
1091
+ // instead its descendants when it is the older commit, its ancestors when it is the newer one — moves ONLY
1092
+ // the closure term onto the rename count K: at most 2K full-size closures, O(K(H+G)) construction over H
1093
+ // reachable commits and G parent edges, O(KH) bits. That 2K ceiling bounds closure buffers, count and bytes,
1094
+ // against C; it does NOT bound runtime or edge visits, since a rename with many unrelated descendants
1095
+ // traverses ground the event-side ancestor walk never touched. The projector's own work is unchanged and is
1096
+ // NOT covered by that term: one scan of the N events, plus a lineage walk whose frontier compares each step's
1097
+ // applicable renames pairwise — Σ d(candidate)² O(1) queries, worst case Θ(NK²) when one path carries K
1098
+ // mutually incomparable renames. So: no linear-in-history promise — at K≈H the closure term is O(H(H+G)),
1099
+ // quadratic only where the DAG is sparse enough that G=O(H), and the untouched frontier term is cubic when
1100
+ // N, K and H grow together. It is also why a
1101
+ // reachability matrix over the rename commits is not worth it: same O(KH) bits, but eagerly.
1102
+ function renameSideReachability(
1103
+ renameCommits: Set<string>,
1104
+ topology: TopologyProjection,
1105
+ ): (older: string, newer: string) => boolean {
1106
+ const { order, parents } = topology
1107
+ const size = (order.size + 7) >> 3
1108
+ let childEdges: Map<string, string[]> | null = null
1109
+ const children = (): Map<string, string[]> => {
1110
+ if (childEdges) return childEdges
1111
+ childEdges = new Map()
1112
+ for (const [hash] of order) for (const parent of parents.get(hash) ?? []) {
1113
+ if (!order.has(parent)) continue // shallow boundary: an unwalked parent ends the chain
1114
+ const kids = childEdges.get(parent)
1115
+ if (kids) kids.push(hash)
1116
+ else childEdges.set(parent, [hash])
1117
+ }
1118
+ return childEdges
1119
+ }
1120
+ const closure = (start: string, edges: Map<string, string[]>, memo: Map<string, Uint8Array>): Uint8Array => {
1121
+ const hit = memo.get(start)
955
1122
  if (hit) return hit
956
- const start = topologyOrd.get(hash)
957
- if (start === undefined) return undefined
958
- const bits = new Uint8Array((topologyOrd.size + 7) >> 3)
959
- bits[start >> 3] |= 1 << (start & 7)
960
- const stack = [hash]
961
- while (stack.length) for (const parent of topologyParents.get(stack.pop()!) ?? []) {
962
- const position = topologyOrd.get(parent)
1123
+ const bits = new Uint8Array(size)
1124
+ const at = order.get(start)!
1125
+ bits[at >> 3] |= 1 << (at & 7)
1126
+ const stack = [start]
1127
+ while (stack.length) for (const next of edges.get(stack.pop()!) ?? []) {
1128
+ const position = order.get(next)
963
1129
  if (position === undefined) continue
964
1130
  const mask = 1 << (position & 7)
965
1131
  if (bits[position >> 3] & mask) continue
966
1132
  bits[position >> 3] |= mask
967
- stack.push(parent)
1133
+ stack.push(next)
968
1134
  }
969
- ancestryCache.set(hash, bits)
1135
+ memo.set(start, bits)
970
1136
  return bits
971
1137
  }
972
- const precedes = (older: string, newer: string): boolean => {
973
- const position = topologyOrd.get(older), ancestry = ancestryOf(newer)
974
- if (position === undefined || ancestry === undefined)
1138
+ const ancestors = new Map<string, Uint8Array>(), descendants = new Map<string, Uint8Array>()
1139
+ return (older, newer) => {
1140
+ const from = order.get(older), to = order.get(newer)
1141
+ if (from === undefined || to === undefined)
975
1142
  throw new Error(`rename projection cannot place ${older} against ${newer} in the current topology`)
976
- return (ancestry[position >> 3] & (1 << (position & 7))) !== 0
1143
+ // Whichever end is the rename owns the closure; the projector always puts one there.
1144
+ return renameCommits.has(older)
1145
+ ? (closure(older, children(), descendants)[to >> 3] & (1 << (to & 7))) !== 0
1146
+ : (closure(newer, parents, ancestors)[from >> 3] & (1 << (from & 7))) !== 0
977
1147
  }
1148
+ }
1149
+
1150
+ type RenameProjectionEvent = { hash: string; to: string }
1151
+ function canonicalPathProjector(
1152
+ renamesByFrom: Map<string, RenameProjectionEvent[]>,
1153
+ topology: TopologyProjection,
1154
+ ): (path: string, event: string) => string[] {
1155
+ const renameCommits = new Set<string>()
1156
+ for (const renames of renamesByFrom.values()) for (const rename of renames) renameCommits.add(rename.hash)
1157
+ const precedes = renameSideReachability(renameCommits, topology)
978
1158
  return (path, event) => {
1159
+ // A path no rename ever left keeps its own identity at every event; there is no lineage to walk.
1160
+ if (!renamesByFrom.has(path)) return [path]
979
1161
  const pending = [path], resolved = new Set<string>(), seen = new Set<string>()
980
1162
  while (pending.length) {
981
1163
  const candidate = pending.pop()!
@@ -1002,68 +1184,61 @@ function canonicalPathProjector(
1002
1184
  }
1003
1185
 
1004
1186
  type SharedIndexInputs = {
1005
- allPathsOut: string
1006
- topologyOut: string
1007
- historyOut: string
1008
- driftOut: string
1187
+ allPaths: Set<string>
1188
+ specPaths: Set<string>
1189
+ topology: TopologyProjection
1190
+ identityRecords: IdentityRawRecord[]
1009
1191
  mergeIndex: MergeHistoryEvents
1010
1192
  }
1011
1193
 
1194
+ type TopologyProjection = {
1195
+ order: Map<string, number>
1196
+ parents: Map<string, string[]>
1197
+ reachable: Set<string>
1198
+ }
1199
+
1012
1200
  async function buildIndex(root: string, tip: string, transient: boolean, useCache = true, shared?: SharedIndexInputs): Promise<HistoryIndex> {
1013
1201
  const versions = new Map<string, Version[]>()
1014
- const stats = new Map<string, Map<string, DiffStat>>()
1202
+ const contentVersions = new Set<string>()
1203
+ const versionPaths = new Map<string, string>()
1015
1204
  const mergeVersions = new Set<string>()
1016
1205
  const commitVersions = new Map<string, Version>()
1017
1206
  const commitOrder = new Map<string, number>()
1018
- const [tipPathsOut, topologyOut] = shared
1019
- ? [shared.allPathsOut, shared.topologyOut]
1020
- : await Promise.all([
1207
+ const rawVersions = new Map<string, Version[]>()
1208
+ let currentPaths: Set<string>
1209
+ let topology: TopologyProjection
1210
+ if (shared) {
1211
+ currentPaths = shared.specPaths
1212
+ topology = shared.topology
1213
+ } else {
1214
+ const [tipPathsOut, topologyOut] = await Promise.all([
1021
1215
  strictEventGit(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--name-only', tip, '--', '.spec']),
1022
1216
  strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
1023
1217
  ])
1024
- const rawVersions = new Map<string, Version[]>()
1025
- const rawStats = new Map<string, Map<string, DiffStat>>()
1026
- const currentPaths = new Set(tipPathsOut.split('\0').filter((path) => path.startsWith('.spec/')))
1218
+ currentPaths = new Set(tipPathsOut.split('\0').filter((path) => path.startsWith('.spec/')))
1219
+ topology = topologyProjection(topologyOut)
1220
+ }
1027
1221
  const renamesByFrom = new Map<string, { hash: string; to: string }[]>()
1028
- const topologyOrd = new Map<string, number>(), topologyParents = new Map<string, string[]>()
1029
- let topologyPosition = 0
1030
- for (const line of topologyOut.trim().split('\n')) {
1031
- if (!line) continue
1032
- const [hash, ...parents] = line.split(' ')
1033
- topologyOrd.set(hash, topologyPosition++)
1034
- topologyParents.set(hash, parents)
1035
- }
1036
- const topologyReachable = new Set(topologyOrd.keys())
1037
- const out = shared?.historyOut ?? await eventStream(root, tip,
1038
- indexEventRequests(root, tip, topologyOrd, topologyReachable).numstat, !transient, useCache)
1039
- if (!out) return { versions, stats, mergeVersions }
1222
+ const topologyOrd = topology.order
1223
+ const topologyParents = topology.parents
1224
+ const topologyReachable = topology.reachable
1225
+ const identityRecords = shared?.identityRecords ?? await identityRawEventStream(root, tip,
1226
+ indexEventRequests(root, tip, topologyOrd, topologyReachable)['identity-raw'], !transient, useCache)
1227
+ if (!identityRecords.length) return { versions, contentVersions, versionPaths, mergeVersions }
1040
1228
  let commitPosition = 0
1041
- for (const rec of out.split(RS)) {
1042
- const r = rec.replace(/^\n/, '')
1043
- if (!r) continue
1044
- const parts = r.split(US)
1045
- const hash = parts[0], date = parts[1], reason = parts[2]
1046
- const rest = parts.slice(3).join(US) // body (had no US) followed by the numstat block
1047
- const sm = rest.match(/Session:\s*(\S+)/)
1048
- const version: Version = { hash, date, reason, session: sm ? sm[1] : null }
1049
- commitVersions.set(hash, version)
1050
- if (!commitOrder.has(hash)) commitOrder.set(hash, commitPosition++)
1051
- for (const line of rest.split('\n')) {
1052
- const m = line.match(/^(-|\d+)\t(-|\d+)\t(.+)$/)
1053
- if (!m) continue
1054
- const add = m[1] === '-' ? 0 : +m[1]
1055
- const del = m[2] === '-' ? 0 : +m[2]
1056
- const { from, to } = parseStatPath(m[3])
1229
+ const rawContent = new Set<string>()
1230
+ for (const record of identityRecords) {
1231
+ const version: Version = { hash: record.h, date: record.d, reason: record.r, session: record.s }
1232
+ commitVersions.set(record.h, version)
1233
+ if (!commitOrder.has(record.h)) commitOrder.set(record.h, commitPosition++)
1234
+ for (const [, from, to, oldOid, newOid] of record.c) {
1235
+ if (!from.startsWith('.spec/') && !to.startsWith('.spec/')) continue
1057
1236
  if (!rawVersions.has(to)) rawVersions.set(to, [])
1058
1237
  rawVersions.get(to)!.push(version)
1059
- let hs = rawStats.get(to)
1060
- if (!hs) { hs = new Map(); rawStats.set(to, hs) }
1061
- const s = hs.get(hash) ?? { additions: 0, deletions: 0, files: 0 }
1062
- s.additions += add; s.deletions += del; s.files += 1
1063
- hs.set(hash, s)
1238
+ if (oldOid !== newOid) rawContent.add(`${to}\0${record.h}`)
1064
1239
  if (from !== to) {
1065
1240
  const renames = renamesByFrom.get(from) ?? []
1066
- renames.push({ hash, to })
1241
+ renames.push({ hash: record.h, to })
1067
1242
  renamesByFrom.set(from, renames)
1068
1243
  }
1069
1244
  }
@@ -1130,56 +1305,41 @@ async function buildIndex(root: string, tip: string, transient: boolean, useCach
1130
1305
  renames.push({ hash: rename.hash, to: rename.to })
1131
1306
  renamesByFrom.set(rename.from, renames)
1132
1307
  }
1133
- const canonical = canonicalPathProjector(renamesByFrom, topologyOrd, topologyParents)
1308
+ const canonical = canonicalPathProjector(renamesByFrom, topology)
1134
1309
  for (const [path, pathRows] of rawVersions) {
1135
1310
  for (const row of pathRows) for (const head of canonical(path, row.hash).filter((candidate) => currentPaths.has(candidate))) {
1136
1311
  const rows = versions.get(head) ?? []
1137
1312
  if (!rows.some((existing) => existing.hash === row.hash)) rows.push(row)
1138
1313
  versions.set(head, rows)
1139
- const hs = stats.get(head) ?? new Map<string, DiffStat>()
1140
- const value = rawStats.get(path)?.get(row.hash)
1141
- if (value) {
1142
- const existing = hs.get(row.hash)
1143
- hs.set(row.hash, existing ? {
1144
- additions: existing.additions + value.additions,
1145
- deletions: existing.deletions + value.deletions,
1146
- files: existing.files + value.files,
1147
- } : value)
1148
- }
1149
- stats.set(head, hs)
1314
+ const key = `${head}\0${row.hash}`
1315
+ if (rawContent.has(`${path}\0${row.hash}`)) contentVersions.add(key)
1316
+ if (!versionPaths.has(key)) versionPaths.set(key, path)
1150
1317
  }
1151
1318
  }
1152
1319
  for (const [path, mergeEvents] of mergeIndex.resolutions) {
1153
1320
  if (!isSpecMd(path)) continue
1154
1321
  for (const { hash } of mergeEvents) for (const head of canonical(path, hash)) {
1155
1322
  const rows = versions.get(head) ?? []
1156
- const hs = stats.get(head) ?? new Map<string, DiffStat>()
1157
1323
  const version = commitVersions.get(hash)
1158
1324
  if (!version || rows.some((row) => row.hash === hash)) continue
1159
1325
  rows.push(version)
1160
- hs.set(hash, { additions: 0, deletions: 0, files: 1 })
1161
- mergeVersions.add(`${head}\0${hash}`)
1326
+ const key = `${head}\0${hash}`
1327
+ mergeVersions.add(key)
1328
+ if (!versionPaths.has(key)) versionPaths.set(key, path)
1162
1329
  versions.set(head, rows)
1163
- stats.set(head, hs)
1164
1330
  }
1165
1331
  }
1166
1332
  for (const rows of versions.values()) {
1167
1333
  rows.sort((a, b) => (commitOrder.get(a.hash) ?? Number.MAX_SAFE_INTEGER) - (commitOrder.get(b.hash) ?? Number.MAX_SAFE_INTEGER))
1168
1334
  }
1169
- return { versions, stats, mergeVersions }
1335
+ return { versions, contentVersions, versionPaths, mergeVersions }
1170
1336
  }
1171
1337
 
1172
- // pure lookups over a prebuilt index (no git). rowsFor drops pure-rename rows (0/0) so a move isn't a version.
1338
+ // Pure lookups over a prebuilt index. Blob identity decides whether a one-parent row is a content version;
1339
+ // numstat remains display data and must not let attributes erase a version window.
1173
1340
  export function rowsFor(idx: HistoryIndex, relPath: string): Version[] {
1174
1341
  const rows = idx.versions.get(relPath) ?? []
1175
- const st = idx.stats.get(relPath)
1176
- return rows.filter((v) => {
1177
- const s = st?.get(v.hash)
1178
- return s != null && (s.additions + s.deletions > 0 || idx.mergeVersions?.has(`${relPath}\0${v.hash}`))
1179
- })
1180
- }
1181
- export function statsFor(idx: HistoryIndex, relPath: string): Map<string, DiffStat> {
1182
- return idx.stats.get(relPath) ?? new Map()
1342
+ return rows.filter((v) => idx.contentVersions.has(`${relPath}\0${v.hash}`) || idx.mergeVersions?.has(`${relPath}\0${v.hash}`))
1183
1343
  }
1184
1344
 
1185
1345
  // per-commit numstat summed over a SET of paths in one `git log` walk. No `--follow` (it takes a single
@@ -1203,6 +1363,31 @@ export async function pathsStats(root: string, paths: string[]): Promise<Map<str
1203
1363
  return m
1204
1364
  }
1205
1365
 
1366
+ // History display stats are intentionally read only for the selected node: their text interpretation may
1367
+ // depend on working-tree attributes, so they cannot live in the shared immutable event ledger. One diff-tree
1368
+ // batch preserves every selected commit's historical path without turning a history page into N processes.
1369
+ export async function historyStats(root: string, idx: HistoryIndex, relPath: string): Promise<Map<string, DiffStat>> {
1370
+ const rows = rowsFor(idx, relPath)
1371
+ if (!rows.length) return new Map()
1372
+ const paths = new Map(rows.map((row) => [row.hash, idx.versionPaths.get(`${relPath}\0${row.hash}`) ?? relPath]))
1373
+ 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`)
1374
+ const stats = new Map<string, DiffStat>()
1375
+ for (const record of out.split(RS)) {
1376
+ const lines = record.replace(/^\n/, '').split('\n')
1377
+ const hash = lines.shift()?.trim() ?? ''
1378
+ const path = paths.get(hash)
1379
+ if (!path) continue
1380
+ const stat = { additions: 0, deletions: 0, files: 0 }
1381
+ for (const line of lines) {
1382
+ const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/)
1383
+ if (!match || parseStatPath(match[3]).to !== path) continue
1384
+ stat.files++; stat.additions += match[1] === '-' ? 0 : Number(match[1]); stat.deletions += match[2] === '-' ? 0 : Number(match[2])
1385
+ }
1386
+ stats.set(hash, stat)
1387
+ }
1388
+ return stats
1389
+ }
1390
+
1206
1391
  // the patch a spec.md got in one commit (vs parent); resolve its path AT that commit (reparents move it)
1207
1392
  // via the stable leaf dir `…/<id>/spec.md`, then `git show` that path. `-M` keeps a rename+edit's body. '' on error.
1208
1393
  export async function fileDiffAt(root: string, relPath: string, hash: string): Promise<string> {
@@ -1241,6 +1426,27 @@ export type DriftPathEvent = {
1241
1426
  export type DiffLineRange = [number, number]
1242
1427
  export type CombinedDiffOwnedChanges = { after: DiffLineRange[]; before: DiffLineRange[][]; parentPaths: string[] }
1243
1428
 
1429
+ function decodeGitCPath(value: string): string {
1430
+ if (!value.startsWith('"')) return value
1431
+ if (value.length < 2 || !value.endsWith('"')) throw new Error(`malformed Git C-quoted path '${value}'`)
1432
+ const bytes: number[] = []
1433
+ const plain = (part: string) => bytes.push(...Buffer.from(part, 'utf8'))
1434
+ for (let index = 1; index < value.length - 1;) {
1435
+ const point = value.codePointAt(index)!
1436
+ const char = String.fromCodePoint(point)
1437
+ index += char.length
1438
+ if (char !== '\\') { plain(char); continue }
1439
+ const escaped = value[index++]
1440
+ if (escaped === undefined) throw new Error(`malformed Git C-quoted path '${value}'`)
1441
+ const simple: Record<string, number> = { a: 7, b: 8, f: 12, n: 10, r: 13, t: 9, v: 11, '"': 34, '\\': 92 }
1442
+ if (escaped in simple) { bytes.push(simple[escaped]); continue }
1443
+ const octal = `${escaped}${value.slice(index, index + 2)}`
1444
+ if (!/^[0-7]{3}$/.test(octal)) throw new Error(`malformed Git C-quoted path '${value}'`)
1445
+ bytes.push(Number.parseInt(octal, 8)); index += 2
1446
+ }
1447
+ return Buffer.from(bytes).toString('utf8')
1448
+ }
1449
+
1244
1450
  // Dense combined diff prefixes have one column per parent. Ownership is a LINE fact, not a hunk fact:
1245
1451
  // all `+` means the result authored a line absent from every parent; all `-` means it deleted a line present
1246
1452
  // in every parent. Mixed columns inherit from at least one parent. Track every cursor through all displayed
@@ -1256,14 +1462,14 @@ export function combinedDiffOwnedChanges(patch: string): Map<string, CombinedDif
1256
1462
 
1257
1463
  for (const line of patch.split('\n')) {
1258
1464
  if (line.startsWith('diff --cc ')) {
1259
- path = line.slice('diff --cc '.length)
1465
+ path = decodeGitCPath(line.slice('diff --cc '.length))
1260
1466
  parents = 0
1261
1467
  parentPaths = []
1262
1468
  inHunk = false
1263
1469
  continue
1264
1470
  }
1265
1471
  if (!inHunk && path !== null && line.startsWith('--- ')) {
1266
- const raw = line.slice(4)
1472
+ const raw = decodeGitCPath(line.slice(4))
1267
1473
  parentPaths.push(raw === '/dev/null' ? path : raw.replace(/^a\//, ''))
1268
1474
  continue
1269
1475
  }
@@ -1324,7 +1530,7 @@ function parseMergeHistoryEvents(out: string): MergeHistoryEvents {
1324
1530
  const parentCount = raw[1].length
1325
1531
  const fields = line.split('\t')
1326
1532
  const status = fields[0].trim().split(/\s+/).at(-1) ?? ''
1327
- const paths = fields.slice(1)
1533
+ const paths = fields.slice(1).map(decodeGitCPath)
1328
1534
  if (status.length !== parentCount || paths.length !== parentCount + 1)
1329
1535
  throw new Error(`combined raw diff for ${hash} exposed ${status.length} statuses and ${paths.length} paths for ${parentCount} parents`)
1330
1536
  const resultPath = paths[parentCount]
@@ -1352,7 +1558,7 @@ async function mergeHistoryEvents(
1352
1558
  ): Promise<MergeHistoryEvents> {
1353
1559
  if (!useCache) return parseMergeHistoryEvents(await strictEventGit(['-C', root, '-c', 'core.quotePath=false',
1354
1560
  'log', '--merges', '--raw', '--patch', '--cc', '--combined-all-paths', '--unified=0', '--no-color', '--no-ext-diff', '-M', `--format=${RS}%H`, tip]))
1355
- return parseMergeHistoryEvents(await eventStream(root, tip,
1561
+ return parseMergeHistoryEvents(await textEventStream(root, tip,
1356
1562
  indexEventRequests(root, tip, order, reachable).merge, !transient, useCache))
1357
1563
  }
1358
1564
 
@@ -1361,51 +1567,43 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
1361
1567
  const fileEvents = new Map<string, DriftPathEvent[]>()
1362
1568
  const lineageEvents = new Map<string, DriftPathEvent[]>()
1363
1569
  const acks = new Map<string, Set<string>>(), selfAcks = new Map<string, Set<string>>(), specNodes = new Map<string, Set<string>>()
1364
- const ackCandidates = new Map<string, Set<string>>(), trees = new Map<string, string>()
1570
+ const ackCandidates = new Map<string, Set<string>>(), ackCheckpoints = new Map<string, boolean>()
1365
1571
  const idx: DriftIndex = { tip, ord, parents, fileEvents, lineageEvents, lineageKeys: (path) => [path], resolutionEvents: new Map(), acks, selfAcks, specNodes, anc: new Map() }
1366
- const [tipPathsOut, topology] = shared
1367
- ? [shared.allPathsOut, shared.topologyOut]
1368
- : await Promise.all([
1369
- strictEventGit(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--name-only', tip]),
1572
+ let currentPaths: Set<string>
1573
+ let topology: TopologyProjection
1574
+ if (shared) {
1575
+ currentPaths = shared.allPaths
1576
+ topology = shared.topology
1577
+ } else {
1578
+ const [tipPathsOut, topologyOut] = await Promise.all([
1579
+ strictEventGit(['-C', root, 'ls-tree', '-r', '-z', '--name-only', tip]),
1370
1580
  strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
1371
1581
  ])
1372
- const currentPaths = new Set(tipPathsOut.split('\0').filter(Boolean))
1373
- const topologyOrder = new Map<string, number>(), topologyParents = new Map<string, string[]>(), topologyReachable = new Set<string>()
1374
- let topologyPosition = 0
1375
- for (const line of topology.trim().split('\n')) {
1376
- const [hash, ...parentList] = line.split(' ')
1377
- if (hash) {
1378
- topologyOrder.set(hash, topologyPosition++)
1379
- topologyParents.set(hash, parentList)
1380
- topologyReachable.add(hash)
1381
- }
1382
- }
1383
- // Numstat is the immutable identity event: unlike a name-only stream it records both sides of a rename,
1384
- // allowing the same event-scoped projection used by spec history to follow forks and reject path reuse.
1385
- const out = shared?.driftOut ?? await eventStream(root, tip,
1386
- indexEventRequests(root, tip, topologyOrder, topologyReachable)['drift-numstat'], !transient, useCache)
1387
- if (!out) return idx
1582
+ currentPaths = new Set(tipPathsOut.split('\0').filter(Boolean))
1583
+ topology = topologyProjection(topologyOut)
1584
+ }
1585
+ const topologyOrder = topology.order
1586
+ const topologyParents = topology.parents
1587
+ const topologyReachable = topology.reachable
1588
+ for (const [hash, position] of topologyOrder) {
1589
+ ord.set(hash, position)
1590
+ parents.set(hash, topologyParents.get(hash) ?? [])
1591
+ }
1592
+ // Raw identity records path pairs and immutable object ids once; projection owns forks and reuse.
1593
+ const records = shared?.identityRecords ?? await identityRawEventStream(root, tip,
1594
+ indexEventRequests(root, tip, topologyOrder, topologyReachable)['identity-raw'], !transient, useCache)
1595
+ if (!records.length) return idx
1388
1596
  const rawFileEvents = new Map<string, DriftPathEvent[]>()
1389
1597
  const renamesByFrom = new Map<string, RenameProjectionEvent[]>()
1390
- let i = 0
1391
- for (const rec of out.split(RS)) {
1392
- const r = rec.replace(/^\n/, '')
1393
- if (!r) continue
1394
- const lines = r.split('\n')
1395
- const [hash, parentStr = '', tree = '', ackStr = ''] = lines[0].split(US)
1396
- if (!hash) continue
1397
- trees.set(hash, tree)
1398
- if (!ord.has(hash)) {
1399
- ord.set(hash, i++)
1400
- parents.set(hash, parentStr.split(' ').filter(Boolean))
1401
- }
1598
+ for (const record of records) {
1599
+ const { h: hash, a: ackStr, c: changes } = record
1402
1600
  const ackSet = new Set(ackStr.split(',').map((s) => s.trim()).filter(Boolean))
1403
- if (ackSet.size) ackCandidates.set(hash, ackSet)
1404
- const merge = parentStr.split(' ').filter(Boolean).length > 1
1405
- for (const line of lines.slice(1)) {
1406
- const stat = line.match(/^(-|\d+)\t(-|\d+)\t(.+)$/)
1407
- if (!stat) continue
1408
- const { from, to } = parseStatPath(stat[3])
1601
+ if (ackSet.size) {
1602
+ ackCandidates.set(hash, ackSet)
1603
+ ackCheckpoints.set(hash, changes.length === 0)
1604
+ }
1605
+ const merge = (parents.get(hash) ?? []).length > 1
1606
+ for (const [, from, to] of changes) {
1409
1607
  if (!merge) {
1410
1608
  const events = rawFileEvents.get(to) ?? []
1411
1609
  const event: DriftPathEvent = {
@@ -1430,10 +1628,11 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
1430
1628
  renames.push({ hash: rename.hash, to: rename.to })
1431
1629
  renamesByFrom.set(rename.from, renames)
1432
1630
  }
1433
- const canonical = canonicalPathProjector(renamesByFrom, topologyOrder, topologyParents)
1631
+ const canonical = canonicalPathProjector(renamesByFrom, topology)
1434
1632
  idx.lineageKeys = canonical
1435
- const addEvent = (path: string, event: DriftPathEvent, target: Map<string, DriftPathEvent[]>) => {
1436
- const keys = canonical(path, event.commit)
1633
+ // One projection per event serves both the lineage index and the current-path index; asking the
1634
+ // projector the same question twice per event only pays for it twice.
1635
+ const addEvent = (keys: string[], event: DriftPathEvent, target: Map<string, DriftPathEvent[]>) => {
1437
1636
  for (const key of keys) {
1438
1637
  const events = target.get(key) ?? []
1439
1638
  if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath)) events.push(event)
@@ -1441,8 +1640,9 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
1441
1640
  }
1442
1641
  }
1443
1642
  for (const [path, rawEvents] of rawFileEvents) for (const event of rawEvents) {
1444
- addEvent(path, event, lineageEvents)
1445
- for (const head of canonical(path, event.commit).filter((candidate) => currentPaths.has(candidate))) {
1643
+ const keys = canonical(path, event.commit)
1644
+ addEvent(keys, event, lineageEvents)
1645
+ for (const head of keys.filter((candidate) => currentPaths.has(candidate))) {
1446
1646
  const events = fileEvents.get(head) ?? []
1447
1647
  if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath)) events.push(event)
1448
1648
  fileEvents.set(head, events)
@@ -1456,7 +1656,8 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
1456
1656
  for (const [hash, nodes] of ackCandidates) {
1457
1657
  const parentList = parents.get(hash) ?? []
1458
1658
  const firstParent = parentList[0]
1459
- const checkpoint = parentList.length === 1 && !!firstParent && trees.get(hash) === trees.get(firstParent)
1659
+ // A non-merge commit with no raw identity entries has the same tree as its sole parent.
1660
+ const checkpoint = parentList.length === 1 && !!firstParent && ackCheckpoints.get(hash) === true
1460
1661
  ;(checkpoint ? acks : selfAcks).set(hash, nodes)
1461
1662
  }
1462
1663
  for (const [path, mergeEvents] of mergeIndex.resolutions) for (const mergeEvent of mergeEvents) {
@@ -1468,8 +1669,9 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
1468
1669
  historicalPath: path,
1469
1670
  parents: mergeParents.map((commit, index) => ({ commit, historicalPath: mergeEvent.parentPaths[index] })),
1470
1671
  }
1471
- addEvent(path, event, lineageEvents)
1472
- for (const head of canonical(path, mergeEvent.hash).filter((candidate) => currentPaths.has(candidate))) {
1672
+ const keys = canonical(path, mergeEvent.hash)
1673
+ addEvent(keys, event, lineageEvents)
1674
+ for (const head of keys.filter((candidate) => currentPaths.has(candidate))) {
1473
1675
  const events = idx.resolutionEvents!.get(head) ?? []
1474
1676
  if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath)) events.push(event)
1475
1677
  idx.resolutionEvents!.set(head, events)
@@ -1497,7 +1699,7 @@ export function driftIndex(root: string, tip = 'HEAD'): Promise<DriftIndex> {
1497
1699
  }
1498
1700
  const head = headOrEmpty(root) // filesystem HEAD, no subprocess — see historyIndex
1499
1701
  if (!head) return buildDriftIndex(root, 'HEAD', false, true)
1500
- const cacheKey = `${rootKey(root)}\0${head}\0${gitInterpretationKey(root)}`
1702
+ const cacheKey = indexCacheKey(root, head)
1501
1703
  touchRoot(driftRoots, driftIdxCache, root, cacheKey)
1502
1704
  const hit = driftIdxCache.get(cacheKey)
1503
1705
  if (hit) return hit
@@ -1507,32 +1709,43 @@ export function driftIndex(root: string, tip = 'HEAD'): Promise<DriftIndex> {
1507
1709
  return p
1508
1710
  }
1509
1711
 
1510
- function topologyProjection(out: string): { order: Map<string, number>; reachable: Set<string> } {
1511
- const order = new Map<string, number>(), reachable = new Set<string>()
1712
+ function topologyProjection(out: string): TopologyProjection {
1713
+ const order = new Map<string, number>(), parents = new Map<string, string[]>(), reachable = new Set<string>()
1512
1714
  let position = 0
1513
1715
  for (const line of out.trim().split('\n')) {
1514
- const hash = line.split(' ', 1)[0]
1716
+ const [hash, ...parentList] = line.split(' ')
1515
1717
  if (!hash) continue
1516
1718
  order.set(hash, position++)
1719
+ parents.set(hash, parentList)
1517
1720
  reachable.add(hash)
1518
1721
  }
1519
- return { order, reachable }
1722
+ return { order, parents, reachable }
1520
1723
  }
1521
1724
 
1725
+ function textStream(value: EventStreamOutput | undefined, kind: EventStreamKind): string {
1726
+ if (typeof value !== 'string') throw new Error(`history event stream '${kind}' did not render text`)
1727
+ return value
1728
+ }
1729
+ function identityRawStream(value: EventStreamOutput | undefined, kind: EventStreamKind): IdentityRawRecord[] {
1730
+ if (!Array.isArray(value) || value.some((record) => !('a' in record))) throw new Error(`history event stream '${kind}' did not render identity records`)
1731
+ return value as IdentityRawRecord[]
1732
+ }
1522
1733
  async function buildIndexPair(root: string, tip: string, transient: boolean, useCache = true): Promise<[HistoryIndex, DriftIndex]> {
1523
1734
  const [allPathsOut, topologyOut] = await Promise.all([
1524
1735
  strictEventGit(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--name-only', tip]),
1525
1736
  strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
1526
1737
  ])
1527
1738
  const topology = topologyProjection(topologyOut)
1739
+ const allPaths = new Set(allPathsOut.split('\0').filter(Boolean))
1740
+ const specPaths = new Set([...allPaths].filter((path) => path.startsWith('.spec/')))
1528
1741
  const requests = indexEventRequests(root, tip, topology.order, topology.reachable)
1529
1742
  const streams = await deriveEventStreams(root, tip, EVENT_STREAM_KINDS.map((kind) => requests[kind]), !transient, useCache)
1530
1743
  const shared: SharedIndexInputs = {
1531
- allPathsOut,
1532
- topologyOut,
1533
- historyOut: streams.get('numstat') ?? '',
1534
- driftOut: streams.get('drift-numstat') ?? '',
1535
- mergeIndex: parseMergeHistoryEvents(streams.get('merge') ?? ''),
1744
+ allPaths,
1745
+ specPaths,
1746
+ topology,
1747
+ identityRecords: identityRawStream(streams.get('identity-raw'), 'identity-raw'),
1748
+ mergeIndex: parseMergeHistoryEvents(textStream(streams.get('merge'), 'merge')),
1536
1749
  }
1537
1750
  streams.clear()
1538
1751
  return Promise.all([
@@ -1565,7 +1778,7 @@ export function sourceIndexes(root: string, tip = 'HEAD'): Promise<[HistoryIndex
1565
1778
  }
1566
1779
  const head = headOrEmpty(root)
1567
1780
  if (!head) return buildIndexPair(root, 'HEAD', false, true)
1568
- const cacheKey = `${rootKey(root)}\0${head}\0${gitInterpretationKey(root)}`
1781
+ const cacheKey = indexCacheKey(root, head)
1569
1782
  touchRoot(indexRoots, indexCache, root, cacheKey)
1570
1783
  touchRoot(driftRoots, driftIdxCache, root, cacheKey)
1571
1784
  const historyHit = indexCache.get(cacheKey), driftHit = driftIdxCache.get(cacheKey)
@@ -1735,6 +1948,15 @@ function parseNameStatus(out: string): { code: string; from: string; to: string
1735
1948
 
1736
1949
  export type ReviewDiffFile = { path: string; oldPath?: string; status: string; additions: number; deletions: number }
1737
1950
  const DIFF_STATUS: Record<string, string> = { A: 'added', M: 'modified', D: 'deleted', R: 'renamed', C: 'copied', T: 'type-changed' }
1951
+ function parseStatPath(token: string): { from: string; to: string } {
1952
+ const b = token.indexOf('{'), arrow = token.indexOf(' => ', b), close = token.indexOf('}', arrow)
1953
+ if (b >= 0 && arrow > b && close > arrow) {
1954
+ const pre = token.slice(0, b), post = token.slice(close + 1)
1955
+ return { from: `${pre}${token.slice(b + 1, arrow)}${post}`.replace(/\/\//g, '/'), to: `${pre}${token.slice(arrow + 4, close)}${post}`.replace(/\/\//g, '/') }
1956
+ }
1957
+ const arrowAt = token.indexOf(' => ')
1958
+ return arrowAt >= 0 ? { from: token.slice(0, arrowAt), to: token.slice(arrowAt + 4) } : { from: token, to: token }
1959
+ }
1738
1960
  export async function mergeBaseDiff(wtPath: string, mainRef = 'main'): Promise<ReviewDiffFile[]> {
1739
1961
  const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
1740
1962
  const base = (await run(['merge-base', mainRef, 'HEAD'])).trim()