spexcode 0.5.6 → 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 (32) 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 +380 -198
  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 +41 -7
  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/session-maintenance.ts +2 -1
  12. package/spec-cli/src/sessions.ts +898 -140
  13. package/spec-cli/src/specs.ts +3 -3
  14. package/spec-dashboard/dist/assets/{App-B72LuS5I.js → App-u2P7KdSg.js} +2 -2
  15. package/spec-dashboard/dist/assets/{Dashboard-C5X4Va3V.js → Dashboard-B8wp5_61.js} +3 -3
  16. package/spec-dashboard/dist/assets/{EvalsPage-BTvJIW8Q.js → EvalsPage-Bq1Tkb8y.js} +1 -1
  17. package/spec-dashboard/dist/assets/{IssuesPage-Bn94h_HQ.js → IssuesPage-BlkPSkmv.js} +1 -1
  18. package/spec-dashboard/dist/assets/{MobileApp-ClbtwZ1e.js → MobileApp-B1GxRZXK.js} +2 -2
  19. package/spec-dashboard/dist/assets/{Modal-6l_QtCKF.js → Modal-bAkq9IIT.js} +1 -1
  20. package/spec-dashboard/dist/assets/{PageScroll-B2kxcqJJ.js → PageScroll-px_rUZVJ.js} +1 -1
  21. package/spec-dashboard/dist/assets/{ProjectsPage-C8IPsMKV.js → ProjectsPage-8uGqYM12.js} +1 -1
  22. package/spec-dashboard/dist/assets/SessionInterface-CswwbewF.js +39 -0
  23. package/spec-dashboard/dist/assets/{SessionWindow-Dag_GiJB.js → SessionWindow-IspcLjFA.js} +1 -1
  24. package/spec-dashboard/dist/assets/{Settings-J3aibcXo.js → Settings-bpAbfnmS.js} +1 -1
  25. package/spec-dashboard/dist/assets/{Thread-Dg35J-Pu.js → Thread-BpL3N3kw.js} +11 -11
  26. package/spec-dashboard/dist/assets/{TimelineChat-f0UF9fXq.js → TimelineChat-Ckmb1Ez2.js} +1 -1
  27. package/spec-dashboard/dist/assets/{data-SNi0AmVT.js → data-CQFbQEMH.js} +1 -1
  28. package/spec-dashboard/dist/assets/index-CixSnz1H.css +1 -0
  29. package/spec-dashboard/dist/assets/{index-BUKLPN_4.js → index-Di1ch5dd.js} +5 -5
  30. package/spec-dashboard/dist/index.html +2 -2
  31. package/spec-dashboard/dist/assets/SessionInterface-B5jf7dW7.js +0 -39
  32. 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 = {
@@ -464,15 +485,26 @@ function decodeEventPayload(payload: Buffer, location: EventCacheLocation): Even
464
485
  continue
465
486
  }
466
487
  const kind = eventStreamKind(row.k)
467
- if (!exactKeys(row, ['h', 'k', 'r']) || !kind
468
- || typeof row.h !== 'string' || !isGitObjectIdForFormat(location.objectFormat, row.h)
469
- || typeof row.r !== 'string' || !row.r) return null
470
- const rawHash = row.r.split(US, 1)[0].split('\n', 1)[0].trim()
471
- 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
472
504
  let stream = state.streams.get(kind)
473
505
  if (!stream) { stream = new Map(); state.streams.set(kind, stream) }
474
506
  if (stream.has(row.h)) return null
475
- stream.set(row.h, { hash: row.h, raw: row.r })
507
+ stream.set(row.h, record)
476
508
  }
477
509
  return state
478
510
  }
@@ -606,22 +638,35 @@ function replaceEventLedger(path: string, payload: Buffer, additions: string[]):
606
638
  throw error
607
639
  }
608
640
  }
609
- function renderEventStream(state: EventCache, request: EventStreamRequest): string {
610
- const stream = state.streams.get(request.kind) ?? new Map<string, EventRecord>()
611
- return [...stream.values()].filter((record) => request.reachable.has(record.hash)).sort((a, b) => {
612
- if (request.kind === 'numstat') {
613
- const ad = Date.parse(a.raw.split(US)[1] ?? ''), bd = Date.parse(b.raw.split(US)[1] ?? '')
614
- if (Number.isFinite(ad) && Number.isFinite(bd) && ad !== bd) return bd - ad
615
- }
616
- return (request.order.get(a.hash) ?? Number.MAX_SAFE_INTEGER) - (request.order.get(b.hash) ?? Number.MAX_SAFE_INTEGER)
617
- }).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('')
618
661
  }
619
662
  function appendEventRecord(state: EventCache, kind: EventStreamKind, record: EventRecord, additions: string[]): void {
620
663
  let stream = state.streams.get(kind)
621
664
  if (!stream) { stream = new Map(); state.streams.set(kind, stream) }
622
665
  if (stream.has(record.hash)) return
623
666
  stream.set(record.hash, record)
624
- 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')
625
670
  }
626
671
  function appendEventTip(state: EventCache, kind: EventStreamKind, tip: string, additions: string[]): void {
627
672
  const tips = state.streamTips.get(kind) ?? []
@@ -631,6 +676,7 @@ function appendEventTip(state: EventCache, kind: EventStreamKind, tip: string, a
631
676
  additions.push(JSON.stringify({ k: `tip:${kind}`, tip }) + '\n')
632
677
  }
633
678
  function parseEventRecords(out: string, kind: EventStreamKind, location: EventCacheLocation): EventRecord[] {
679
+ if (kind === 'identity-raw') return parseIdentityRawEventRecords(out, location)
634
680
  const records: EventRecord[] = []
635
681
  for (const rec of out.split(RS)) {
636
682
  const raw = rec.replace(/^\n/, '')
@@ -649,17 +695,11 @@ function indexEventRequests(
649
695
  reachable: Set<string>,
650
696
  ): Record<EventStreamKind, EventStreamRequest> {
651
697
  return {
652
- numstat: {
653
- kind: 'numstat', order, reachable,
654
- argsFor: (base) => ['-C', root, '-c', 'core.quotePath=false',
655
- 'log', '--full-history', '--date-order', '--no-diff-merges', '-M', '--numstat',
656
- `--format=${RS}%H${US}%aI${US}%s${US}%b`, ...(base ? [`^${base}`] : []), tip, '--', '.spec'],
657
- },
658
- 'drift-numstat': {
659
- kind: 'drift-numstat', order, reachable,
698
+ 'identity-raw': {
699
+ kind: 'identity-raw', order, reachable,
660
700
  argsFor: (base) => ['-C', root, '-c', 'core.quotePath=false',
661
- 'log', '--full-history', '--date-order', '--no-diff-merges', '-M', '--numstat',
662
- `--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`,
663
703
  ...(base ? [`^${base}`] : []), tip],
664
704
  },
665
705
  merge: {
@@ -680,15 +720,23 @@ async function deriveEventStreams(
680
720
  requests: EventStreamRequest[],
681
721
  persist = true,
682
722
  cache = true,
683
- ): Promise<Map<EventStreamKind, string>> {
723
+ ): Promise<Map<EventStreamKind, EventStreamOutput>> {
684
724
  if (!cache) {
725
+ const location = eventCacheLocation(root)
685
726
  const outputs = await Promise.all(requests.map((request) => strictEventGit(request.argsFor(''))))
686
- 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
687
735
  }
688
736
  if (new Set(requests.map((request) => request.kind)).size !== requests.length)
689
737
  throw new Error('one event-ledger transaction cannot request the same stream twice')
690
738
 
691
- const run = async (location: EventCacheLocation): Promise<Map<EventStreamKind, string> | null> => {
739
+ const run = async (location: EventCacheLocation): Promise<Map<EventStreamKind, EventStreamOutput> | null> => {
692
740
  const snapshot = loadEventLedger(location)
693
741
  const missing = requests.filter((request) => !(snapshot.state.streamTips.get(request.kind) ?? []).includes(tip))
694
742
  const outputs = await Promise.all(missing.map((request) => {
@@ -726,8 +774,20 @@ async function eventStream(
726
774
  request: EventStreamRequest,
727
775
  persist = true,
728
776
  cache = true,
729
- ): Promise<string> {
730
- 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[]
731
791
  }
732
792
  export type GitTryFailure = 'exit' | 'spawn' | 'timeout'
733
793
  export async function gitTry(args: string[], options: { indexFile?: string } = {}): Promise<{ ok: boolean; stdout: string; stderr: string; failure?: GitTryFailure }> {
@@ -847,27 +907,76 @@ export type DiffStat = { additions: number; deletions: number; files: number }
847
907
 
848
908
  export type HistoryIndex = {
849
909
  versions: Map<string, Version[]> // headPath -> rows newest-first (incl. pure-rename rows)
850
- 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
851
912
  mergeVersions?: Set<string> // path\0hash pairs with an all-parent combined-diff line
852
913
  }
853
914
 
854
- // git numstat encodes a rename as `dir/{old => new}/file` (either side may be empty) or `old => new`;
855
- // recover both endpoints. Spec paths are brace/space-free here, so the textual parse is unambiguous.
856
- function parseStatPath(token: string): { from: string; to: string } {
857
- const b = token.indexOf('{')
858
- if (b >= 0) {
859
- const arrow = token.indexOf(' => ', b)
860
- const close = token.indexOf('}', arrow)
861
- if (arrow > b && close > arrow) {
862
- const pre = token.slice(0, b), post = token.slice(close + 1)
863
- const from = (pre + token.slice(b + 1, arrow) + post).replace(/\/\//g, '/')
864
- const to = (pre + token.slice(arrow + 4, close) + post).replace(/\/\//g, '/')
865
- return { from, to }
866
- }
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
867
931
  }
868
- const i = token.indexOf(' => ')
869
- if (i >= 0) return { from: token.slice(0, i), to: token.slice(i + 4) }
870
- 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 }
871
980
  }
872
981
 
873
982
  // Both bulk indices are pure functions of a checkout's HEAD, and they are read for SEVERAL roots at
@@ -885,11 +994,11 @@ const INDEX_ROOT_SLOTS = Math.max(4, Number(process.env.SPEXCODE_INDEX_CACHE_ROO
885
994
 
886
995
  function rootKey(root: string): string { return resolve(root) }
887
996
 
888
- function gitInterpretationKey(root: string): string { return eventCacheLocation(root).identity }
997
+ function indexCacheKey(root: string, head: string): string { return `${eventCacheLocation(root).path}\0${head}` }
889
998
 
890
- // HEAD plus Git's object-interpretation state identifies the immutable index contents; the root owns which
891
- // view is still useful. Moving a checkout or changing replace/shallow/graft state drops its old history,
892
- // 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.
893
1002
  function touchRoot(roots: Map<string, string>, cache: Map<string, Promise<unknown>>, root: string, cacheKey: string): void {
894
1003
  const key = rootKey(root)
895
1004
  const previous = roots.get(key)
@@ -953,7 +1062,7 @@ export function historyIndex(root: string, tip = 'HEAD'): Promise<HistoryIndex>
953
1062
  }
954
1063
  const head = headOrEmpty(root)
955
1064
  if (!head) return buildIndex(root, 'HEAD', false, true)
956
- const cacheKey = `${rootKey(root)}\0${head}\0${gitInterpretationKey(root)}`
1065
+ const cacheKey = indexCacheKey(root, head)
957
1066
  touchRoot(indexRoots, indexCache, root, cacheKey)
958
1067
  const hit = indexCache.get(cacheKey)
959
1068
  if (hit) return hit
@@ -973,39 +1082,82 @@ function headOrEmpty(root: string): string {
973
1082
  }
974
1083
  }
975
1084
 
976
- type RenameProjectionEvent = { hash: string; to: string }
977
- function canonicalPathProjector(
978
- renamesByFrom: Map<string, RenameProjectionEvent[]>,
979
- topologyOrd: Map<string, number>,
980
- topologyParents: Map<string, string[]>,
981
- ): (path: string, event: string) => string[] {
982
- const ancestryCache = new Map<string, Uint8Array>()
983
- const ancestryOf = (hash: string): Uint8Array | undefined => {
984
- 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)
985
1122
  if (hit) return hit
986
- const start = topologyOrd.get(hash)
987
- if (start === undefined) return undefined
988
- const bits = new Uint8Array((topologyOrd.size + 7) >> 3)
989
- bits[start >> 3] |= 1 << (start & 7)
990
- const stack = [hash]
991
- while (stack.length) for (const parent of topologyParents.get(stack.pop()!) ?? []) {
992
- 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)
993
1129
  if (position === undefined) continue
994
1130
  const mask = 1 << (position & 7)
995
1131
  if (bits[position >> 3] & mask) continue
996
1132
  bits[position >> 3] |= mask
997
- stack.push(parent)
1133
+ stack.push(next)
998
1134
  }
999
- ancestryCache.set(hash, bits)
1135
+ memo.set(start, bits)
1000
1136
  return bits
1001
1137
  }
1002
- const precedes = (older: string, newer: string): boolean => {
1003
- const position = topologyOrd.get(older), ancestry = ancestryOf(newer)
1004
- 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)
1005
1142
  throw new Error(`rename projection cannot place ${older} against ${newer} in the current topology`)
1006
- 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
1007
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)
1008
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]
1009
1161
  const pending = [path], resolved = new Set<string>(), seen = new Set<string>()
1010
1162
  while (pending.length) {
1011
1163
  const candidate = pending.pop()!
@@ -1035,8 +1187,7 @@ type SharedIndexInputs = {
1035
1187
  allPaths: Set<string>
1036
1188
  specPaths: Set<string>
1037
1189
  topology: TopologyProjection
1038
- historyOut: string
1039
- driftOut: string
1190
+ identityRecords: IdentityRawRecord[]
1040
1191
  mergeIndex: MergeHistoryEvents
1041
1192
  }
1042
1193
 
@@ -1048,12 +1199,12 @@ type TopologyProjection = {
1048
1199
 
1049
1200
  async function buildIndex(root: string, tip: string, transient: boolean, useCache = true, shared?: SharedIndexInputs): Promise<HistoryIndex> {
1050
1201
  const versions = new Map<string, Version[]>()
1051
- const stats = new Map<string, Map<string, DiffStat>>()
1202
+ const contentVersions = new Set<string>()
1203
+ const versionPaths = new Map<string, string>()
1052
1204
  const mergeVersions = new Set<string>()
1053
1205
  const commitVersions = new Map<string, Version>()
1054
1206
  const commitOrder = new Map<string, number>()
1055
1207
  const rawVersions = new Map<string, Version[]>()
1056
- const rawStats = new Map<string, Map<string, DiffStat>>()
1057
1208
  let currentPaths: Set<string>
1058
1209
  let topology: TopologyProjection
1059
1210
  if (shared) {
@@ -1071,36 +1222,23 @@ async function buildIndex(root: string, tip: string, transient: boolean, useCach
1071
1222
  const topologyOrd = topology.order
1072
1223
  const topologyParents = topology.parents
1073
1224
  const topologyReachable = topology.reachable
1074
- const out = shared?.historyOut ?? await eventStream(root, tip,
1075
- indexEventRequests(root, tip, topologyOrd, topologyReachable).numstat, !transient, useCache)
1076
- if (!out) return { versions, stats, mergeVersions }
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 }
1077
1228
  let commitPosition = 0
1078
- for (const rec of out.split(RS)) {
1079
- const r = rec.replace(/^\n/, '')
1080
- if (!r) continue
1081
- const parts = r.split(US)
1082
- const hash = parts[0], date = parts[1], reason = parts[2]
1083
- const rest = parts.slice(3).join(US) // body (had no US) followed by the numstat block
1084
- const sm = rest.match(/Session:\s*(\S+)/)
1085
- const version: Version = { hash, date, reason, session: sm ? sm[1] : null }
1086
- commitVersions.set(hash, version)
1087
- if (!commitOrder.has(hash)) commitOrder.set(hash, commitPosition++)
1088
- for (const line of rest.split('\n')) {
1089
- const m = line.match(/^(-|\d+)\t(-|\d+)\t(.+)$/)
1090
- if (!m) continue
1091
- const add = m[1] === '-' ? 0 : +m[1]
1092
- const del = m[2] === '-' ? 0 : +m[2]
1093
- 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
1094
1236
  if (!rawVersions.has(to)) rawVersions.set(to, [])
1095
1237
  rawVersions.get(to)!.push(version)
1096
- let hs = rawStats.get(to)
1097
- if (!hs) { hs = new Map(); rawStats.set(to, hs) }
1098
- const s = hs.get(hash) ?? { additions: 0, deletions: 0, files: 0 }
1099
- s.additions += add; s.deletions += del; s.files += 1
1100
- hs.set(hash, s)
1238
+ if (oldOid !== newOid) rawContent.add(`${to}\0${record.h}`)
1101
1239
  if (from !== to) {
1102
1240
  const renames = renamesByFrom.get(from) ?? []
1103
- renames.push({ hash, to })
1241
+ renames.push({ hash: record.h, to })
1104
1242
  renamesByFrom.set(from, renames)
1105
1243
  }
1106
1244
  }
@@ -1167,56 +1305,41 @@ async function buildIndex(root: string, tip: string, transient: boolean, useCach
1167
1305
  renames.push({ hash: rename.hash, to: rename.to })
1168
1306
  renamesByFrom.set(rename.from, renames)
1169
1307
  }
1170
- const canonical = canonicalPathProjector(renamesByFrom, topologyOrd, topologyParents)
1308
+ const canonical = canonicalPathProjector(renamesByFrom, topology)
1171
1309
  for (const [path, pathRows] of rawVersions) {
1172
1310
  for (const row of pathRows) for (const head of canonical(path, row.hash).filter((candidate) => currentPaths.has(candidate))) {
1173
1311
  const rows = versions.get(head) ?? []
1174
1312
  if (!rows.some((existing) => existing.hash === row.hash)) rows.push(row)
1175
1313
  versions.set(head, rows)
1176
- const hs = stats.get(head) ?? new Map<string, DiffStat>()
1177
- const value = rawStats.get(path)?.get(row.hash)
1178
- if (value) {
1179
- const existing = hs.get(row.hash)
1180
- hs.set(row.hash, existing ? {
1181
- additions: existing.additions + value.additions,
1182
- deletions: existing.deletions + value.deletions,
1183
- files: existing.files + value.files,
1184
- } : value)
1185
- }
1186
- 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)
1187
1317
  }
1188
1318
  }
1189
1319
  for (const [path, mergeEvents] of mergeIndex.resolutions) {
1190
1320
  if (!isSpecMd(path)) continue
1191
1321
  for (const { hash } of mergeEvents) for (const head of canonical(path, hash)) {
1192
1322
  const rows = versions.get(head) ?? []
1193
- const hs = stats.get(head) ?? new Map<string, DiffStat>()
1194
1323
  const version = commitVersions.get(hash)
1195
1324
  if (!version || rows.some((row) => row.hash === hash)) continue
1196
1325
  rows.push(version)
1197
- hs.set(hash, { additions: 0, deletions: 0, files: 1 })
1198
- mergeVersions.add(`${head}\0${hash}`)
1326
+ const key = `${head}\0${hash}`
1327
+ mergeVersions.add(key)
1328
+ if (!versionPaths.has(key)) versionPaths.set(key, path)
1199
1329
  versions.set(head, rows)
1200
- stats.set(head, hs)
1201
1330
  }
1202
1331
  }
1203
1332
  for (const rows of versions.values()) {
1204
1333
  rows.sort((a, b) => (commitOrder.get(a.hash) ?? Number.MAX_SAFE_INTEGER) - (commitOrder.get(b.hash) ?? Number.MAX_SAFE_INTEGER))
1205
1334
  }
1206
- return { versions, stats, mergeVersions }
1335
+ return { versions, contentVersions, versionPaths, mergeVersions }
1207
1336
  }
1208
1337
 
1209
- // 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.
1210
1340
  export function rowsFor(idx: HistoryIndex, relPath: string): Version[] {
1211
1341
  const rows = idx.versions.get(relPath) ?? []
1212
- const st = idx.stats.get(relPath)
1213
- return rows.filter((v) => {
1214
- const s = st?.get(v.hash)
1215
- return s != null && (s.additions + s.deletions > 0 || idx.mergeVersions?.has(`${relPath}\0${v.hash}`))
1216
- })
1217
- }
1218
- export function statsFor(idx: HistoryIndex, relPath: string): Map<string, DiffStat> {
1219
- 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}`))
1220
1343
  }
1221
1344
 
1222
1345
  // per-commit numstat summed over a SET of paths in one `git log` walk. No `--follow` (it takes a single
@@ -1240,6 +1363,31 @@ export async function pathsStats(root: string, paths: string[]): Promise<Map<str
1240
1363
  return m
1241
1364
  }
1242
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
+
1243
1391
  // the patch a spec.md got in one commit (vs parent); resolve its path AT that commit (reparents move it)
1244
1392
  // via the stable leaf dir `…/<id>/spec.md`, then `git show` that path. `-M` keeps a rename+edit's body. '' on error.
1245
1393
  export async function fileDiffAt(root: string, relPath: string, hash: string): Promise<string> {
@@ -1278,6 +1426,27 @@ export type DriftPathEvent = {
1278
1426
  export type DiffLineRange = [number, number]
1279
1427
  export type CombinedDiffOwnedChanges = { after: DiffLineRange[]; before: DiffLineRange[][]; parentPaths: string[] }
1280
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
+
1281
1450
  // Dense combined diff prefixes have one column per parent. Ownership is a LINE fact, not a hunk fact:
1282
1451
  // all `+` means the result authored a line absent from every parent; all `-` means it deleted a line present
1283
1452
  // in every parent. Mixed columns inherit from at least one parent. Track every cursor through all displayed
@@ -1293,14 +1462,14 @@ export function combinedDiffOwnedChanges(patch: string): Map<string, CombinedDif
1293
1462
 
1294
1463
  for (const line of patch.split('\n')) {
1295
1464
  if (line.startsWith('diff --cc ')) {
1296
- path = line.slice('diff --cc '.length)
1465
+ path = decodeGitCPath(line.slice('diff --cc '.length))
1297
1466
  parents = 0
1298
1467
  parentPaths = []
1299
1468
  inHunk = false
1300
1469
  continue
1301
1470
  }
1302
1471
  if (!inHunk && path !== null && line.startsWith('--- ')) {
1303
- const raw = line.slice(4)
1472
+ const raw = decodeGitCPath(line.slice(4))
1304
1473
  parentPaths.push(raw === '/dev/null' ? path : raw.replace(/^a\//, ''))
1305
1474
  continue
1306
1475
  }
@@ -1361,7 +1530,7 @@ function parseMergeHistoryEvents(out: string): MergeHistoryEvents {
1361
1530
  const parentCount = raw[1].length
1362
1531
  const fields = line.split('\t')
1363
1532
  const status = fields[0].trim().split(/\s+/).at(-1) ?? ''
1364
- const paths = fields.slice(1)
1533
+ const paths = fields.slice(1).map(decodeGitCPath)
1365
1534
  if (status.length !== parentCount || paths.length !== parentCount + 1)
1366
1535
  throw new Error(`combined raw diff for ${hash} exposed ${status.length} statuses and ${paths.length} paths for ${parentCount} parents`)
1367
1536
  const resultPath = paths[parentCount]
@@ -1389,7 +1558,7 @@ async function mergeHistoryEvents(
1389
1558
  ): Promise<MergeHistoryEvents> {
1390
1559
  if (!useCache) return parseMergeHistoryEvents(await strictEventGit(['-C', root, '-c', 'core.quotePath=false',
1391
1560
  'log', '--merges', '--raw', '--patch', '--cc', '--combined-all-paths', '--unified=0', '--no-color', '--no-ext-diff', '-M', `--format=${RS}%H`, tip]))
1392
- return parseMergeHistoryEvents(await eventStream(root, tip,
1561
+ return parseMergeHistoryEvents(await textEventStream(root, tip,
1393
1562
  indexEventRequests(root, tip, order, reachable).merge, !transient, useCache))
1394
1563
  }
1395
1564
 
@@ -1398,7 +1567,7 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
1398
1567
  const fileEvents = new Map<string, DriftPathEvent[]>()
1399
1568
  const lineageEvents = new Map<string, DriftPathEvent[]>()
1400
1569
  const acks = new Map<string, Set<string>>(), selfAcks = new Map<string, Set<string>>(), specNodes = new Map<string, Set<string>>()
1401
- 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>()
1402
1571
  const idx: DriftIndex = { tip, ord, parents, fileEvents, lineageEvents, lineageKeys: (path) => [path], resolutionEvents: new Map(), acks, selfAcks, specNodes, anc: new Map() }
1403
1572
  let currentPaths: Set<string>
1404
1573
  let topology: TopologyProjection
@@ -1416,32 +1585,25 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
1416
1585
  const topologyOrder = topology.order
1417
1586
  const topologyParents = topology.parents
1418
1587
  const topologyReachable = topology.reachable
1419
- // Numstat is the immutable identity event: unlike a name-only stream it records both sides of a rename,
1420
- // allowing the same event-scoped projection used by spec history to follow forks and reject path reuse.
1421
- const out = shared?.driftOut ?? await eventStream(root, tip,
1422
- indexEventRequests(root, tip, topologyOrder, topologyReachable)['drift-numstat'], !transient, useCache)
1423
- if (!out) return idx
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
1424
1596
  const rawFileEvents = new Map<string, DriftPathEvent[]>()
1425
1597
  const renamesByFrom = new Map<string, RenameProjectionEvent[]>()
1426
- let i = 0
1427
- for (const rec of out.split(RS)) {
1428
- const r = rec.replace(/^\n/, '')
1429
- if (!r) continue
1430
- const lines = r.split('\n')
1431
- const [hash, parentStr = '', tree = '', ackStr = ''] = lines[0].split(US)
1432
- if (!hash) continue
1433
- trees.set(hash, tree)
1434
- if (!ord.has(hash)) {
1435
- ord.set(hash, i++)
1436
- parents.set(hash, parentStr.split(' ').filter(Boolean))
1437
- }
1598
+ for (const record of records) {
1599
+ const { h: hash, a: ackStr, c: changes } = record
1438
1600
  const ackSet = new Set(ackStr.split(',').map((s) => s.trim()).filter(Boolean))
1439
- if (ackSet.size) ackCandidates.set(hash, ackSet)
1440
- const merge = parentStr.split(' ').filter(Boolean).length > 1
1441
- for (const line of lines.slice(1)) {
1442
- const stat = line.match(/^(-|\d+)\t(-|\d+)\t(.+)$/)
1443
- if (!stat) continue
1444
- 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) {
1445
1607
  if (!merge) {
1446
1608
  const events = rawFileEvents.get(to) ?? []
1447
1609
  const event: DriftPathEvent = {
@@ -1466,10 +1628,11 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
1466
1628
  renames.push({ hash: rename.hash, to: rename.to })
1467
1629
  renamesByFrom.set(rename.from, renames)
1468
1630
  }
1469
- const canonical = canonicalPathProjector(renamesByFrom, topologyOrder, topologyParents)
1631
+ const canonical = canonicalPathProjector(renamesByFrom, topology)
1470
1632
  idx.lineageKeys = canonical
1471
- const addEvent = (path: string, event: DriftPathEvent, target: Map<string, DriftPathEvent[]>) => {
1472
- 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[]>) => {
1473
1636
  for (const key of keys) {
1474
1637
  const events = target.get(key) ?? []
1475
1638
  if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath)) events.push(event)
@@ -1477,8 +1640,9 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
1477
1640
  }
1478
1641
  }
1479
1642
  for (const [path, rawEvents] of rawFileEvents) for (const event of rawEvents) {
1480
- addEvent(path, event, lineageEvents)
1481
- 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))) {
1482
1646
  const events = fileEvents.get(head) ?? []
1483
1647
  if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath)) events.push(event)
1484
1648
  fileEvents.set(head, events)
@@ -1492,7 +1656,8 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
1492
1656
  for (const [hash, nodes] of ackCandidates) {
1493
1657
  const parentList = parents.get(hash) ?? []
1494
1658
  const firstParent = parentList[0]
1495
- 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
1496
1661
  ;(checkpoint ? acks : selfAcks).set(hash, nodes)
1497
1662
  }
1498
1663
  for (const [path, mergeEvents] of mergeIndex.resolutions) for (const mergeEvent of mergeEvents) {
@@ -1504,8 +1669,9 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
1504
1669
  historicalPath: path,
1505
1670
  parents: mergeParents.map((commit, index) => ({ commit, historicalPath: mergeEvent.parentPaths[index] })),
1506
1671
  }
1507
- addEvent(path, event, lineageEvents)
1508
- 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))) {
1509
1675
  const events = idx.resolutionEvents!.get(head) ?? []
1510
1676
  if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath)) events.push(event)
1511
1677
  idx.resolutionEvents!.set(head, events)
@@ -1533,7 +1699,7 @@ export function driftIndex(root: string, tip = 'HEAD'): Promise<DriftIndex> {
1533
1699
  }
1534
1700
  const head = headOrEmpty(root) // filesystem HEAD, no subprocess — see historyIndex
1535
1701
  if (!head) return buildDriftIndex(root, 'HEAD', false, true)
1536
- const cacheKey = `${rootKey(root)}\0${head}\0${gitInterpretationKey(root)}`
1702
+ const cacheKey = indexCacheKey(root, head)
1537
1703
  touchRoot(driftRoots, driftIdxCache, root, cacheKey)
1538
1704
  const hit = driftIdxCache.get(cacheKey)
1539
1705
  if (hit) return hit
@@ -1556,6 +1722,14 @@ function topologyProjection(out: string): TopologyProjection {
1556
1722
  return { order, parents, reachable }
1557
1723
  }
1558
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
+ }
1559
1733
  async function buildIndexPair(root: string, tip: string, transient: boolean, useCache = true): Promise<[HistoryIndex, DriftIndex]> {
1560
1734
  const [allPathsOut, topologyOut] = await Promise.all([
1561
1735
  strictEventGit(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--name-only', tip]),
@@ -1570,9 +1744,8 @@ async function buildIndexPair(root: string, tip: string, transient: boolean, use
1570
1744
  allPaths,
1571
1745
  specPaths,
1572
1746
  topology,
1573
- historyOut: streams.get('numstat') ?? '',
1574
- driftOut: streams.get('drift-numstat') ?? '',
1575
- mergeIndex: parseMergeHistoryEvents(streams.get('merge') ?? ''),
1747
+ identityRecords: identityRawStream(streams.get('identity-raw'), 'identity-raw'),
1748
+ mergeIndex: parseMergeHistoryEvents(textStream(streams.get('merge'), 'merge')),
1576
1749
  }
1577
1750
  streams.clear()
1578
1751
  return Promise.all([
@@ -1605,7 +1778,7 @@ export function sourceIndexes(root: string, tip = 'HEAD'): Promise<[HistoryIndex
1605
1778
  }
1606
1779
  const head = headOrEmpty(root)
1607
1780
  if (!head) return buildIndexPair(root, 'HEAD', false, true)
1608
- const cacheKey = `${rootKey(root)}\0${head}\0${gitInterpretationKey(root)}`
1781
+ const cacheKey = indexCacheKey(root, head)
1609
1782
  touchRoot(indexRoots, indexCache, root, cacheKey)
1610
1783
  touchRoot(driftRoots, driftIdxCache, root, cacheKey)
1611
1784
  const historyHit = indexCache.get(cacheKey), driftHit = driftIdxCache.get(cacheKey)
@@ -1775,6 +1948,15 @@ function parseNameStatus(out: string): { code: string; from: string; to: string
1775
1948
 
1776
1949
  export type ReviewDiffFile = { path: string; oldPath?: string; status: string; additions: number; deletions: number }
1777
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
+ }
1778
1960
  export async function mergeBaseDiff(wtPath: string, mainRef = 'main'): Promise<ReviewDiffFile[]> {
1779
1961
  const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
1780
1962
  const base = (await run(['merge-base', mainRef, 'HEAD'])).trim()