spexcode 0.5.1 → 0.5.3

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 (155) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +25 -1
  4. package/spec-cli/hooks/dispatch.sh +24 -1
  5. package/spec-cli/src/anchors.ts +192 -58
  6. package/spec-cli/src/attach.ts +11 -8
  7. package/spec-cli/src/claude-headless.ts +116 -66
  8. package/spec-cli/src/cli.ts +221 -72
  9. package/spec-cli/src/client.ts +143 -5
  10. package/spec-cli/src/gateway-hub.ts +2 -6
  11. package/spec-cli/src/gateway.ts +96 -11
  12. package/spec-cli/src/git.ts +1395 -96
  13. package/spec-cli/src/graph.ts +21 -17
  14. package/spec-cli/src/graphCache.ts +405 -61
  15. package/spec-cli/src/graphStream.ts +581 -182
  16. package/spec-cli/src/guide.ts +13 -3
  17. package/spec-cli/src/harness.ts +965 -70
  18. package/spec-cli/src/headless-controller.ts +63 -0
  19. package/spec-cli/src/help.ts +120 -64
  20. package/spec-cli/src/host-resources.ts +706 -0
  21. package/spec-cli/src/host.ts +1 -0
  22. package/spec-cli/src/index.ts +95 -21
  23. package/spec-cli/src/init.ts +47 -8
  24. package/spec-cli/src/issues.ts +36 -3
  25. package/spec-cli/src/layout.ts +193 -37
  26. package/spec-cli/src/lint.ts +152 -27
  27. package/spec-cli/src/localIssues.ts +5 -1
  28. package/spec-cli/src/maintenance-wrapper.ts +276 -0
  29. package/spec-cli/src/materialize.ts +6 -7
  30. package/spec-cli/src/opencode-headless.ts +117 -7
  31. package/spec-cli/src/opencode.ts +8 -4
  32. package/spec-cli/src/pi-headless.ts +18 -60
  33. package/spec-cli/src/process-identity.ts +49 -0
  34. package/spec-cli/src/project-store.ts +17 -0
  35. package/spec-cli/src/pty-bridge.ts +37 -7
  36. package/spec-cli/src/pty-helper.mjs +16 -6
  37. package/spec-cli/src/pty-native-helper.mjs +22 -0
  38. package/spec-cli/src/reviews.ts +17 -13
  39. package/spec-cli/src/runtime-ownership.ts +99 -0
  40. package/spec-cli/src/session-maintenance.ts +750 -0
  41. package/spec-cli/src/session-timeline.ts +29 -23
  42. package/spec-cli/src/sessions.ts +1262 -200
  43. package/spec-cli/src/sh.ts +3 -0
  44. package/spec-cli/src/source-files.ts +27 -2
  45. package/spec-cli/src/specs.ts +58 -25
  46. package/spec-cli/src/supervise.ts +31 -11
  47. package/spec-cli/src/uninstall.ts +7 -6
  48. package/spec-cli/templates/hooks/commit-msg +14 -0
  49. package/spec-cli/templates/hooks/pre-commit +26 -6
  50. package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
  51. package/spec-cli/templates/hooks/reference-transaction +105 -0
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
  54. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
  55. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
  56. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
  57. package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
  58. package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
  59. package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
  60. package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
  61. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  62. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  63. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  64. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  65. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  66. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  67. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  68. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  69. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  70. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  71. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  72. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  73. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  74. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  75. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  76. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  77. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  78. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  79. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  80. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  81. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  82. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  83. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  84. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  85. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  86. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  87. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  88. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  89. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  90. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  91. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  92. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  93. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  94. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  95. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  96. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  97. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  98. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  99. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  100. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  101. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  102. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  103. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  104. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  105. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  106. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  107. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  108. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  109. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  110. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  111. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  112. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  113. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  114. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  115. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  116. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  117. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  118. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  119. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  120. package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
  121. package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
  122. package/spec-dashboard/dist/assets/{PageScroll-B_dKCuXx.js → PageScroll-CAY4S4g4.js} +1 -1
  123. package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
  124. package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
  125. package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
  126. package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
  127. package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
  128. package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
  129. package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
  130. package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
  131. package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
  132. package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
  133. package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
  134. package/spec-dashboard/dist/index.html +5 -4
  135. package/spec-dashboard/src/reviewFilters.js +46 -8
  136. package/spec-dashboard/src/session.js +64 -9
  137. package/spec-eval/src/cli.ts +144 -32
  138. package/spec-eval/src/evaltab.ts +16 -9
  139. package/spec-eval/src/freshness.ts +336 -54
  140. package/spec-eval/src/scenariofresh.ts +51 -1
  141. package/spec-eval/src/scenarios.ts +324 -25
  142. package/spec-eval/src/sessioneval.ts +969 -173
  143. package/spec-cli/src/message-stream.ts +0 -147
  144. package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +0 -27
  145. package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +0 -2
  146. package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +0 -1
  147. package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +0 -2
  148. package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +0 -1
  149. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +0 -1
  150. package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +0 -39
  151. package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +0 -13
  152. package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +0 -1
  153. package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +0 -1
  154. package/spec-dashboard/dist/assets/index-BKaTHjmU.js +0 -41
  155. package/spec-dashboard/dist/assets/index-DcnCaBAC.css +0 -1
@@ -1,17 +1,110 @@
1
- import { execFileSync, execFile } from 'node:child_process'
2
- import { promisify } from 'node:util'
3
- import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
1
+ import { execFileSync, execFile, spawn } from 'node:child_process'
2
+ import { AsyncLocalStorage } from 'node:async_hooks'
3
+ import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdirSync, rmSync, renameSync, openSync, closeSync } from 'node:fs'
4
4
  import { join, isAbsolute, resolve } from 'node:path'
5
+ import { createHash, randomBytes } from 'node:crypto'
6
+ import { projectRuntimeRoot } from './project-store.js'
5
7
 
6
8
  const US = '\x1f', RS = '\x1e'
7
9
 
8
- // @@@ bounded git children - a git child that never exits (wedged fs, a hijacked PATH git, a dead network
9
- // mount) must not pin its awaiter forever: [[graph-cache]]'s settle guarantee starts at this seam. Every
10
- // shared helper passes a generous timeout (an order of magnitude above the slowest legitimate full-history
11
- // walk) with SIGKILL same pattern sessions.ts's tmux/ps probes already use so a hung child dies and the
12
- // call fails like any other git failure instead of hanging its caller's promise. The kill is warned loudly:
13
- // gitA maps failure to '', which would otherwise hide the pathology as an innocently-empty result.
10
+ // @@@ bounded graph git children - a git child that never exits (wedged fs, a hijacked PATH git, a dead
11
+ // network mount) must not pin its awaiter forever: [[graph-cache]]'s settle guarantee starts at this seam.
12
+ // Every shared helper passes a generous timeout with SIGKILL. A graph build additionally carries one fixed
13
+ // permit pool through AsyncLocalStorage, so corpus-wide Promise.all fanout queues here before spawn rather
14
+ // than materializing one process per worktree/eval. Calls outside that build context remain unconstrained.
14
15
  const GIT_TIMEOUT_MS = Number(process.env.SPEXCODE_GIT_TIMEOUT_MS || 120000)
16
+ export const BOARD_GIT_CONCURRENCY = 4
17
+ type GitPermitPool = { acquire: (signal: AbortSignal) => Promise<() => void> }
18
+ type GitBuildContext = { signal: AbortSignal; permits: GitPermitPool }
19
+ const gitBuild = new AsyncLocalStorage<GitBuildContext>()
20
+
21
+ // @@@ a build's git children run on a bounded pack footprint - git sizes its mmap window, its mmap ceiling
22
+ // and its delta-base cache for a process that owns the machine. A graph build's heaviest walks each mapped
23
+ // well over a hundred megabytes of pack for output measured in kilobytes, and those children land inside the
24
+ // build's own memory platform. Capping the three makes the same walks run in a fraction of the resident set
25
+ // for a fraction of a second more. This is a RESOURCE boundary, never a semantic one — output, exit status
26
+ // and stderr are byte-identical under every setting — and it is scoped to the build context, so ordinary
27
+ // CLI/API git keeps git's defaults. It is also content-blind: the transport knows pack sizing, never which
28
+ // walk a caller is doing.
29
+ const BUILD_GIT_LIMITS = [
30
+ '-c', 'core.packedGitWindowSize=1m',
31
+ '-c', 'core.packedGitLimit=32m',
32
+ '-c', 'core.deltaBaseCacheLimit=1m',
33
+ ]
34
+ const withBuildLimits = (args: string[]): string[] => (gitBuild.getStore() ? [...BUILD_GIT_LIMITS, ...args] : args)
35
+
36
+ export function gitAbortError(): Error {
37
+ return Object.assign(new Error('The operation was aborted'), { name: 'AbortError', code: 'ABORT_ERR' })
38
+ }
39
+
40
+ function gitPermitPool(limit: number): GitPermitPool {
41
+ type Waiter = {
42
+ signal: AbortSignal
43
+ resolve: (release: () => void) => void
44
+ reject: (error: Error) => void
45
+ onAbort: () => void
46
+ }
47
+ let active = 0
48
+ const waiting: Waiter[] = []
49
+
50
+ const releasePermit = (): (() => void) => {
51
+ let released = false
52
+ return () => {
53
+ if (released) return
54
+ released = true
55
+ active--
56
+ drain()
57
+ }
58
+ }
59
+ const drain = () => {
60
+ while (active < limit && waiting.length) {
61
+ const waiter = waiting.shift()!
62
+ waiter.signal.removeEventListener('abort', waiter.onAbort)
63
+ if (waiter.signal.aborted) {
64
+ waiter.reject(gitAbortError())
65
+ continue
66
+ }
67
+ active++
68
+ waiter.resolve(releasePermit())
69
+ }
70
+ }
71
+
72
+ return {
73
+ acquire(signal) {
74
+ if (signal.aborted) return Promise.reject(gitAbortError())
75
+ if (active < limit) {
76
+ active++
77
+ return Promise.resolve(releasePermit())
78
+ }
79
+ return new Promise((resolve, reject) => {
80
+ const waiter: Waiter = {
81
+ signal,
82
+ resolve,
83
+ reject,
84
+ onAbort: () => {
85
+ const index = waiting.indexOf(waiter)
86
+ if (index >= 0) waiting.splice(index, 1)
87
+ reject(gitAbortError())
88
+ },
89
+ }
90
+ waiting.push(waiter)
91
+ signal.addEventListener('abort', waiter.onAbort, { once: true })
92
+ })
93
+ },
94
+ }
95
+ }
96
+
97
+ // A board build owns one abort signal. Async git calls inherit it without every graph layer growing a
98
+ // cancellation parameter; aborting the build therefore reaches every active child and queued permit below
99
+ // the graph seam. The pool is created here, so ordinary CLI/API git calls never share or wait on it.
100
+ export function withGitAbortSignal<T>(signal: AbortSignal, run: () => Promise<T>): Promise<T> {
101
+ return gitBuild.run({ signal, permits: gitPermitPool(BOARD_GIT_CONCURRENCY) }, run)
102
+ }
103
+
104
+ const inheritedContext = (): GitBuildContext | undefined => gitBuild.getStore()
105
+ export function currentGitBuildAbortSignal(): AbortSignal | undefined {
106
+ return inheritedContext()?.signal
107
+ }
15
108
  function warnIfTimedOut(e: any, args: string[]): void {
16
109
  if (e?.signal === 'SIGKILL') console.warn(`spec-cli: git ${args.slice(0, 6).join(' ')}… killed after ${GIT_TIMEOUT_MS}ms — child never exited`)
17
110
  }
@@ -21,29 +114,616 @@ export function git(args: string[]): string {
21
114
  const env = { ...process.env }
22
115
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
23
116
  try {
24
- return execFileSync('git', args, { encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'], timeout: GIT_TIMEOUT_MS, killSignal: 'SIGKILL' })
117
+ return execFileSync('git', withBuildLimits(args), { encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'], timeout: GIT_TIMEOUT_MS, killSignal: 'SIGKILL' })
118
+ } catch (e: any) { warnIfTimedOut(e, args); throw e }
119
+ }
120
+
121
+ function gitBuffer(args: string[], input?: string): Buffer {
122
+ const env = { ...process.env }
123
+ delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
124
+ try {
125
+ return execFileSync('git', withBuildLimits(args), {
126
+ input,
127
+ env,
128
+ stdio: ['pipe', 'pipe', 'pipe'],
129
+ timeout: GIT_TIMEOUT_MS,
130
+ killSignal: 'SIGKILL',
131
+ maxBuffer: 1 << 27,
132
+ })
25
133
  } catch (e: any) { warnIfTimedOut(e, args); throw e }
26
134
  }
27
135
 
28
- const pexecFile = promisify(execFile)
136
+ export type GitObjectFormat = 'sha1' | 'sha256'
137
+ const gitObjectFormatMemo = new Map<string, GitObjectFormat>()
138
+ export function gitObjectFormat(root: string): GitObjectFormat {
139
+ const key = rootKey(root)
140
+ const hit = gitObjectFormatMemo.get(key)
141
+ if (hit) return hit
142
+ const format = git(['-C', root, 'rev-parse', '--show-object-format=storage']).trim()
143
+ if (format !== 'sha1' && format !== 'sha256') throw new Error(`unsupported Git object format '${format || 'empty'}' at ${root}`)
144
+ gitObjectFormatMemo.set(key, format)
145
+ return format
146
+ }
147
+ export function isGitObjectId(root: string, value: string): boolean {
148
+ const length = gitObjectFormat(root) === 'sha256' ? 64 : 40
149
+ return value.length === length && /^[0-9a-f]+$/.test(value)
150
+ }
151
+
152
+ // Batch immutable object lookups used by the shared anchor/index path. Git accepts revision:path queries on
153
+ // batch-check, so dozens of rev-parse + cat-file children collapse into two bounded processes without
154
+ // changing the returned bytes or object ids.
155
+ export function batchRevisionOids(root: string, revisions: string[]): (string | null)[] {
156
+ if (!revisions.length) return []
157
+ const out = gitBuffer(['-C', root, 'cat-file', '--batch-check=%(objectname)'], revisions.join('\n') + '\n').toString('utf8')
158
+ const lines = out.split('\n')
159
+ if (lines.length - 1 !== revisions.length) throw new Error(`git cat-file --batch-check returned ${lines.length - 1} rows for ${revisions.length} revisions`)
160
+ return revisions.map((revision, index) => {
161
+ const value = lines[index].trim()
162
+ if (isGitObjectId(root, value)) return value
163
+ if (value === `${revision} missing`) return null
164
+ throw new Error(`git cat-file --batch-check returned '${value}' for ${revision}`)
165
+ })
166
+ }
167
+ export function batchBlobTexts(root: string, oids: string[]): Map<string, string> {
168
+ const unique = [...new Set(oids.filter(Boolean))]
169
+ const files = new Map<string, string>()
170
+ if (!unique.length) return files
171
+ for (const oid of unique) if (!isGitObjectId(root, oid)) throw new Error(`invalid object id '${oid}'`)
172
+ const out = gitBuffer(['-C', root, 'cat-file', '--batch'], unique.join('\n') + '\n')
173
+ let offset = 0
174
+ for (const oid of unique) {
175
+ const newline = out.indexOf(10, offset)
176
+ if (newline < 0) throw new Error(`git cat-file --batch ended before ${oid}`)
177
+ const header = out.subarray(offset, newline).toString('utf8')
178
+ const size = Number(header.match(/ blob (\d+)$/)?.[1])
179
+ if (!header.startsWith(`${oid} blob `) || !Number.isFinite(size)) throw new Error(`git cat-file --batch returned '${header}' for ${oid}`) // dead-words-ok: Git object protocol type
180
+ const start = newline + 1, end = start + size
181
+ if (end >= out.length || out[end] !== 0x0a) throw new Error(`git cat-file --batch truncated object ${oid}`)
182
+ files.set(oid, out.subarray(start, end).toString('utf8'))
183
+ offset = end + 1
184
+ }
185
+ if (offset !== out.length) throw new Error(`git cat-file --batch returned ${out.length - offset} unexpected trailing bytes`)
186
+ return files
187
+ }
188
+
189
+ type TreeBlob = { oid: string; path: string }
190
+ function treeBlobs(root: string, tip: string, pathspec = '.'): TreeBlob[] {
191
+ const out = git(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', tip, '--', pathspec])
192
+ const blobs: TreeBlob[] = []
193
+ for (const record of out.split('\0')) {
194
+ if (!record) continue
195
+ const m = record.match(/^\d+ blob ([0-9a-f]+)\t([\s\S]+)$/)
196
+ if (m) blobs.push({ oid: m[1], path: m[2] })
197
+ }
198
+ return blobs
199
+ }
200
+
201
+ // Read a tree slice in two Git calls regardless of file count: ls-tree names immutable blob oids, then
202
+ // cat-file --batch returns their exact bytes. Pending lint uses this for .spec so commit --only/partial
203
+ // staging can never be judged through unrelated working-tree prose.
204
+ export function treeTextFiles(root: string, tip: string, pathspec = '.'): Map<string, string> {
205
+ const blobs = treeBlobs(root, tip, pathspec)
206
+ const files = new Map<string, string>()
207
+ if (!blobs.length) return files
208
+ const out = gitBuffer(['-C', root, 'cat-file', '--batch'], blobs.map((b) => b.oid).join('\n') + '\n')
209
+ let offset = 0
210
+ for (const blob of blobs) {
211
+ const newline = out.indexOf(10, offset)
212
+ if (newline < 0) throw new Error(`git cat-file --batch ended before ${blob.path}`)
213
+ const header = out.subarray(offset, newline).toString('utf8')
214
+ const size = Number(header.match(/ blob (\d+)$/)?.[1])
215
+ if (!Number.isFinite(size)) throw new Error(`git cat-file --batch returned '${header}' for ${blob.path}`)
216
+ const start = newline + 1, end = start + size
217
+ files.set(blob.path, out.subarray(start, end).toString('utf8'))
218
+ offset = end + 1 // one LF follows each payload
219
+ }
220
+ return files
221
+ }
222
+
223
+ export function treeFilePaths(root: string, tip: string): Set<string> {
224
+ return new Set(treeBlobs(root, tip).map((b) => b.path))
225
+ }
226
+
227
+ export function treeFileText(root: string, tip: string, path: string): string | null {
228
+ try { return gitBuffer(['-C', root, 'cat-file', 'blob', `${tip}:${path}`]).toString('utf8') } // dead-words-ok: git plumbing
229
+ catch { return null }
230
+ }
231
+
232
+ type GitExec = { stdout: string; stderr: string }
233
+
234
+ // execFile's AbortSignal kills only its direct child. A wedged adapter may have descendants (the
235
+ // deterministic tests use a shell + sleep), so async git runs in their own process group and abort/timeout
236
+ // kills the whole group. The callback still carries the same stdout/stderr/error shape to gitA/gitTry.
237
+ const GIT_MAX_BUFFER = 1 << 24
238
+ function execGit(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal, maxBuffer = GIT_MAX_BUFFER): Promise<GitExec> {
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
244
+ const killTree = () => {
245
+ if (!child?.pid) return
246
+ try { process.kill(-child.pid, 'SIGKILL') } catch { /* group may already be gone */ }
247
+ try { child.kill('SIGKILL') } catch { /* already exited */ }
248
+ }
249
+ 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)
255
+ 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 })
263
+ })
264
+ signal?.addEventListener('abort', onAbort, { once: true })
265
+ timer = setTimeout(() => { timedOut = true; killTree() }, GIT_TIMEOUT_MS)
266
+ timer.unref?.()
267
+ })
268
+ }
269
+
270
+ async function execGitForCaller(args: string[], env: NodeJS.ProcessEnv, maxBuffer?: number): Promise<GitExec> {
271
+ const context = inheritedContext()
272
+ if (!context) return execGit(args, env, undefined, maxBuffer)
273
+ const release = await context.permits.acquire(context.signal)
274
+ try {
275
+ return await execGit(withBuildLimits(args), env, context.signal, maxBuffer)
276
+ } finally {
277
+ release()
278
+ }
279
+ }
280
+
281
+ // Event streams are the index input itself and may legitimately exceed execFile's fixed maxBuffer. Read
282
+ // them through spawn so the only bound is the index the caller is intentionally constructing; timeout,
283
+ // cancellation, process-group cleanup and build permits remain identical to the ordinary async transport.
284
+ function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<GitExec> {
285
+ return new Promise((resolve, reject) => {
286
+ if (signal?.aborted) { reject(gitAbortError()); return }
287
+ const child = spawn('git', args, { env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] })
288
+ const stdout: Buffer[] = [], stderr: Buffer[] = []
289
+ let settled = false, aborted = false, timedOut = false
290
+ const killTree = () => {
291
+ if (!child.pid) return
292
+ try { process.kill(-child.pid, 'SIGKILL') } catch { /* group may already be gone */ }
293
+ try { child.kill('SIGKILL') } catch { /* already exited */ }
294
+ }
295
+ const onAbort = () => { aborted = true; killTree() }
296
+ const timer = setTimeout(() => { timedOut = true; killTree() }, GIT_TIMEOUT_MS)
297
+ timer.unref?.()
298
+ signal?.addEventListener('abort', onAbort, { once: true })
299
+ child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk))
300
+ child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk))
301
+ child.on('error', (error: any) => {
302
+ if (settled) return
303
+ settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort)
304
+ if (aborted) error.name = 'AbortError'
305
+ if (timedOut) error.spexcodeGitTimeout = true
306
+ reject(error)
307
+ })
308
+ child.on('close', (code, childSignal) => {
309
+ if (settled) return
310
+ settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort)
311
+ const result = { stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8') }
312
+ if (code === 0 && !aborted && !timedOut) { resolve(result); return }
313
+ const error: any = new Error(`git exited with ${code ?? childSignal ?? 'unknown status'}`)
314
+ error.code = code
315
+ error.signal = childSignal
316
+ error.stdout = result.stdout
317
+ error.stderr = result.stderr
318
+ if (aborted) error.name = 'AbortError'
319
+ if (timedOut) error.spexcodeGitTimeout = true
320
+ reject(error)
321
+ })
322
+ })
323
+ }
324
+ async function execGitStreamForCaller(args: string[], env: NodeJS.ProcessEnv): Promise<GitExec> {
325
+ const context = inheritedContext()
326
+ if (!context) return execGitStream(args, env)
327
+ const release = await context.permits.acquire(context.signal)
328
+ try { return await execGitStream(withBuildLimits(args), env, context.signal) }
329
+ finally { release() }
330
+ }
331
+
29
332
  export async function gitA(args: string[]): Promise<string> {
30
333
  const env = { ...process.env }
31
334
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
335
+ const context = inheritedContext()
32
336
  try {
33
- const { stdout } = await pexecFile('git', args, { encoding: 'utf8', env, maxBuffer: 1 << 24, timeout: GIT_TIMEOUT_MS, killSignal: 'SIGKILL' })
337
+ const { stdout } = await execGitForCaller(args, env)
34
338
  return stdout
35
- } catch (e: any) { warnIfTimedOut(e, args); return '' }
339
+ } catch (e: any) {
340
+ if (context?.signal.aborted || e?.name === 'AbortError') throw e
341
+ warnIfTimedOut(e, args); return ''
342
+ }
36
343
  }
37
344
 
38
- export async function gitTry(args: string[]): Promise<{ ok: boolean; stdout: string; stderr: string }> {
345
+ type EventRecord = { hash: string; raw: string }
346
+ 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
349
+ type EventStreamKind = typeof EVENT_STREAM_KINDS[number]
350
+ type EventCacheLocation = { path: string; identity: string; objectFormat: GitObjectFormat }
351
+ type EventLedgerSnapshot = {
352
+ payload: Buffer
353
+ state: EventCache
354
+ }
355
+ type EventStreamRequest = {
356
+ kind: EventStreamKind
357
+ argsFor: (base: string) => string[]
358
+ order: Map<string, number>
359
+ reachable: Set<string>
360
+ }
361
+
362
+ type EventPathMemo = EventCacheLocation & {
363
+ common: string; shallowPath: string; grafts: string; shallow: string; replacements: string
364
+ }
365
+ const eventPathMemo = new Map<string, EventPathMemo>()
366
+ function eventCacheLocation(root: string): EventCacheLocation {
367
+ const rootId = rootKey(root), old = eventPathMemo.get(rootId)
368
+ const common = old?.common ?? git(['-C', root, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
369
+ const shallowPath = old?.shallowPath ?? git(['-C', root, 'rev-parse', '--path-format=absolute', '--git-path', 'shallow']).trim()
370
+ const shallow = existsSync(shallowPath) ? readFileSync(shallowPath, 'utf8') : 'unshallow'
371
+ const graftsPath = join(common, 'info', 'grafts')
372
+ const grafts = existsSync(graftsPath) ? readFileSync(graftsPath, 'utf8') : ''
373
+ const replacements = git(['-C', root, 'for-each-ref', 'refs/replace', '--format=%(refname) %(objectname)'])
374
+ const objectFormat = gitObjectFormat(root)
375
+ if (old && old.shallow === shallow && old.grafts === grafts && old.replacements === replacements && old.objectFormat === objectFormat)
376
+ return { path: old.path, identity: old.identity, objectFormat }
377
+ const identity = createHash('sha256')
378
+ .update(`${EVENT_CACHE_SCHEMA}\0${objectFormat}\0${shallow}\0${grafts}\0${replacements}`)
379
+ .digest('hex').slice(0, 16)
380
+ // projectRuntimeRoot derives checkout identity from dirname(common). A bare repository is its own common
381
+ // dir, so a synthetic `.git` suffix preserves the repository path instead of collapsing sibling bares.
382
+ const gitDir = gitDirOf(root)
383
+ const storeIdentity = gitDir === root && common === root ? join(common, '.git') : common
384
+ const path = join(projectRuntimeRoot(storeIdentity), `${EVENT_CACHE_SCHEMA}-${identity}.ndjson`)
385
+ eventPathMemo.set(rootId, { common, shallowPath, grafts, shallow, replacements, path, identity, objectFormat })
386
+ return { path, identity, objectFormat }
387
+ }
388
+ function emptyEventCache(): EventCache {
389
+ return { streams: new Map(), streamTips: new Map() }
390
+ }
391
+ function exactKeys(value: Record<string, unknown>, expected: string[]): boolean {
392
+ const actual = Object.keys(value).sort()
393
+ return actual.length === expected.length && actual.every((key, index) => key === expected[index])
394
+ }
395
+ function isGitObjectIdForFormat(format: GitObjectFormat, value: string): boolean {
396
+ return value.length === (format === 'sha256' ? 64 : 40) && /^[0-9a-f]+$/.test(value)
397
+ }
398
+ function eventPayloadDigest(payload: Buffer): string {
399
+ return createHash('sha256').update(payload).digest('hex')
400
+ }
401
+ function eventIntegrityFooter(payload: Buffer, addition: Buffer): Buffer {
402
+ const footer = {
403
+ k: 'integrity',
404
+ bytes: payload.length + addition.length,
405
+ sha256: createHash('sha256').update(payload).update(addition).digest('hex'),
406
+ }
407
+ return Buffer.from(`${JSON.stringify(footer)}\n`)
408
+ }
409
+ function eventStreamKind(value: unknown): EventStreamKind | null {
410
+ return typeof value === 'string' && (EVENT_STREAM_KINDS as readonly string[]).includes(value)
411
+ ? value as EventStreamKind
412
+ : null
413
+ }
414
+ function decodeEventPayload(payload: Buffer, location: EventCacheLocation): EventCache | null {
415
+ const state = emptyEventCache()
416
+ const text = payload.toString('utf8')
417
+ for (let start = 0; start < text.length;) {
418
+ const newline = text.indexOf('\n', start)
419
+ const end = newline < 0 ? text.length : newline
420
+ const line = text.slice(start, end)
421
+ start = end + 1
422
+ if (!line) continue
423
+ let value: unknown
424
+ try { value = JSON.parse(line) } catch { return null }
425
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
426
+ const row = value as Record<string, unknown>
427
+ if (exactKeys(row, ['k', 'tip'])) {
428
+ const kind = typeof row.k === 'string' && row.k.startsWith('tip:') ? eventStreamKind(row.k.slice(4)) : null
429
+ if (!kind || typeof row.tip !== 'string' || !isGitObjectIdForFormat(location.objectFormat, row.tip)) return null
430
+ const tips = state.streamTips.get(kind) ?? []
431
+ if (tips.includes(row.tip)) return null
432
+ tips.push(row.tip)
433
+ state.streamTips.set(kind, tips)
434
+ continue
435
+ }
436
+ 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
442
+ let stream = state.streams.get(kind)
443
+ if (!stream) { stream = new Map(); state.streams.set(kind, stream) }
444
+ if (stream.has(row.h)) return null
445
+ stream.set(row.h, { hash: row.h, raw: row.r })
446
+ }
447
+ return state
448
+ }
449
+ function loadEventLedger(location: EventCacheLocation): EventLedgerSnapshot {
450
+ let file: Buffer
451
+ try { file = readFileSync(location.path) }
452
+ catch (error: any) {
453
+ if (error?.code !== 'ENOENT') throw error
454
+ return { payload: Buffer.alloc(0), state: emptyEventCache() }
455
+ }
456
+ if (!file.length || file[file.length - 1] !== 0x0a)
457
+ return { payload: Buffer.alloc(0), state: emptyEventCache() }
458
+ const body = file.subarray(0, file.length - 1)
459
+ const footerBreak = body.lastIndexOf(0x0a)
460
+ const footerStart = footerBreak + 1
461
+ const payload = file.subarray(0, footerStart)
462
+ try {
463
+ const value = JSON.parse(body.subarray(footerStart).toString('utf8')) as unknown
464
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid integrity row')
465
+ const row = value as Record<string, unknown>
466
+ if (!exactKeys(row, ['bytes', 'k', 'sha256']) || row.k !== 'integrity' || row.bytes !== payload.length
467
+ || typeof row.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(row.sha256)
468
+ || row.sha256 !== eventPayloadDigest(payload)) throw new Error('invalid integrity row')
469
+ const state = decodeEventPayload(payload, location)
470
+ if (!state) throw new Error('invalid event row')
471
+ return { payload, state }
472
+ } catch {
473
+ return { payload: Buffer.alloc(0), state: emptyEventCache() }
474
+ }
475
+ }
476
+
477
+ type EventLockOwner = { pid: number; token: string }
478
+ function readEventLockOwner(lock: string): EventLockOwner | null {
479
+ try {
480
+ const value = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8')) as { pid?: unknown; token?: unknown }
481
+ return Number.isInteger(value.pid) && (value.pid as number) > 0 && typeof value.token === 'string'
482
+ ? { pid: value.pid as number, token: value.token }
483
+ : null
484
+ } catch { return null }
485
+ }
486
+ function processAlive(pid: number): boolean {
487
+ try { process.kill(pid, 0); return true }
488
+ catch (error: any) { return error?.code !== 'ESRCH' }
489
+ }
490
+ function retireLockPath(active: string): boolean {
491
+ const inert = `${active}.inert.${process.pid}.${randomBytes(8).toString('hex')}`
492
+ try { renameSync(active, inert) }
493
+ catch (error: any) {
494
+ if (error?.code === 'ENOENT') return false
495
+ throw error
496
+ }
497
+ try { rmSync(inert, { recursive: true, force: true }) } catch { /* no longer arbitrates ownership */ }
498
+ return true
499
+ }
500
+ function reclaimDeadEventLock(lock: string, held: EventLockOwner, claimant: EventLockOwner): boolean {
501
+ if (processAlive(held.pid)) return false
502
+ const reclaim = join(lock, 'reclaim')
503
+ if (existsSync(reclaim)) {
504
+ const reclaimer = readEventLockOwner(reclaim)
505
+ if (!reclaimer || processAlive(reclaimer.pid)) return false
506
+ retireLockPath(reclaim)
507
+ }
508
+ const prepared = join(lock, `reclaim-${claimant.pid}-${claimant.token}`)
509
+ try {
510
+ mkdirSync(prepared)
511
+ writeFileSync(join(prepared, 'owner.json'), JSON.stringify(claimant))
512
+ renameSync(prepared, reclaim)
513
+ } catch (error: any) {
514
+ rmSync(prepared, { recursive: true, force: true })
515
+ if (error?.code === 'EEXIST' || error?.code === 'ENOTEMPTY' || error?.code === 'ENOENT') return false
516
+ throw error
517
+ }
518
+ const current = readEventLockOwner(lock)
519
+ if (current?.pid !== held.pid || current.token !== held.token || processAlive(current.pid)) {
520
+ retireLockPath(reclaim)
521
+ return false
522
+ }
523
+ return retireLockPath(lock)
524
+ }
525
+ async function withEventCacheLock<T>(path: string, run: () => Promise<T> | T): Promise<T> {
526
+ const lock = `${path}.lock`
527
+ mkdirSync(join(path, '..'), { recursive: true })
528
+ const owner: EventLockOwner = { pid: process.pid, token: randomBytes(16).toString('hex') }
529
+ const attempts = Math.max(1, Math.ceil(GIT_TIMEOUT_MS / 5))
530
+ const signal = inheritedContext()?.signal
531
+ for (let attempt = 0; ; attempt++) {
532
+ if (signal?.aborted) throw gitAbortError()
533
+ const claimant = `${lock}.claim.${owner.pid}.${owner.token}`
534
+ try {
535
+ mkdirSync(claimant)
536
+ writeFileSync(join(claimant, 'owner.json'), JSON.stringify(owner))
537
+ renameSync(claimant, lock)
538
+ break
539
+ } catch (error: any) {
540
+ rmSync(claimant, { recursive: true, force: true })
541
+ if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error
542
+ const held = readEventLockOwner(lock)
543
+ if (held && reclaimDeadEventLock(lock, held, owner)) continue
544
+ if (attempt >= attempts) {
545
+ const heldBy = readEventLockOwner(lock)?.pid
546
+ throw new Error(`timed out waiting for history event cache lock held by ${heldBy ? `pid ${heldBy}` : 'unknown owner'}: ${path}`)
547
+ }
548
+ await new Promise((resolve) => setTimeout(resolve, 5))
549
+ }
550
+ }
551
+ try { return await run() } finally {
552
+ if (readEventLockOwner(lock)?.token === owner.token) retireLockPath(lock)
553
+ }
554
+ }
555
+ function removeEventTemps(path: string): void {
556
+ const dir = join(path, '..'), base = path.split('/').pop() ?? ''
557
+ try {
558
+ for (const name of readdirSync(dir)) if (name.startsWith(`${base}.`) && name.endsWith('.tmp'))
559
+ rmSync(join(dir, name), { force: true })
560
+ } catch { /* the writer creates the directory immediately below */ }
561
+ }
562
+ function replaceEventLedger(path: string, payload: Buffer, additions: string[]): void {
563
+ const addition = Buffer.from(additions.join(''))
564
+ const tmp = `${path}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`
565
+ let fd: number | null = null
566
+ try {
567
+ fd = openSync(tmp, 'wx', 0o600)
568
+ writeFileSync(fd, payload)
569
+ writeFileSync(fd, addition)
570
+ writeFileSync(fd, eventIntegrityFooter(payload, addition))
571
+ const written = fd; fd = null; closeSync(written)
572
+ renameSync(tmp, path)
573
+ } catch (error) {
574
+ if (fd !== null) closeSync(fd)
575
+ rmSync(tmp, { force: true })
576
+ throw error
577
+ }
578
+ }
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('')
588
+ }
589
+ function appendEventRecord(state: EventCache, kind: EventStreamKind, record: EventRecord, additions: string[]): void {
590
+ let stream = state.streams.get(kind)
591
+ if (!stream) { stream = new Map(); state.streams.set(kind, stream) }
592
+ if (stream.has(record.hash)) return
593
+ stream.set(record.hash, record)
594
+ additions.push(JSON.stringify({ k: kind, h: record.hash, r: record.raw }) + '\n')
595
+ }
596
+ function appendEventTip(state: EventCache, kind: EventStreamKind, tip: string, additions: string[]): void {
597
+ const tips = state.streamTips.get(kind) ?? []
598
+ if (tips.includes(tip)) return
599
+ tips.push(tip)
600
+ state.streamTips.set(kind, tips)
601
+ additions.push(JSON.stringify({ k: `tip:${kind}`, tip }) + '\n')
602
+ }
603
+ function parseEventRecords(out: string, kind: EventStreamKind, location: EventCacheLocation): EventRecord[] {
604
+ const records: EventRecord[] = []
605
+ for (const rec of out.split(RS)) {
606
+ const raw = rec.replace(/^\n/, '')
607
+ if (!raw) continue
608
+ const hash = raw.split(US, 1)[0].split('\n', 1)[0].trim()
609
+ if (!isGitObjectIdForFormat(location.objectFormat, hash))
610
+ throw new Error(`history event stream '${kind}' returned malformed object id '${hash || 'empty'}'`)
611
+ records.push({ hash, raw })
612
+ }
613
+ return records
614
+ }
615
+ function indexEventRequests(
616
+ root: string,
617
+ tip: string,
618
+ order: Map<string, number>,
619
+ reachable: Set<string>,
620
+ ): Record<EventStreamKind, EventStreamRequest> {
621
+ 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,
630
+ 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)`,
633
+ ...(base ? [`^${base}`] : []), tip],
634
+ },
635
+ merge: {
636
+ kind: 'merge', order, reachable,
637
+ argsFor: (base) => ['-C', root, '-c', 'core.quotePath=false',
638
+ 'log', '--merges', '--raw', '--patch', '--cc', '--combined-all-paths', '--unified=0', '--no-color', '--no-ext-diff', '-M',
639
+ `--format=${RS}%H`, ...(base ? [`^${base}`] : []), tip],
640
+ },
641
+ }
642
+ }
643
+
644
+ async function strictEventGit(args: string[]): Promise<string> {
645
+ return gitRequiredA(args, 'cannot derive history events')
646
+ }
647
+ async function deriveEventStreams(
648
+ root: string,
649
+ tip: string,
650
+ requests: EventStreamRequest[],
651
+ persist = true,
652
+ cache = true,
653
+ ): Promise<Map<EventStreamKind, string>> {
654
+ if (!cache) {
655
+ const outputs = await Promise.all(requests.map((request) => strictEventGit(request.argsFor(''))))
656
+ return new Map(requests.map((request, index) => [request.kind, outputs[index]]))
657
+ }
658
+ if (new Set(requests.map((request) => request.kind)).size !== requests.length)
659
+ throw new Error('one event-ledger transaction cannot request the same stream twice')
660
+
661
+ const run = async (location: EventCacheLocation): Promise<Map<EventStreamKind, string> | null> => {
662
+ const snapshot = loadEventLedger(location)
663
+ const missing = requests.filter((request) => !(snapshot.state.streamTips.get(request.kind) ?? []).includes(tip))
664
+ const outputs = await Promise.all(missing.map((request) => {
665
+ const base = [...(snapshot.state.streamTips.get(request.kind) ?? [])].reverse()
666
+ .find((candidate) => request.reachable.has(candidate)) ?? ''
667
+ return strictEventGit(request.argsFor(base))
668
+ }))
669
+ if (eventCacheLocation(root).identity !== location.identity) return null
670
+
671
+ const additions: string[] = []
672
+ for (let index = 0; index < missing.length; index++) {
673
+ const request = missing[index]
674
+ for (const record of parseEventRecords(outputs[index], request.kind, location))
675
+ appendEventRecord(snapshot.state, request.kind, record, additions)
676
+ if (persist) appendEventTip(snapshot.state, request.kind, tip, additions)
677
+ }
678
+ if (persist && additions.length) {
679
+ removeEventTemps(location.path)
680
+ mkdirSync(join(location.path, '..'), { recursive: true })
681
+ replaceEventLedger(location.path, snapshot.payload, additions)
682
+ }
683
+ return new Map(requests.map((request) => [request.kind, renderEventStream(snapshot.state, request)]))
684
+ }
685
+
686
+ for (let attempt = 0; attempt < 8; attempt++) {
687
+ const location = eventCacheLocation(root)
688
+ const result = persist ? await withEventCacheLock(location.path, () => run(location)) : await run(location)
689
+ if (result) return result
690
+ }
691
+ throw new Error(`history event cache identity changed repeatedly while deriving ${tip}`)
692
+ }
693
+ async function eventStream(
694
+ root: string,
695
+ tip: string,
696
+ request: EventStreamRequest,
697
+ persist = true,
698
+ cache = true,
699
+ ): Promise<string> {
700
+ return (await deriveEventStreams(root, tip, [request], persist, cache)).get(request.kind) ?? ''
701
+ }
702
+ export type GitTryFailure = 'exit' | 'spawn' | 'timeout'
703
+ export async function gitTry(args: string[], options: { indexFile?: string } = {}): Promise<{ ok: boolean; stdout: string; stderr: string; failure?: GitTryFailure }> {
39
704
  const env = { ...process.env }
40
705
  delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
706
+ if (options.indexFile) env.GIT_INDEX_FILE = options.indexFile
707
+ const context = inheritedContext()
41
708
  try {
42
- const { stdout, stderr } = await pexecFile('git', args, { encoding: 'utf8', env, maxBuffer: 1 << 24, timeout: GIT_TIMEOUT_MS, killSignal: 'SIGKILL' })
709
+ const { stdout, stderr } = await execGitForCaller(args, env)
43
710
  return { ok: true, stdout, stderr }
44
711
  } catch (e: any) {
712
+ if (context?.signal.aborted || e?.name === 'AbortError') throw e
45
713
  warnIfTimedOut(e, args)
46
- return { ok: false, stdout: e?.stdout ?? '', stderr: e?.stderr ?? String(e?.message ?? e) }
714
+ const failure: GitTryFailure = e?.spexcodeGitTimeout ? 'timeout' : typeof e?.code === 'number' ? 'exit' : 'spawn'
715
+ return { ok: false, stdout: e?.stdout ?? '', stderr: e?.stderr ?? String(e?.message ?? e), failure }
716
+ }
717
+ }
718
+
719
+ export async function gitRequiredA(args: string[], purpose: string): Promise<string> {
720
+ const env = { ...process.env }
721
+ delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
722
+ try { return (await execGitStreamForCaller(args, env)).stdout }
723
+ catch (error: any) {
724
+ if (error?.name === 'AbortError') throw error
725
+ warnIfTimedOut(error, args)
726
+ throw new Error(`${purpose}: git ${args.slice(2).join(' ')} failed: ${String(error?.stderr || error?.message || 'unknown git error').trim()}`)
47
727
  }
48
728
  }
49
729
 
@@ -62,6 +742,12 @@ export function repoRoot(): string {
62
742
  function gitDirOf(root: string): string {
63
743
  // a normal checkout has a `.git` DIRECTORY; a linked worktree has a `.git` FILE: `gitdir: <path>`.
64
744
  const dotgit = join(root, '.git')
745
+ if (!existsSync(dotgit)) {
746
+ try {
747
+ if (git(['-C', root, 'rev-parse', '--is-bare-repository']).trim() === 'true') return root
748
+ } catch { /* the caller will receive the same loud path error as a malformed checkout */ }
749
+ throw new Error(`headSha: no .git directory at ${dotgit}`)
750
+ }
65
751
  if (statSync(dotgit).isDirectory()) return dotgit
66
752
  const m = readFileSync(dotgit, 'utf8').match(/^gitdir:\s*(.+)$/m)
67
753
  if (!m) throw new Error(`headSha: unparseable .git file at ${dotgit}`)
@@ -132,6 +818,7 @@ export type DiffStat = { additions: number; deletions: number; files: number }
132
818
  export type HistoryIndex = {
133
819
  versions: Map<string, Version[]> // headPath -> rows newest-first (incl. pure-rename rows)
134
820
  stats: Map<string, Map<string, DiffStat>> // headPath -> (commit hash -> this file's diffstat there)
821
+ mergeVersions?: Set<string> // path\0hash pairs with an all-parent combined-diff line
135
822
  }
136
823
 
137
824
  // git numstat encodes a rename as `dir/{old => new}/file` (either side may be empty) or `old => new`;
@@ -158,29 +845,91 @@ function parseStatPath(token: string): { from: string; to: string } {
158
845
  // tab roots its readings at the session's branch). A single-slot cache thrashes between those roots:
159
846
  // each eval-tab request evicts the board's entry and vice versa, so every request re-runs a full-history
160
847
  // `git log` and re-parses it on the event loop — which is what starves every other request (the board,
161
- // remark posts) under load. So the cache is a small LRU keyed by HEAD (same head ⇒ same index, whatever
162
- // the root), holding the in-flight PROMISE so concurrent requests for one head share a single build.
163
- const INDEX_SLOTS = 16
164
- function lruGet<V>(m: Map<string, V>, k: string): V | undefined {
165
- const v = m.get(k)
166
- if (v !== undefined) { m.delete(k); m.set(k, v) } // refresh recency
167
- return v
848
+ // remark posts) under load. So the cache is a small LRU keyed by interpretation identity + HEAD, holding
849
+ // the in-flight PROMISE so concurrent requests for one immutable view share a single build.
850
+ const indexCache = new Map<string, Promise<HistoryIndex>>()
851
+ const indexRoots = new Map<string, string>()
852
+ const driftRoots = new Map<string, string>()
853
+ const driftIdxCache = new Map<string, Promise<DriftIndex>>()
854
+ const INDEX_ROOT_SLOTS = Math.max(4, Number(process.env.SPEXCODE_INDEX_CACHE_ROOTS || 32))
855
+
856
+ function rootKey(root: string): string { return resolve(root) }
857
+
858
+ function gitInterpretationKey(root: string): string { return eventCacheLocation(root).identity }
859
+
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.
863
+ function touchRoot(roots: Map<string, string>, cache: Map<string, Promise<unknown>>, root: string, cacheKey: string): void {
864
+ const key = rootKey(root)
865
+ const previous = roots.get(key)
866
+ if (previous !== cacheKey) {
867
+ roots.set(key, cacheKey)
868
+ if (previous && ![...roots.values()].includes(previous)) cache.delete(previous)
869
+ } else {
870
+ roots.delete(key)
871
+ roots.set(key, cacheKey)
872
+ }
873
+ while (roots.size > INDEX_ROOT_SLOTS) {
874
+ const oldest = roots.keys().next().value as string | undefined
875
+ if (oldest === undefined) break
876
+ const oldHead = roots.get(oldest)
877
+ roots.delete(oldest)
878
+ if (oldHead && ![...roots.values()].includes(oldHead)) cache.delete(oldHead)
879
+ }
168
880
  }
169
- function lruPut<V>(m: Map<string, V>, k: string, v: V): void {
170
- m.set(k, v)
171
- while (m.size > INDEX_SLOTS) m.delete(m.keys().next().value!)
881
+
882
+ function dropFailed(cache: Map<string, Promise<unknown>>, head: string, promise: Promise<unknown>): void {
883
+ if (cache.get(head) !== promise) return
884
+ // A rejected index is never reusable, even when its root still points at that HEAD. The next read must
885
+ // start a fresh walk after a watchdog abort or transient git failure.
886
+ cache.delete(head)
172
887
  }
173
888
 
174
- const indexCache = new Map<string, Promise<HistoryIndex>>()
889
+ function pendingOnlyIssues(root: string, tip: string): boolean {
890
+ try {
891
+ const parents = git(['-C', root, 'rev-list', '--parents', '-n1', tip]).trim().split(/\s+/).filter(Boolean)
892
+ // A first-parent issue diff can hide side-branch code debt in an ours merge. Only a single-parent
893
+ // candidate may reuse its parent's indexes; every merge must build the candidate's full reachable view.
894
+ if (parents.length !== 2) return false
895
+ const parent = git(['-C', root, 'rev-parse', `${tip}^`]).trim()
896
+ const paths = git(['-C', root, 'diff-tree', '--no-commit-id', '--name-only', '-r', parent, tip])
897
+ .split('\n').map((p) => p.trim()).filter(Boolean)
898
+ return paths.length > 0 && paths.every((p) => p.startsWith('.spec/.issues/'))
899
+ } catch { return false }
900
+ }
901
+ function pendingParent(root: string, tip: string): string | null {
902
+ try {
903
+ const parent = git(['-C', root, 'rev-parse', `${tip}^`]).trim()
904
+ return isGitObjectId(root, parent) ? parent : null
905
+ } catch { return null }
906
+ }
175
907
 
176
- export function historyIndex(root: string): Promise<HistoryIndex> {
908
+ export function historyIndex(root: string, tip = 'HEAD'): Promise<HistoryIndex> {
909
+ if (tip !== 'HEAD') {
910
+ const resolved = git(['-C', root, 'rev-parse', `${tip}^{commit}`]).trim()
911
+ // An issue-only candidate changes no governed content, but its lint result still includes the
912
+ // parent's existing drift/related-drift warnings. Reuse that immutable parent index instead of
913
+ // manufacturing an empty one (which silently dropped warnings at golden depths).
914
+ if (pendingOnlyIssues(root, resolved)) {
915
+ const parent = pendingParent(root, resolved)
916
+ if (parent) {
917
+ const head = headOrEmpty(root)
918
+ if (head === parent) return historyIndex(root)
919
+ return buildIndex(root, parent, true, true)
920
+ }
921
+ }
922
+ return buildIndex(root, resolved, true, true)
923
+ }
177
924
  const head = headOrEmpty(root)
178
- if (!head) return buildIndex(root)
179
- const hit = lruGet(indexCache, head)
925
+ if (!head) return buildIndex(root, 'HEAD', false, true)
926
+ const cacheKey = `${rootKey(root)}\0${head}\0${gitInterpretationKey(root)}`
927
+ touchRoot(indexRoots, indexCache, root, cacheKey)
928
+ const hit = indexCache.get(cacheKey)
180
929
  if (hit) return hit
181
- const p = buildIndex(root)
182
- p.catch(() => { indexCache.delete(head) }) // don't pin a failed build
183
- lruPut(indexCache, head, p)
930
+ const p = buildIndex(root, head.startsWith('unborn:') ? 'HEAD' : head, false, true)
931
+ p.catch(() => { dropFailed(indexCache, cacheKey, p) }) // don't pin a failed build
932
+ indexCache.set(cacheKey, p)
184
933
  return p
185
934
  }
186
935
 
@@ -194,15 +943,101 @@ function headOrEmpty(root: string): string {
194
943
  }
195
944
  }
196
945
 
197
- async function buildIndex(root: string): Promise<HistoryIndex> {
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)
955
+ 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)
963
+ if (position === undefined) continue
964
+ const mask = 1 << (position & 7)
965
+ if (bits[position >> 3] & mask) continue
966
+ bits[position >> 3] |= mask
967
+ stack.push(parent)
968
+ }
969
+ ancestryCache.set(hash, bits)
970
+ return bits
971
+ }
972
+ const precedes = (older: string, newer: string): boolean => {
973
+ const position = topologyOrd.get(older), ancestry = ancestryOf(newer)
974
+ if (position === undefined || ancestry === undefined)
975
+ throw new Error(`rename projection cannot place ${older} against ${newer} in the current topology`)
976
+ return (ancestry[position >> 3] & (1 << (position & 7))) !== 0
977
+ }
978
+ return (path, event) => {
979
+ const pending = [path], resolved = new Set<string>(), seen = new Set<string>()
980
+ while (pending.length) {
981
+ const candidate = pending.pop()!
982
+ if (seen.has(candidate)) { resolved.add(candidate); continue }
983
+ seen.add(candidate)
984
+ const unique = new Map<string, RenameProjectionEvent>()
985
+ for (const rename of renamesByFrom.get(candidate) ?? []) {
986
+ if (!precedes(rename.hash, event)) unique.set(`${rename.hash}\0${rename.to}`, rename)
987
+ }
988
+ const applicable = [...unique.values()]
989
+ if (!applicable.length) { resolved.add(candidate); continue }
990
+ // The earliest applicable rename starts this path's next lineage epoch. Later renames from the same
991
+ // path belong to a recreated path, not to an event that predates the first boundary. Incomparable
992
+ // rename branches are both frontier members, so their identities still fork at the merge. A merge can
993
+ // expose the same rename once per parent; equal-commit rows are peers, never ancestors of one another.
994
+ const frontier = applicable.filter((rename, index) =>
995
+ !applicable.some((other, otherIndex) => otherIndex !== index
996
+ && other.hash !== rename.hash && precedes(other.hash, rename.hash)))
997
+ if (frontier.some((rename) => !precedes(event, rename.hash))) resolved.add(candidate)
998
+ for (const rename of frontier) pending.push(rename.to)
999
+ }
1000
+ return [...resolved]
1001
+ }
1002
+ }
1003
+
1004
+ type SharedIndexInputs = {
1005
+ allPathsOut: string
1006
+ topologyOut: string
1007
+ historyOut: string
1008
+ driftOut: string
1009
+ mergeIndex: MergeHistoryEvents
1010
+ }
1011
+
1012
+ async function buildIndex(root: string, tip: string, transient: boolean, useCache = true, shared?: SharedIndexInputs): Promise<HistoryIndex> {
198
1013
  const versions = new Map<string, Version[]>()
199
1014
  const stats = new Map<string, Map<string, DiffStat>>()
200
- const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'log', '-M', '--numstat',
201
- `--format=${RS}%H${US}%aI${US}%s${US}%b`, '--', '.spec'])
202
- if (!out) return { versions, stats }
203
- // Walk newest -> oldest (git log default). `alias` maps a path as it exists at the current walk
204
- // point to its head (current) path; the first (newest) time we meet a file, that path IS its head.
205
- const alias = new Map<string, string>()
1015
+ const mergeVersions = new Set<string>()
1016
+ const commitVersions = new Map<string, Version>()
1017
+ const commitOrder = new Map<string, number>()
1018
+ const [tipPathsOut, topologyOut] = shared
1019
+ ? [shared.allPathsOut, shared.topologyOut]
1020
+ : await Promise.all([
1021
+ strictEventGit(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--name-only', tip, '--', '.spec']),
1022
+ strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
1023
+ ])
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/')))
1027
+ 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 }
1040
+ let commitPosition = 0
206
1041
  for (const rec of out.split(RS)) {
207
1042
  const r = rec.replace(/^\n/, '')
208
1043
  if (!r) continue
@@ -211,32 +1046,137 @@ async function buildIndex(root: string): Promise<HistoryIndex> {
211
1046
  const rest = parts.slice(3).join(US) // body (had no US) followed by the numstat block
212
1047
  const sm = rest.match(/Session:\s*(\S+)/)
213
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++)
214
1051
  for (const line of rest.split('\n')) {
215
1052
  const m = line.match(/^(-|\d+)\t(-|\d+)\t(.+)$/)
216
1053
  if (!m) continue
217
1054
  const add = m[1] === '-' ? 0 : +m[1]
218
1055
  const del = m[2] === '-' ? 0 : +m[2]
219
1056
  const { from, to } = parseStatPath(m[3])
220
- let head = alias.get(to)
221
- if (head === undefined) { head = to; alias.set(to, to) }
222
- if (!versions.has(head)) versions.set(head, [])
223
- versions.get(head)!.push(version)
224
- let hs = stats.get(head)
225
- if (!hs) { hs = new Map(); stats.set(head, hs) }
1057
+ if (!rawVersions.has(to)) rawVersions.set(to, [])
1058
+ rawVersions.get(to)!.push(version)
1059
+ let hs = rawStats.get(to)
1060
+ if (!hs) { hs = new Map(); rawStats.set(to, hs) }
226
1061
  const s = hs.get(hash) ?? { additions: 0, deletions: 0, files: 0 }
227
1062
  s.additions += add; s.deletions += del; s.files += 1
228
1063
  hs.set(hash, s)
229
- if (from !== to) { alias.set(from, head); alias.delete(to) } // older history calls it `from`
1064
+ if (from !== to) {
1065
+ const renames = renamesByFrom.get(from) ?? []
1066
+ renames.push({ hash, to })
1067
+ renamesByFrom.set(from, renames)
1068
+ }
1069
+ }
1070
+ }
1071
+
1072
+ // Re-establish Git's date-order contract after records are parsed from a stream. An independent commit
1073
+ // can appear before its child in a date-ordered result; repair that partial-order constraint with a stable
1074
+ // topological pass. A comparator that
1075
+ // materializes an ancestor Set/bitset for every sort comparison turns a 4k-commit history into an
1076
+ // accidental quadratic walk; this queue visits each commit and parent edge once.
1077
+ const originalOrder = new Map(commitOrder)
1078
+ const childrenLeft = new Map<string, number>()
1079
+ for (const hash of commitVersions.keys()) childrenLeft.set(hash, 0)
1080
+ for (const hash of commitVersions.keys()) for (const parent of topologyParents.get(hash) ?? [])
1081
+ if (childrenLeft.has(parent)) childrenLeft.set(parent, childrenLeft.get(parent)! + 1)
1082
+ const heap: string[] = []
1083
+ const before = (a: string, b: string) => (originalOrder.get(a) ?? Number.MAX_SAFE_INTEGER) - (originalOrder.get(b) ?? Number.MAX_SAFE_INTEGER)
1084
+ const push = (hash: string) => {
1085
+ let i = heap.push(hash) - 1
1086
+ while (i > 0) {
1087
+ const parent = (i - 1) >> 1
1088
+ if (before(heap[parent], hash) <= 0) break
1089
+ heap[i] = heap[parent]; i = parent
1090
+ }
1091
+ heap[i] = hash
1092
+ }
1093
+ const pop = (): string => {
1094
+ const first = heap[0], last = heap.pop()!
1095
+ if (heap.length) {
1096
+ let i = 0
1097
+ while (true) {
1098
+ const left = i * 2 + 1
1099
+ if (left >= heap.length) break
1100
+ let child = left
1101
+ const right = left + 1
1102
+ if (right < heap.length && before(heap[right], heap[left]) < 0) child = right
1103
+ if (before(heap[child], last) >= 0) break
1104
+ heap[i] = heap[child]; i = child
1105
+ }
1106
+ heap[i] = last
1107
+ }
1108
+ return first
1109
+ }
1110
+ for (const [hash, left] of childrenLeft) if (left === 0) push(hash)
1111
+ const ordered: string[] = []
1112
+ while (heap.length) {
1113
+ const hash = pop(); ordered.push(hash)
1114
+ for (const parent of topologyParents.get(hash) ?? []) {
1115
+ if (!childrenLeft.has(parent)) continue
1116
+ const left = childrenLeft.get(parent)! - 1
1117
+ childrenLeft.set(parent, left)
1118
+ if (left === 0) push(parent)
1119
+ }
1120
+ }
1121
+ // A shallow or malformed topology should fail closed rather than silently dropping a version row.
1122
+ if (ordered.length !== commitVersions.size)
1123
+ for (const hash of commitVersions.keys()) if (!ordered.includes(hash)) ordered.push(hash)
1124
+ commitOrder.clear(); ordered.forEach((hash, i) => commitOrder.set(hash, i))
1125
+
1126
+ const mergeIndex = shared?.mergeIndex
1127
+ ?? await mergeHistoryEvents(root, tip, transient, topologyOrd, topologyReachable, useCache)
1128
+ for (const rename of mergeIndex.renames) {
1129
+ const renames = renamesByFrom.get(rename.from) ?? []
1130
+ renames.push({ hash: rename.hash, to: rename.to })
1131
+ renamesByFrom.set(rename.from, renames)
1132
+ }
1133
+ const canonical = canonicalPathProjector(renamesByFrom, topologyOrd, topologyParents)
1134
+ for (const [path, pathRows] of rawVersions) {
1135
+ for (const row of pathRows) for (const head of canonical(path, row.hash).filter((candidate) => currentPaths.has(candidate))) {
1136
+ const rows = versions.get(head) ?? []
1137
+ if (!rows.some((existing) => existing.hash === row.hash)) rows.push(row)
1138
+ 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)
1150
+ }
1151
+ }
1152
+ for (const [path, mergeEvents] of mergeIndex.resolutions) {
1153
+ if (!isSpecMd(path)) continue
1154
+ for (const { hash } of mergeEvents) for (const head of canonical(path, hash)) {
1155
+ const rows = versions.get(head) ?? []
1156
+ const hs = stats.get(head) ?? new Map<string, DiffStat>()
1157
+ const version = commitVersions.get(hash)
1158
+ if (!version || rows.some((row) => row.hash === hash)) continue
1159
+ rows.push(version)
1160
+ hs.set(hash, { additions: 0, deletions: 0, files: 1 })
1161
+ mergeVersions.add(`${head}\0${hash}`)
1162
+ versions.set(head, rows)
1163
+ stats.set(head, hs)
230
1164
  }
231
1165
  }
232
- return { versions, stats }
1166
+ for (const rows of versions.values()) {
1167
+ rows.sort((a, b) => (commitOrder.get(a.hash) ?? Number.MAX_SAFE_INTEGER) - (commitOrder.get(b.hash) ?? Number.MAX_SAFE_INTEGER))
1168
+ }
1169
+ return { versions, stats, mergeVersions }
233
1170
  }
234
1171
 
235
1172
  // pure lookups over a prebuilt index (no git). rowsFor drops pure-rename rows (0/0) so a move isn't a version.
236
1173
  export function rowsFor(idx: HistoryIndex, relPath: string): Version[] {
237
1174
  const rows = idx.versions.get(relPath) ?? []
238
1175
  const st = idx.stats.get(relPath)
239
- return rows.filter((v) => { const s = st?.get(v.hash); return s != null && s.additions + s.deletions > 0 })
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
+ })
240
1180
  }
241
1181
  export function statsFor(idx: HistoryIndex, relPath: string): Map<string, DiffStat> {
242
1182
  return idx.stats.get(relPath) ?? new Map()
@@ -252,7 +1192,7 @@ export async function pathsStats(root: string, paths: string[]): Promise<Map<str
252
1192
  let cur = ''
253
1193
  for (const line of out.split('\n')) {
254
1194
  const t = line.trim()
255
- if (/^[0-9a-f]{7,40}$/.test(t)) { cur = t; continue }
1195
+ if (isGitObjectId(root, t)) { cur = t; continue }
256
1196
  const n = line.match(/^(\d+|-)\t(\d+|-)\t/)
257
1197
  if (n && cur) {
258
1198
  const s = m.get(cur) ?? { additions: 0, deletions: 0, files: 0 }
@@ -273,68 +1213,384 @@ export async function fileDiffAt(root: string, relPath: string, hash: string): P
273
1213
  return gitA(['-C', root, '-c', 'core.quotePath=false', 'show', '-M', '--format=', hash, '--', at])
274
1214
  }
275
1215
 
276
- // A cached `git log` over HEAD (HEAD-keyed like historyIndex), enriched with parent edges so "newer than
1216
+ // A `git log` over HEAD, enriched with parent edges so "newer than
277
1217
  // the spec" is answered by true DAG reachability, never by a log-position/date compare (a linear
278
1218
  // order can't encode a branching history's partial order and silently under-reports — back-dated
279
1219
  // branches, adoption). driftFor()/ancestorsOf() are then pure in-memory lookups. `acks`/`specNodes`
280
1220
  // carry the Spec-OK convention (see driftFor): acks[hash] = node ids declared still-valid via
281
1221
  // `Spec-OK:` trailers; specNodes[hash] = node ids whose spec.md it touched.
282
1222
  export type DriftIndex = {
1223
+ tip?: string
283
1224
  ord: Map<string, number> // hash -> dense id from the walk: a bitset slot, NEVER an order to compare
284
1225
  parents: Map<string, string[]> // hash -> parent hashes (the DAG edges, from the same walk)
285
- fileCommits: Map<string, string[]>
286
- acks: Map<string, Set<string>> // commit hash -> node ids acknowledged via `Spec-OK:` trailers
1226
+ fileEvents: Map<string, DriftPathEvent[]>
1227
+ lineageEvents: Map<string, DriftPathEvent[]> // immutable events keyed by terminal rename identity, present or deleted
1228
+ lineageKeys: (path: string, revision: string) => string[]
1229
+ resolutionEvents?: Map<string, DriftPathEvent[]> // merge-authored all-parent lines projected to this path
1230
+ acks: Map<string, Set<string>> // tree-unchanged checkpoint hash -> node ids acknowledged via `Spec-OK:`
1231
+ selfAcks?: Map<string, Set<string>> // content commit hash -> node ids acknowledged for that commit only
287
1232
  specNodes: Map<string, Set<string>> // commit hash -> node ids whose spec.md it touched (its versions)
288
1233
  anc: Map<string, Uint8Array> // memoized reachability bitsets, lazily built per queried sha
289
1234
  }
290
- const driftIdxCache = new Map<string, Promise<DriftIndex>>() // HEAD-keyed LRU, same shape as indexCache above
1235
+ export type DriftPathEvent = {
1236
+ commit: string
1237
+ historicalPath: string
1238
+ parents: { commit: string; historicalPath: string }[]
1239
+ }
1240
+
1241
+ export type DiffLineRange = [number, number]
1242
+ export type CombinedDiffOwnedChanges = { after: DiffLineRange[]; before: DiffLineRange[][]; parentPaths: string[] }
1243
+
1244
+ // Dense combined diff prefixes have one column per parent. Ownership is a LINE fact, not a hunk fact:
1245
+ // all `+` means the result authored a line absent from every parent; all `-` means it deleted a line present
1246
+ // in every parent. Mixed columns inherit from at least one parent. Track every cursor through all displayed
1247
+ // lines so adjacent mixed/owned rows never widen one another.
1248
+ export function combinedDiffOwnedChanges(patch: string): Map<string, CombinedDiffOwnedChanges> {
1249
+ const byPath = new Map<string, CombinedDiffOwnedChanges>()
1250
+ let path: string | null = null
1251
+ let parents = 0
1252
+ let parentLines: number[] = []
1253
+ let parentPaths: string[] = []
1254
+ let resultLine = 0
1255
+ let inHunk = false
1256
+
1257
+ for (const line of patch.split('\n')) {
1258
+ if (line.startsWith('diff --cc ')) {
1259
+ path = line.slice('diff --cc '.length)
1260
+ parents = 0
1261
+ parentPaths = []
1262
+ inHunk = false
1263
+ continue
1264
+ }
1265
+ if (!inHunk && path !== null && line.startsWith('--- ')) {
1266
+ const raw = line.slice(4)
1267
+ parentPaths.push(raw === '/dev/null' ? path : raw.replace(/^a\//, ''))
1268
+ continue
1269
+ }
1270
+ const header = line.match(/^(@{3,}) ((?:-\d+(?:,\d+)? )+)\+(\d+)(?:,\d+)? @+/)
1271
+ if (header) {
1272
+ parents = header[1].length - 1
1273
+ parentLines = [...header[2].matchAll(/-(\d+)/g)].map((match) => Number(match[1]))
1274
+ resultLine = Number(header[3])
1275
+ inHunk = path !== null && parents >= 2 && parentLines.length === parents
1276
+ continue
1277
+ }
1278
+ if (!inHunk || path === null || line.startsWith('\')) continue
1279
+ const prefix = line.slice(0, parents)
1280
+ if (prefix.length !== parents || !/^[ +\-]+$/.test(prefix)) {
1281
+ inHunk = false
1282
+ continue
1283
+ }
291
1284
 
292
- async function buildDriftIndex(root: string): Promise<DriftIndex> {
1285
+ const authoredAfter = [...prefix].every((c) => c === '+')
1286
+ const authoredBefore = [...prefix].every((c) => c === '-')
1287
+ if ((authoredAfter || authoredBefore) && parentPaths.length !== parents)
1288
+ throw new Error(`combined diff for '${path}' declared ${parents} parents but exposed ${parentPaths.length} parent paths`)
1289
+ const changes = byPath.get(path) ?? {
1290
+ after: [],
1291
+ before: Array.from({ length: parents }, () => []),
1292
+ parentPaths: [...parentPaths],
1293
+ }
1294
+ if (authoredAfter) changes.after.push([Math.max(1, resultLine), Math.max(1, resultLine)])
1295
+ if (authoredBefore) {
1296
+ for (let parent = 0; parent < parents; parent++) {
1297
+ const point = Math.max(1, parentLines[parent])
1298
+ changes.before[parent].push([point, point])
1299
+ }
1300
+ }
1301
+ if (authoredAfter || authoredBefore) byPath.set(path, changes)
1302
+ const resultExists = prefix.includes('+') || [...prefix].every((c) => c === ' ')
1303
+ for (let parent = 0; parent < parents; parent++)
1304
+ if (prefix[parent] === '-' || (prefix[parent] === ' ' && resultExists)) parentLines[parent]++
1305
+ if (resultExists) resultLine++
1306
+ }
1307
+ return byPath
1308
+ }
1309
+ type MergeResolutionEvent = { hash: string; parentPaths: string[] }
1310
+ type MergeRenameEvent = { hash: string; from: string; to: string }
1311
+ type MergeHistoryEvents = { resolutions: Map<string, MergeResolutionEvent[]>; renames: MergeRenameEvent[] }
1312
+ function parseMergeHistoryEvents(out: string): MergeHistoryEvents {
1313
+ const resolutions = new Map<string, MergeResolutionEvent[]>()
1314
+ const renames: MergeRenameEvent[] = []
1315
+ for (const rec of out.split(RS)) {
1316
+ const normalized = rec.replace(/^\n/, '')
1317
+ const newline = normalized.indexOf('\n')
1318
+ const hash = (newline < 0 ? normalized : normalized.slice(0, newline)).trim()
1319
+ if (!hash) continue
1320
+ const patch = newline < 0 ? '' : normalized.slice(newline + 1)
1321
+ for (const line of patch.split('\n')) {
1322
+ const raw = line.match(/^(:{2,})[0-7]{6}(?: [0-7]{6})+ (?:[0-9a-f]+ )+[A-Z]+\t/)
1323
+ if (!raw) continue
1324
+ const parentCount = raw[1].length
1325
+ const fields = line.split('\t')
1326
+ const status = fields[0].trim().split(/\s+/).at(-1) ?? ''
1327
+ const paths = fields.slice(1)
1328
+ if (status.length !== parentCount || paths.length !== parentCount + 1)
1329
+ throw new Error(`combined raw diff for ${hash} exposed ${status.length} statuses and ${paths.length} paths for ${parentCount} parents`)
1330
+ const resultPath = paths[parentCount]
1331
+ for (let parent = 0; parent < parentCount; parent++) {
1332
+ if (status[parent] !== 'R' || paths[parent] === resultPath) continue
1333
+ renames.push({ hash, from: paths[parent], to: resultPath })
1334
+ }
1335
+ }
1336
+ for (const [path, changes] of combinedDiffOwnedChanges(patch)) {
1337
+ const events = resolutions.get(path) ?? []
1338
+ events.push({ hash, parentPaths: changes.parentPaths })
1339
+ resolutions.set(path, events)
1340
+ }
1341
+ }
1342
+ return { resolutions, renames }
1343
+ }
1344
+
1345
+ async function mergeHistoryEvents(
1346
+ root: string,
1347
+ tip: string,
1348
+ transient: boolean,
1349
+ order: Map<string, number>,
1350
+ reachable: Set<string>,
1351
+ useCache = true,
1352
+ ): Promise<MergeHistoryEvents> {
1353
+ if (!useCache) return parseMergeHistoryEvents(await strictEventGit(['-C', root, '-c', 'core.quotePath=false',
1354
+ '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,
1356
+ indexEventRequests(root, tip, order, reachable).merge, !transient, useCache))
1357
+ }
1358
+
1359
+ async function buildDriftIndex(root: string, tip: string, transient: boolean, useCache = true, shared?: SharedIndexInputs): Promise<DriftIndex> {
293
1360
  const ord = new Map<string, number>(), parents = new Map<string, string[]>()
294
- const fileCommits = new Map<string, string[]>()
295
- const acks = new Map<string, Set<string>>(), specNodes = new Map<string, Set<string>>()
296
- const idx: DriftIndex = { ord, parents, fileCommits, acks, specNodes, anc: new Map() }
297
- // RS-delimited records: `<hash>US<parents>US<comma-joined Spec-OK values>` on line 1, then the
298
- // --name-only file list. `valueonly,separator` collapses the trailer block to one line so it never
299
- // collides with the file names below it (a raw `%b` body would interleave and be unparseable).
300
- const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'log', '--name-only',
301
- `--format=${RS}%H${US}%P${US}%(trailers:key=Spec-OK,valueonly,separator=%x2C)`, 'HEAD'])
1361
+ const fileEvents = new Map<string, DriftPathEvent[]>()
1362
+ const lineageEvents = new Map<string, DriftPathEvent[]>()
1363
+ 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>()
1365
+ 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]),
1370
+ strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
1371
+ ])
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)
302
1387
  if (!out) return idx
1388
+ const rawFileEvents = new Map<string, DriftPathEvent[]>()
1389
+ const renamesByFrom = new Map<string, RenameProjectionEvent[]>()
303
1390
  let i = 0
304
1391
  for (const rec of out.split(RS)) {
305
1392
  const r = rec.replace(/^\n/, '')
306
1393
  if (!r) continue
307
1394
  const lines = r.split('\n')
308
- const [hash, parentStr = '', ackStr = ''] = lines[0].split(US)
1395
+ const [hash, parentStr = '', tree = '', ackStr = ''] = lines[0].split(US)
309
1396
  if (!hash) continue
1397
+ trees.set(hash, tree)
310
1398
  if (!ord.has(hash)) {
311
1399
  ord.set(hash, i++)
312
1400
  parents.set(hash, parentStr.split(' ').filter(Boolean))
313
1401
  }
314
1402
  const ackSet = new Set(ackStr.split(',').map((s) => s.trim()).filter(Boolean))
315
- if (ackSet.size) acks.set(hash, ackSet)
1403
+ if (ackSet.size) ackCandidates.set(hash, ackSet)
1404
+ const merge = parentStr.split(' ').filter(Boolean).length > 1
316
1405
  for (const line of lines.slice(1)) {
317
- if (!line) continue
318
- let arr = fileCommits.get(line); if (!arr) { arr = []; fileCommits.set(line, arr) }
319
- arr.push(hash)
320
- if (isSpecMd(line)) {
321
- let ns = specNodes.get(hash); if (!ns) { ns = new Set(); specNodes.set(hash, ns) }
322
- ns.add(nodeIdOf(line))
1406
+ const stat = line.match(/^(-|\d+)\t(-|\d+)\t(.+)$/)
1407
+ if (!stat) continue
1408
+ const { from, to } = parseStatPath(stat[3])
1409
+ if (!merge) {
1410
+ const events = rawFileEvents.get(to) ?? []
1411
+ const event: DriftPathEvent = {
1412
+ commit: hash,
1413
+ historicalPath: to,
1414
+ parents: (parents.get(hash) ?? []).slice(0, 1).map((commit) => ({ commit, historicalPath: from })),
1415
+ }
1416
+ events.push(event)
1417
+ rawFileEvents.set(to, events)
1418
+ }
1419
+ if (from !== to) {
1420
+ const renames = renamesByFrom.get(from) ?? []
1421
+ renames.push({ hash, to })
1422
+ renamesByFrom.set(from, renames)
1423
+ }
1424
+ }
1425
+ }
1426
+ const mergeIndex = shared?.mergeIndex
1427
+ ?? await mergeHistoryEvents(root, tip, transient, topologyOrder, topologyReachable, useCache)
1428
+ for (const rename of mergeIndex.renames) {
1429
+ const renames = renamesByFrom.get(rename.from) ?? []
1430
+ renames.push({ hash: rename.hash, to: rename.to })
1431
+ renamesByFrom.set(rename.from, renames)
1432
+ }
1433
+ const canonical = canonicalPathProjector(renamesByFrom, topologyOrder, topologyParents)
1434
+ idx.lineageKeys = canonical
1435
+ const addEvent = (path: string, event: DriftPathEvent, target: Map<string, DriftPathEvent[]>) => {
1436
+ const keys = canonical(path, event.commit)
1437
+ for (const key of keys) {
1438
+ const events = target.get(key) ?? []
1439
+ if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath)) events.push(event)
1440
+ target.set(key, events)
1441
+ }
1442
+ }
1443
+ 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))) {
1446
+ const events = fileEvents.get(head) ?? []
1447
+ if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath)) events.push(event)
1448
+ fileEvents.set(head, events)
1449
+ if (isSpecMd(head)) {
1450
+ const nodes = specNodes.get(event.commit) ?? new Set<string>()
1451
+ nodes.add(nodeIdOf(head))
1452
+ specNodes.set(event.commit, nodes)
1453
+ }
1454
+ }
1455
+ }
1456
+ for (const [hash, nodes] of ackCandidates) {
1457
+ const parentList = parents.get(hash) ?? []
1458
+ const firstParent = parentList[0]
1459
+ const checkpoint = parentList.length === 1 && !!firstParent && trees.get(hash) === trees.get(firstParent)
1460
+ ;(checkpoint ? acks : selfAcks).set(hash, nodes)
1461
+ }
1462
+ for (const [path, mergeEvents] of mergeIndex.resolutions) for (const mergeEvent of mergeEvents) {
1463
+ const mergeParents = topologyParents.get(mergeEvent.hash) ?? []
1464
+ if (mergeParents.length !== mergeEvent.parentPaths.length)
1465
+ throw new Error(`merge ${mergeEvent.hash} has ${mergeParents.length} topology parents but ${mergeEvent.parentPaths.length} combined parent paths`)
1466
+ const event: DriftPathEvent = {
1467
+ commit: mergeEvent.hash,
1468
+ historicalPath: path,
1469
+ parents: mergeParents.map((commit, index) => ({ commit, historicalPath: mergeEvent.parentPaths[index] })),
1470
+ }
1471
+ addEvent(path, event, lineageEvents)
1472
+ for (const head of canonical(path, mergeEvent.hash).filter((candidate) => currentPaths.has(candidate))) {
1473
+ const events = idx.resolutionEvents!.get(head) ?? []
1474
+ if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath)) events.push(event)
1475
+ idx.resolutionEvents!.set(head, events)
1476
+ if (isSpecMd(head)) {
1477
+ const nodes = specNodes.get(mergeEvent.hash) ?? new Set<string>()
1478
+ nodes.add(nodeIdOf(head))
1479
+ specNodes.set(mergeEvent.hash, nodes)
323
1480
  }
324
1481
  }
325
1482
  }
326
1483
  return idx
327
1484
  }
328
- export function driftIndex(root: string): Promise<DriftIndex> {
1485
+ export function driftIndex(root: string, tip = 'HEAD'): Promise<DriftIndex> {
1486
+ if (tip !== 'HEAD') {
1487
+ const resolved = git(['-C', root, 'rev-parse', `${tip}^{commit}`]).trim()
1488
+ if (pendingOnlyIssues(root, resolved)) {
1489
+ const parent = pendingParent(root, resolved)
1490
+ if (parent) {
1491
+ const head = headOrEmpty(root)
1492
+ if (head === parent) return driftIndex(root)
1493
+ return buildDriftIndex(root, parent, true, true)
1494
+ }
1495
+ }
1496
+ return buildDriftIndex(root, resolved, true, true)
1497
+ }
329
1498
  const head = headOrEmpty(root) // filesystem HEAD, no subprocess — see historyIndex
330
- if (!head) return buildDriftIndex(root)
331
- const hit = lruGet(driftIdxCache, head)
1499
+ if (!head) return buildDriftIndex(root, 'HEAD', false, true)
1500
+ const cacheKey = `${rootKey(root)}\0${head}\0${gitInterpretationKey(root)}`
1501
+ touchRoot(driftRoots, driftIdxCache, root, cacheKey)
1502
+ const hit = driftIdxCache.get(cacheKey)
332
1503
  if (hit) return hit
333
- const p = buildDriftIndex(root)
334
- p.catch(() => { driftIdxCache.delete(head) })
335
- lruPut(driftIdxCache, head, p)
1504
+ const p = buildDriftIndex(root, head.startsWith('unborn:') ? 'HEAD' : head, false, true)
1505
+ p.catch(() => { dropFailed(driftIdxCache, cacheKey, p) })
1506
+ driftIdxCache.set(cacheKey, p)
336
1507
  return p
337
1508
  }
1509
+
1510
+ function topologyProjection(out: string): { order: Map<string, number>; reachable: Set<string> } {
1511
+ const order = new Map<string, number>(), reachable = new Set<string>()
1512
+ let position = 0
1513
+ for (const line of out.trim().split('\n')) {
1514
+ const hash = line.split(' ', 1)[0]
1515
+ if (!hash) continue
1516
+ order.set(hash, position++)
1517
+ reachable.add(hash)
1518
+ }
1519
+ return { order, reachable }
1520
+ }
1521
+
1522
+ async function buildIndexPair(root: string, tip: string, transient: boolean, useCache = true): Promise<[HistoryIndex, DriftIndex]> {
1523
+ const [allPathsOut, topologyOut] = await Promise.all([
1524
+ strictEventGit(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--name-only', tip]),
1525
+ strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
1526
+ ])
1527
+ const topology = topologyProjection(topologyOut)
1528
+ const requests = indexEventRequests(root, tip, topology.order, topology.reachable)
1529
+ const streams = await deriveEventStreams(root, tip, EVENT_STREAM_KINDS.map((kind) => requests[kind]), !transient, useCache)
1530
+ const shared: SharedIndexInputs = {
1531
+ allPathsOut,
1532
+ topologyOut,
1533
+ historyOut: streams.get('numstat') ?? '',
1534
+ driftOut: streams.get('drift-numstat') ?? '',
1535
+ mergeIndex: parseMergeHistoryEvents(streams.get('merge') ?? ''),
1536
+ }
1537
+ streams.clear()
1538
+ return Promise.all([
1539
+ buildIndex(root, tip, transient, useCache, shared),
1540
+ buildDriftIndex(root, tip, transient, useCache, shared),
1541
+ ])
1542
+ }
1543
+
1544
+ // Standing correctness oracle: same projection, but every immutable stream comes from Git root history.
1545
+ export function sourceIndexesFull(root: string, tip = 'HEAD'): Promise<[HistoryIndex, DriftIndex]> {
1546
+ const head = tip === 'HEAD' ? headOrEmpty(root) : ''
1547
+ const resolved = tip === 'HEAD'
1548
+ ? (!head || head.startsWith('unborn:') ? 'HEAD' : head)
1549
+ : git(['-C', root, 'rev-parse', `${tip}^{commit}`]).trim()
1550
+ return buildIndexPair(root, resolved, true, false)
1551
+ }
1552
+
1553
+ // History and drift are one product projection. Building them together is what gives one lint one ledger
1554
+ // transaction; the public single-index functions remain for consumers that genuinely need only one side.
1555
+ export function sourceIndexes(root: string, tip = 'HEAD'): Promise<[HistoryIndex, DriftIndex]> {
1556
+ if (tip !== 'HEAD') {
1557
+ const resolved = git(['-C', root, 'rev-parse', `${tip}^{commit}`]).trim()
1558
+ if (pendingOnlyIssues(root, resolved)) {
1559
+ const parent = pendingParent(root, resolved)
1560
+ if (parent) return headOrEmpty(root) === parent
1561
+ ? sourceIndexes(root)
1562
+ : buildIndexPair(root, parent, true, true)
1563
+ }
1564
+ return buildIndexPair(root, resolved, true, true)
1565
+ }
1566
+ const head = headOrEmpty(root)
1567
+ if (!head) return buildIndexPair(root, 'HEAD', false, true)
1568
+ const cacheKey = `${rootKey(root)}\0${head}\0${gitInterpretationKey(root)}`
1569
+ touchRoot(indexRoots, indexCache, root, cacheKey)
1570
+ touchRoot(driftRoots, driftIdxCache, root, cacheKey)
1571
+ const historyHit = indexCache.get(cacheKey), driftHit = driftIdxCache.get(cacheKey)
1572
+ if (historyHit && driftHit) return Promise.all([historyHit, driftHit])
1573
+
1574
+ const pair = buildIndexPair(root, head.startsWith('unborn:') ? 'HEAD' : head, false, true)
1575
+ const historyPromise = pair.then(([history]) => history)
1576
+ const driftPromise = pair.then(([, drift]) => drift)
1577
+ historyPromise.catch(() => { dropFailed(indexCache, cacheKey, historyPromise) })
1578
+ driftPromise.catch(() => { dropFailed(driftIdxCache, cacheKey, driftPromise) })
1579
+ indexCache.set(cacheKey, historyPromise)
1580
+ driftIdxCache.set(cacheKey, driftPromise)
1581
+ return pair
1582
+ }
1583
+
1584
+ export function historyCacheStats(): { historyHeads: number; driftHeads: number; historyRoots: number; driftRoots: number } {
1585
+ return { historyHeads: indexCache.size, driftHeads: driftIdxCache.size, historyRoots: indexRoots.size, driftRoots: driftRoots.size }
1586
+ }
1587
+ // Tests deliberately clear process-local promises and path identity to exercise both cold and read-back paths.
1588
+ // Production callers retain the bounded index caches; this is not a correctness escape hatch.
1589
+ export function resetHistoryCachesForTests(): void {
1590
+ indexCache.clear(); driftIdxCache.clear(); indexRoots.clear(); driftRoots.clear()
1591
+ eventPathMemo.clear(); gitObjectFormatMemo.clear()
1592
+ }
1593
+ export function historyEventCachePathForTests(root: string): string { return eventCacheLocation(root).path }
338
1594
  // the reachability set of `sha` — itself plus every ancestor — as a bitset over the walk's dense ids.
339
1595
  // Built once per queried sha by following parent edges in memory (no git fork), memoized on the index;
340
1596
  // a bitset costs history-length BITS, so hundreds of cached shas stay cheap on the board hot path.
@@ -366,16 +1622,24 @@ export function inAncestors(idx: DriftIndex, bits: Uint8Array, sha: string): boo
366
1622
  return o !== undefined && (bits[o >> 3] & (1 << (o & 7))) !== 0
367
1623
  }
368
1624
 
1625
+ export function commitReachable(idx: DriftIndex, sha: string): boolean {
1626
+ return ancestorsOf(idx, sha) !== undefined
1627
+ }
1628
+
369
1629
  // the valid Spec-OK coverage for a node's version commit: `sinceHash` is the node's OWN latest version,
370
1630
  // so the node(s) it's a version of (specNodes[sinceHash]) name the node being measured; an ack counts
371
1631
  // only if its `Spec-OK:` set names one of those — `Spec-OK: A` quiets A's drift, never B's. An ack that
372
1632
  // is itself an ancestor of the version can't speak for it (a re-version invalidates older acks); a valid
373
1633
  // ack quiets exactly the commits reachable from it. Shared by driftFor (the count) and the anchor
374
1634
  // engine's windowCommits (the commit set) so both read ONE ack rule.
375
- export function ackCoverFor(idx: DriftIndex, sinceHash: string): Uint8Array[] {
1635
+ function targetNodes(idx: DriftIndex, sinceHash: string, nodeId?: string): Set<string> | undefined {
1636
+ return nodeId ? new Set([nodeId]) : idx.specNodes.get(sinceHash)
1637
+ }
1638
+
1639
+ export function ackCoverFor(idx: DriftIndex, sinceHash: string, nodeId?: string): Uint8Array[] {
376
1640
  const base = ancestorsOf(idx, sinceHash)
377
1641
  if (!base) return []
378
- const targets = idx.specNodes.get(sinceHash)
1642
+ const targets = targetNodes(idx, sinceHash, nodeId)
379
1643
  const cover: Uint8Array[] = []
380
1644
  if (targets) {
381
1645
  for (const [h, ackSet] of idx.acks) {
@@ -388,21 +1652,47 @@ export function ackCoverFor(idx: DriftIndex, sinceHash: string): Uint8Array[] {
388
1652
  return cover
389
1653
  }
390
1654
 
1655
+ export function selfAckCovers(idx: DriftIndex, sinceHash: string, hash: string, nodeId?: string): boolean {
1656
+ const targets = targetNodes(idx, sinceHash, nodeId)
1657
+ const declared = idx.selfAcks?.get(hash)
1658
+ return !!targets && !!declared && [...targets].some((node) => declared.has(node))
1659
+ }
1660
+
1661
+ export function pathEvents(idx: DriftIndex, path: string): DriftPathEvent[] {
1662
+ return [...(idx.fileEvents.get(path) ?? []), ...(idx.resolutionEvents?.get(path) ?? [])]
1663
+ }
1664
+
1665
+ // Exact base..tip window for consumers whose subject is not a spec version. The path is an identity at
1666
+ // `identityRevision`; projecting it and every immutable event to the same terminal lineage keys preserves
1667
+ // rename chains, parallel forks, path reuse, and lineages deleted before the tip.
1668
+ export function pathRangeEvents(idx: DriftIndex, sinceHash: string, path: string, identityRevision = idx.tip ?? sinceHash): DriftPathEvent[] | null {
1669
+ const base = ancestorsOf(idx, sinceHash)
1670
+ if (!base) return null
1671
+ const seen = new Set<string>()
1672
+ const events = idx.lineageKeys(path, identityRevision).flatMap((key) => idx.lineageEvents.get(key) ?? [])
1673
+ return events.filter((event) => {
1674
+ const key = `${event.commit}\0${event.historicalPath}\0${event.parents.map((parent) => `${parent.commit}:${parent.historicalPath}`).join('\x1e')}`
1675
+ if (seen.has(key) || inAncestors(idx, base, event.commit)) return false
1676
+ seen.add(key)
1677
+ return true
1678
+ })
1679
+ }
1680
+
1681
+ export function driftPathWindow(idx: DriftIndex, sinceHash: string, path: string, nodeId?: string): DriftPathEvent[] | null {
1682
+ const base = ancestorsOf(idx, sinceHash)
1683
+ if (!base) return null
1684
+ const events = pathEvents(idx, path).filter((event) => !inAncestors(idx, base, event.commit))
1685
+ const cover = ackCoverFor(idx, sinceHash, nodeId)
1686
+ return events.filter((event) => !cover.some((a) => inAncestors(idx, a, event.commit))
1687
+ && !selfAckCovers(idx, sinceHash, event.commit, nodeId))
1688
+ }
1689
+
391
1690
  // pure lookup, no git: a commit to `path` is drift iff it is NOT an ancestor of `sinceHash` — it lies
392
1691
  // in `sinceHash..HEAD` by true DAG reachability, wherever a date-ordered log happens to place it.
393
1692
  // An off-history `sinceHash` → 0: no basis on HEAD to measure from.
394
- export function driftFor(idx: DriftIndex, sinceHash: string, path: string): number {
1693
+ export function driftFor(idx: DriftIndex, sinceHash: string, path: string, nodeId?: string): number {
395
1694
  if (!sinceHash) return 0
396
- const base = ancestorsOf(idx, sinceHash)
397
- if (!base) return 0
398
- const ackCover = ackCoverFor(idx, sinceHash)
399
- let n = 0
400
- for (const h of idx.fileCommits.get(path) ?? []) {
401
- if (inAncestors(idx, base, h)) continue // reachable from the version → not drift
402
- if (ackCover.some((a) => inAncestors(idx, a, h))) continue // covered by an ack → acknowledged
403
- n++
404
- }
405
- return n
1695
+ return new Set(driftPathWindow(idx, sinceHash, path, nodeId)?.map((event) => event.commit) ?? []).size
406
1696
  }
407
1697
 
408
1698
  // the paths git is about to commit (index vs HEAD), scoping the pre-commit drift gate to this commit's files.
@@ -484,21 +1774,29 @@ export function mergeConflicts(wtPath: string, mainRef = 'main'): Promise<boolea
484
1774
  })
485
1775
  }
486
1776
 
487
- // this worktree's spec ops vs main: one working-tree diff off the fork point (folds committed+staged+unstaged),
488
- // a `status --porcelain` pass to add untracked spec.md, a third diff vs HEAD to mark what's committed.
1777
+ // this worktree's spec ops vs main ([[worktree-linker]]): an op must BOTH differ from main's current tip
1778
+ // (the proposal the vs-main working diff supplies the op set and each op's TYPE, so merge terms are
1779
+ // spoken: content equal to main is no op, an existing node reads `edited` never `added`) AND have been
1780
+ // touched by this branch since its fork point (attribution — main's own post-fork movement is not this
1781
+ // worktree's op). A `status --porcelain` pass adds untracked spec.md, a third diff vs HEAD marks committed.
489
1782
  export async function worktreeSpecDelta(wtPath: string, mainRef: string, baseHint?: string): Promise<NodeOp[]> {
490
1783
  const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
491
1784
  // fork point = where this worktree branched from main; '' (no common ancestor / unreadable ref) falls
492
1785
  // back to mainRef so we still surface changes rather than going silent. The caller (cachedDelta) already
493
1786
  // computes this same merge-base to key its cache, so it passes it in to avoid a redundant subprocess.
494
1787
  const base = baseHint || (await run(['merge-base', mainRef, 'HEAD'])).trim() || mainRef
495
- // the three queries are independent — run them in parallel.
496
- const [workOut, commOut, statusOut] = await Promise.all([
1788
+ // the four queries are independent — run them in parallel.
1789
+ const [mainOut, workOut, commOut, statusOut] = await Promise.all([
1790
+ run(['diff', '--name-status', '-M', mainRef, '--', '.spec']),
497
1791
  run(['diff', '--name-status', '-M', base, '--', '.spec']),
498
1792
  run(['diff', '--name-status', '-M', `${base}...HEAD`, '--', '.spec']),
499
1793
  run(['status', '--porcelain', '--untracked-files=all', '--', '.spec']),
500
1794
  ])
501
- const work = parseNameStatus(workOut)
1795
+ const proposals = parseNameStatus(mainOut)
1796
+ // the branch's own footprint since its fork point — both sides of every row, so a rename matches
1797
+ // whichever side the vs-main diff names.
1798
+ const touched = new Set<string>()
1799
+ for (const r of parseNameStatus(workOut)) { touched.add(r.to); if (r.from) touched.add(r.from) }
502
1800
  const committed = new Set(parseNameStatus(commOut).map((r) => r.to))
503
1801
  // --untracked-files=all: list every untracked spec.md individually (the default collapses a wholly
504
1802
  // new node's directory to `.spec/.../node/`, which we'd never recognise as a spec.md add).
@@ -514,9 +1812,10 @@ export async function worktreeSpecDelta(wtPath: string, mainRef: string, baseHin
514
1812
 
515
1813
  const codeFor: Record<string, NodeOp['op']> = { A: 'added', M: 'edited', D: 'deleted', R: 'moved', C: 'added', T: 'edited' }
516
1814
  const ops: NodeOp[] = [], seen = new Set<string>()
517
- for (const r of work) {
1815
+ for (const r of proposals) {
518
1816
  const path = r.code === 'D' ? r.from : r.to
519
1817
  if (!isSpecMd(path)) continue
1818
+ if (!touched.has(r.to) && !touched.has(r.from)) continue // main moved it, not this branch → no op
520
1819
  seen.add(path)
521
1820
  const op = codeFor[r.code] ?? 'edited'
522
1821
  ops.push({