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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +25 -1
- package/spec-cli/hooks/dispatch.sh +24 -1
- package/spec-cli/src/anchors.ts +192 -58
- package/spec-cli/src/attach.ts +11 -8
- package/spec-cli/src/claude-headless.ts +116 -66
- package/spec-cli/src/cli.ts +221 -72
- package/spec-cli/src/client.ts +143 -5
- package/spec-cli/src/gateway-hub.ts +2 -6
- package/spec-cli/src/gateway.ts +96 -11
- package/spec-cli/src/git.ts +1395 -96
- package/spec-cli/src/graph.ts +21 -17
- package/spec-cli/src/graphCache.ts +405 -61
- package/spec-cli/src/graphStream.ts +581 -182
- package/spec-cli/src/guide.ts +13 -3
- package/spec-cli/src/harness.ts +965 -70
- package/spec-cli/src/headless-controller.ts +63 -0
- package/spec-cli/src/help.ts +120 -64
- package/spec-cli/src/host-resources.ts +706 -0
- package/spec-cli/src/host.ts +1 -0
- package/spec-cli/src/index.ts +95 -21
- package/spec-cli/src/init.ts +47 -8
- package/spec-cli/src/issues.ts +36 -3
- package/spec-cli/src/layout.ts +193 -37
- package/spec-cli/src/lint.ts +152 -27
- package/spec-cli/src/localIssues.ts +5 -1
- package/spec-cli/src/maintenance-wrapper.ts +276 -0
- package/spec-cli/src/materialize.ts +6 -7
- package/spec-cli/src/opencode-headless.ts +117 -7
- package/spec-cli/src/opencode.ts +8 -4
- package/spec-cli/src/pi-headless.ts +18 -60
- package/spec-cli/src/process-identity.ts +49 -0
- package/spec-cli/src/project-store.ts +17 -0
- package/spec-cli/src/pty-bridge.ts +37 -7
- package/spec-cli/src/pty-helper.mjs +16 -6
- package/spec-cli/src/pty-native-helper.mjs +22 -0
- package/spec-cli/src/reviews.ts +17 -13
- package/spec-cli/src/runtime-ownership.ts +99 -0
- package/spec-cli/src/session-maintenance.ts +750 -0
- package/spec-cli/src/session-timeline.ts +29 -23
- package/spec-cli/src/sessions.ts +1262 -200
- package/spec-cli/src/sh.ts +3 -0
- package/spec-cli/src/source-files.ts +27 -2
- package/spec-cli/src/specs.ts +58 -25
- package/spec-cli/src/supervise.ts +31 -11
- package/spec-cli/src/uninstall.ts +7 -6
- package/spec-cli/templates/hooks/commit-msg +14 -0
- package/spec-cli/templates/hooks/pre-commit +26 -6
- package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
- package/spec-cli/templates/hooks/reference-transaction +105 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
- package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
- package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
- package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
- package/spec-dashboard/dist/assets/{PageScroll-B_dKCuXx.js → PageScroll-CAY4S4g4.js} +1 -1
- package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
- package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
- package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
- package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
- package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
- package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
- package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
- package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
- package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
- package/spec-dashboard/dist/index.html +5 -4
- package/spec-dashboard/src/reviewFilters.js +46 -8
- package/spec-dashboard/src/session.js +64 -9
- package/spec-eval/src/cli.ts +144 -32
- package/spec-eval/src/evaltab.ts +16 -9
- package/spec-eval/src/freshness.ts +336 -54
- package/spec-eval/src/scenariofresh.ts +51 -1
- package/spec-eval/src/scenarios.ts +324 -25
- package/spec-eval/src/sessioneval.ts +969 -173
- package/spec-cli/src/message-stream.ts +0 -147
- package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +0 -2
- package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +0 -2
- package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +0 -39
- package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +0 -13
- package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +0 -1
- package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +0 -1
- package/spec-dashboard/dist/assets/index-BKaTHjmU.js +0 -41
- package/spec-dashboard/dist/assets/index-DcnCaBAC.css +0 -1
package/spec-cli/src/git.ts
CHANGED
|
@@ -1,17 +1,110 @@
|
|
|
1
|
-
import { execFileSync, execFile } from 'node:child_process'
|
|
2
|
-
import {
|
|
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
|
|
9
|
-
// mount) must not pin its awaiter forever: [[graph-cache]]'s settle guarantee starts at this seam.
|
|
10
|
-
// shared helper passes a generous timeout
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
|
|
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
|
|
337
|
+
const { stdout } = await execGitForCaller(args, env)
|
|
34
338
|
return stdout
|
|
35
|
-
} catch (e: any) {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
162
|
-
// the
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
183
|
-
|
|
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
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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) {
|
|
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
|
-
|
|
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) => {
|
|
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 (
|
|
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
|
|
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
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
295
|
-
const
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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)
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
if (
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
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
|
|
335
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
488
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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({
|