spexcode 0.6.0 → 0.6.2
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 +97 -149
- package/package.json +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +27 -5
- package/spec-cli/src/cli.ts +239 -75
- package/spec-cli/src/client.ts +113 -14
- package/spec-cli/src/codex-runtime-generations.ts +97 -7
- package/spec-cli/src/commit-surgery.ts +2 -1
- package/spec-cli/src/contract-filter.ts +58 -42
- package/spec-cli/src/delivery-queue.ts +62 -4
- package/spec-cli/src/doctor.ts +2 -1
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/file-write.ts +22 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +503 -105
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +79 -11
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +355 -75
- package/spec-cli/src/help.ts +30 -23
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +109 -14
- package/spec-cli/src/init.ts +8 -17
- package/spec-cli/src/layout.ts +151 -40
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +160 -112
- package/spec-cli/src/plugin-harness.ts +29 -18
- package/spec-cli/src/process-identity.ts +13 -0
- package/spec-cli/src/reviews.ts +103 -30
- package/spec-cli/src/session-execution.ts +68 -0
- package/spec-cli/src/session-files.ts +121 -0
- package/spec-cli/src/session-follow.ts +4 -4
- package/spec-cli/src/session-reparent.ts +33 -0
- package/spec-cli/src/session-timeline.ts +163 -14
- package/spec-cli/src/session-web.ts +135 -0
- package/spec-cli/src/sessions.ts +748 -111
- package/spec-cli/src/uninstall.ts +3 -2
- package/spec-cli/templates/hooks/post-checkout +3 -0
- package/spec-cli/templates/hooks/post-merge +1 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
- package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
- package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
- package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
- package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
- package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
- package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
- package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/reviewFilters.js +5 -0
- package/spec-dashboard/src/session.js +5 -1
- package/spec-eval/src/cli.ts +13 -9
- package/spec-eval/src/evaltab.ts +32 -10
- package/spec-eval/src/freshness.ts +404 -81
- package/spec-eval/src/scenariofresh.ts +58 -8
- package/spec-eval/src/scenarios.ts +60 -12
- package/spec-eval/src/sessioneval.ts +234 -148
- package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
- package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
- package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
- package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
- package/spec-eval/src/matrix.ts +0 -693
package/spec-cli/src/git.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { execFileSync, execFile, spawn } from 'node:child_process'
|
|
2
2
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
3
|
-
import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdirSync, rmSync, renameSync, openSync, closeSync } from 'node:fs'
|
|
4
|
-
import { join, isAbsolute, resolve } from 'node:path'
|
|
3
|
+
import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdirSync, rmSync, renameSync, openSync, closeSync, accessSync, constants } from 'node:fs'
|
|
4
|
+
import { join, isAbsolute, resolve, delimiter } from 'node:path'
|
|
5
5
|
import { createHash, randomBytes } from 'node:crypto'
|
|
6
6
|
import { projectRuntimeRoot } from './project-store.js'
|
|
7
7
|
import { rootSlots, touchRoot as touchRootLru } from './root-lru.js'
|
|
8
|
+
import { processStartToken } from './process-identity.js'
|
|
8
9
|
|
|
9
10
|
const US = '\x1f', RS = '\x1e'
|
|
10
11
|
|
|
@@ -14,7 +15,26 @@ const US = '\x1f', RS = '\x1e'
|
|
|
14
15
|
// permit pool through AsyncLocalStorage, so corpus-wide Promise.all fanout queues here before spawn rather
|
|
15
16
|
// than materializing one process per worktree/eval. Calls outside that build context remain unconstrained.
|
|
16
17
|
const GIT_TIMEOUT_MS = Number(process.env.SPEXCODE_GIT_TIMEOUT_MS || 120000)
|
|
18
|
+
const GIT_SYNC_MAX_BUFFER = 1 << 27
|
|
17
19
|
export const BOARD_GIT_CONCURRENCY = 4
|
|
20
|
+
const gitByPath = new Map<string, string>()
|
|
21
|
+
|
|
22
|
+
export function gitBinary(env: NodeJS.ProcessEnv = process.env): string {
|
|
23
|
+
const path = env.PATH || ''
|
|
24
|
+
const known = gitByPath.get(path)
|
|
25
|
+
if (known) {
|
|
26
|
+
try { accessSync(known, constants.X_OK); return known } catch {}
|
|
27
|
+
}
|
|
28
|
+
for (const dir of path.split(delimiter)) {
|
|
29
|
+
const candidate = resolve(dir || '.', 'git')
|
|
30
|
+
try {
|
|
31
|
+
accessSync(candidate, constants.X_OK)
|
|
32
|
+
gitByPath.set(path, candidate)
|
|
33
|
+
return candidate
|
|
34
|
+
} catch {}
|
|
35
|
+
}
|
|
36
|
+
throw new Error('git executable not found on PATH')
|
|
37
|
+
}
|
|
18
38
|
type GitPermitPool = { acquire: (signal: AbortSignal) => Promise<() => void> }
|
|
19
39
|
type GitBuildContext = { signal: AbortSignal; permits: GitPermitPool }
|
|
20
40
|
const gitBuild = new AsyncLocalStorage<GitBuildContext>()
|
|
@@ -107,7 +127,8 @@ export function currentGitBuildAbortSignal(): AbortSignal | undefined {
|
|
|
107
127
|
return inheritedContext()?.signal
|
|
108
128
|
}
|
|
109
129
|
function warnIfTimedOut(e: any, args: string[]): void {
|
|
110
|
-
if (e?.
|
|
130
|
+
if (e?.code === 'ETIMEDOUT' || e?.spexcodeGitTimeout === true)
|
|
131
|
+
console.warn(`spec-cli: git ${args.slice(0, 6).join(' ')}… killed after ${GIT_TIMEOUT_MS}ms — child never exited`)
|
|
111
132
|
}
|
|
112
133
|
|
|
113
134
|
// strip git's hook-exported env (GIT_DIR etc.) so every call discovers the repo from the filesystem.
|
|
@@ -115,7 +136,14 @@ export function git(args: string[]): string {
|
|
|
115
136
|
const env = { ...process.env }
|
|
116
137
|
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
117
138
|
try {
|
|
118
|
-
return execFileSync(
|
|
139
|
+
return execFileSync(gitBinary(env), withBuildLimits(args), {
|
|
140
|
+
encoding: 'utf8',
|
|
141
|
+
env,
|
|
142
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
143
|
+
timeout: GIT_TIMEOUT_MS,
|
|
144
|
+
killSignal: 'SIGKILL',
|
|
145
|
+
maxBuffer: GIT_SYNC_MAX_BUFFER,
|
|
146
|
+
})
|
|
119
147
|
} catch (e: any) { warnIfTimedOut(e, args); throw e }
|
|
120
148
|
}
|
|
121
149
|
|
|
@@ -123,13 +151,13 @@ function gitBuffer(args: string[], input?: string): Buffer {
|
|
|
123
151
|
const env = { ...process.env }
|
|
124
152
|
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
125
153
|
try {
|
|
126
|
-
return execFileSync(
|
|
154
|
+
return execFileSync(gitBinary(env), withBuildLimits(args), {
|
|
127
155
|
input,
|
|
128
156
|
env,
|
|
129
157
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
130
158
|
timeout: GIT_TIMEOUT_MS,
|
|
131
159
|
killSignal: 'SIGKILL',
|
|
132
|
-
maxBuffer:
|
|
160
|
+
maxBuffer: GIT_SYNC_MAX_BUFFER,
|
|
133
161
|
})
|
|
134
162
|
} catch (e: any) { warnIfTimedOut(e, args); throw e }
|
|
135
163
|
}
|
|
@@ -166,9 +194,10 @@ export function isGitObjectId(root: string, value: string): boolean {
|
|
|
166
194
|
// source corpus never approaches the byte ceiling.
|
|
167
195
|
const BATCH_BLOB_CHUNK = 256
|
|
168
196
|
const BATCH_BLOB_MAX_BUFFER = 1 << 26
|
|
169
|
-
async function batchBuffer(args: string[], input: string, maxBuffer?: number): Promise<Buffer> {
|
|
197
|
+
async function batchBuffer(args: string[], input: string, maxBuffer?: number, extraEnv: Record<string, string> = {}): Promise<Buffer> {
|
|
170
198
|
const env = { ...process.env }
|
|
171
199
|
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
200
|
+
Object.assign(env, extraEnv)
|
|
172
201
|
try { return (await execGitForCaller(args, env, maxBuffer, input)).stdout }
|
|
173
202
|
catch (error: any) {
|
|
174
203
|
if (error?.name === 'AbortError') throw error
|
|
@@ -176,9 +205,10 @@ async function batchBuffer(args: string[], input: string, maxBuffer?: number): P
|
|
|
176
205
|
throw new Error(`git ${args.slice(2, 5).join(' ')} failed: ${String(error?.stderr || error?.message || 'unknown git error').trim()}`)
|
|
177
206
|
}
|
|
178
207
|
}
|
|
179
|
-
export async function batchRevisionOids(root: string, revisions: string[]): Promise<(string | null)[]> {
|
|
208
|
+
export async function batchRevisionOids(root: string, revisions: string[], options: { replaceObjects?: boolean } = {}): Promise<(string | null)[]> {
|
|
180
209
|
if (!revisions.length) return []
|
|
181
|
-
const
|
|
210
|
+
const extraEnv: Record<string, string> = options.replaceObjects === false ? { GIT_NO_REPLACE_OBJECTS: '1' } : {}
|
|
211
|
+
const out = (await batchBuffer(['-C', root, 'cat-file', '--batch-check=%(objectname)'], revisions.join('\n') + '\n', undefined, extraEnv)).toString('utf8')
|
|
182
212
|
const lines = out.split('\n')
|
|
183
213
|
if (lines.length - 1 !== revisions.length) throw new Error(`git cat-file --batch-check returned ${lines.length - 1} rows for ${revisions.length} revisions`)
|
|
184
214
|
return revisions.map((revision, index) => {
|
|
@@ -267,7 +297,7 @@ const GIT_MAX_BUFFER = 1 << 24
|
|
|
267
297
|
function execGit(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal, maxBuffer = GIT_MAX_BUFFER, input?: string): Promise<GitExec> {
|
|
268
298
|
return new Promise((resolve, reject) => {
|
|
269
299
|
if (signal?.aborted) { reject(gitAbortError()); return }
|
|
270
|
-
const child = spawn(
|
|
300
|
+
const child = spawn(gitBinary(env), args, { env, detached: true, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] })
|
|
271
301
|
const stdout: Buffer[] = [], stderr: Buffer[] = []
|
|
272
302
|
let stdoutBytes = 0, stderrBytes = 0, aborted = false, timedOut = false, overflow = false
|
|
273
303
|
let spawnError: Error | null = null
|
|
@@ -335,12 +365,13 @@ async function execGitForCaller(args: string[], env: NodeJS.ProcessEnv, maxBuffe
|
|
|
335
365
|
// Event streams are the index input itself and may legitimately exceed execFile's fixed maxBuffer. Read
|
|
336
366
|
// them through spawn so the only bound is the index the caller is intentionally constructing; timeout,
|
|
337
367
|
// cancellation, process-group cleanup and build permits remain identical to the ordinary async transport.
|
|
338
|
-
function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<GitExec> {
|
|
368
|
+
function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal, input?: string): Promise<GitExec> {
|
|
339
369
|
return new Promise((resolve, reject) => {
|
|
340
370
|
if (signal?.aborted) { reject(gitAbortError()); return }
|
|
341
|
-
const child = spawn(
|
|
371
|
+
const child = spawn(gitBinary(env), args, { env, detached: true, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] })
|
|
342
372
|
const stdout: Buffer[] = [], stderr: Buffer[] = []
|
|
343
373
|
let settled = false, aborted = false, timedOut = false
|
|
374
|
+
let stdinError: any = null
|
|
344
375
|
const killTree = () => {
|
|
345
376
|
if (!child.pid) return
|
|
346
377
|
try { process.kill(-child.pid, 'SIGKILL') } catch { /* group may already be gone */ }
|
|
@@ -350,8 +381,14 @@ function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSig
|
|
|
350
381
|
const timer = setTimeout(() => { timedOut = true; killTree() }, GIT_TIMEOUT_MS)
|
|
351
382
|
timer.unref?.()
|
|
352
383
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
353
|
-
child.stdout
|
|
354
|
-
child.stderr
|
|
384
|
+
child.stdout!.on('data', (chunk: Buffer) => stdout.push(chunk))
|
|
385
|
+
child.stderr!.on('data', (chunk: Buffer) => stderr.push(chunk))
|
|
386
|
+
if (input !== undefined) {
|
|
387
|
+
// A command can reject its input before the pipe drains. Keep that write failure on the same close
|
|
388
|
+
// path as exit and abort failures instead of letting Node raise an unhandled EPIPE.
|
|
389
|
+
child.stdin!.once('error', (error) => { stdinError ??= error })
|
|
390
|
+
child.stdin!.end(input)
|
|
391
|
+
}
|
|
355
392
|
child.on('error', (error: any) => {
|
|
356
393
|
if (settled) return
|
|
357
394
|
settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort)
|
|
@@ -363,9 +400,9 @@ function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSig
|
|
|
363
400
|
if (settled) return
|
|
364
401
|
settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort)
|
|
365
402
|
const result = { stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr).toString('utf8') }
|
|
366
|
-
if (code === 0 && !aborted && !timedOut) { resolve(result); return }
|
|
367
|
-
const error: any = new Error(`git exited with ${code ?? childSignal ?? 'unknown status'}`)
|
|
368
|
-
error.code = code
|
|
403
|
+
if (code === 0 && !aborted && !timedOut && !stdinError) { resolve(result); return }
|
|
404
|
+
const error: any = stdinError ?? new Error(`git exited with ${code ?? childSignal ?? 'unknown status'}`)
|
|
405
|
+
error.code = stdinError ? error.code : code
|
|
369
406
|
error.signal = childSignal
|
|
370
407
|
error.stdout = result.stdout.toString('utf8')
|
|
371
408
|
error.stderr = result.stderr
|
|
@@ -375,11 +412,11 @@ function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSig
|
|
|
375
412
|
})
|
|
376
413
|
})
|
|
377
414
|
}
|
|
378
|
-
async function execGitStreamForCaller(args: string[], env: NodeJS.ProcessEnv): Promise<GitExec> {
|
|
415
|
+
async function execGitStreamForCaller(args: string[], env: NodeJS.ProcessEnv, input?: string): Promise<GitExec> {
|
|
379
416
|
const context = inheritedContext()
|
|
380
|
-
if (!context) return execGitStream(args, env)
|
|
417
|
+
if (!context) return execGitStream(args, env, undefined, input)
|
|
381
418
|
const release = await context.permits.acquire(context.signal)
|
|
382
|
-
try { return await execGitStream(withBuildLimits(args), env, context.signal) }
|
|
419
|
+
try { return await execGitStream(withBuildLimits(args), env, context.signal, input) }
|
|
383
420
|
finally { release() }
|
|
384
421
|
}
|
|
385
422
|
|
|
@@ -396,19 +433,47 @@ export async function gitA(args: string[], input?: string): Promise<string> {
|
|
|
396
433
|
}
|
|
397
434
|
}
|
|
398
435
|
|
|
436
|
+
async function gitARequired(args: string[], input?: string): Promise<string> {
|
|
437
|
+
const env = { ...process.env }
|
|
438
|
+
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
439
|
+
try { return (await execGitForCaller(args, env, undefined, input)).stdout.toString('utf8') }
|
|
440
|
+
catch (error: any) {
|
|
441
|
+
if (error?.name === 'AbortError') throw error
|
|
442
|
+
warnIfTimedOut(error, args)
|
|
443
|
+
throw new Error(`git ${args.slice(2, 6).join(' ')} failed: ${String(error?.stderr || error?.message || 'unknown git error').trim()}`)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
399
447
|
type TextEventRecord = { hash: string; raw: string }
|
|
400
448
|
type IdentityEventRecord = { hash: string; identity: IdentityRawRecord }
|
|
401
449
|
type EventRecord = TextEventRecord | IdentityEventRecord
|
|
402
|
-
type
|
|
450
|
+
export type ImmutableHunkRanges = { after: DiffLineRange[]; before: DiffLineRange[][] }
|
|
451
|
+
type EventCache = {
|
|
452
|
+
streams: Map<EventStreamKind, Map<string, EventRecord>>
|
|
453
|
+
streamTips: Map<EventStreamKind, string[]>
|
|
454
|
+
hunks: Map<string, ImmutableHunkRanges>
|
|
455
|
+
}
|
|
403
456
|
type EventStreamOutput = string | IdentityRawRecord[]
|
|
404
|
-
|
|
457
|
+
// The schema names both the ledger grammar and its on-disk namespace. A reader that predates a row type
|
|
458
|
+
// must never share a ledger with its writer: it seeds the next namespace from Git instead.
|
|
459
|
+
const EVENT_CACHE_SCHEMA = 'history-events-v16'
|
|
460
|
+
const IMMUTABLE_HUNK_FACT = 'immutable-hunk-v1'
|
|
405
461
|
const EVENT_STREAM_KINDS = ['merge', 'identity-raw'] as const
|
|
406
462
|
type EventStreamKind = typeof EVENT_STREAM_KINDS[number]
|
|
407
|
-
type EventCacheLocation = { path: string; identity: string; objectFormat: GitObjectFormat }
|
|
463
|
+
type EventCacheLocation = { path: string; identity: string; interpretation: string; objectFormat: GitObjectFormat }
|
|
408
464
|
type EventLedgerSnapshot = {
|
|
409
465
|
payload: Buffer
|
|
410
466
|
state: EventCache
|
|
411
467
|
}
|
|
468
|
+
type EventLedgerBuild = {
|
|
469
|
+
location: EventCacheLocation
|
|
470
|
+
snapshot: EventLedgerSnapshot
|
|
471
|
+
additions: string[]
|
|
472
|
+
}
|
|
473
|
+
type EventLedgerDiagnostics = { reads: number; locks: number; replaces: number }
|
|
474
|
+
const eventLedgerBuild = new AsyncLocalStorage<EventLedgerBuild>()
|
|
475
|
+
const eventLedgerDemandPolicy = new AsyncLocalStorage<{ path: string }>()
|
|
476
|
+
const eventLedgerDiagnostics: EventLedgerDiagnostics = { reads: 0, locks: 0, replaces: 0 }
|
|
412
477
|
type EventStreamRequest = {
|
|
413
478
|
kind: EventStreamKind
|
|
414
479
|
argsFor: (base: string) => string[]
|
|
@@ -460,20 +525,41 @@ function eventCacheLocation(root: string): EventCacheLocation {
|
|
|
460
525
|
: git(['-C', root, 'for-each-ref', 'refs/replace', '--format=%(refname) %(objectname)'])
|
|
461
526
|
const objectFormat = gitObjectFormat(root)
|
|
462
527
|
if (old && old.shallow === shallow && old.grafts === grafts && old.replacements === replacements && old.objectFormat === objectFormat)
|
|
463
|
-
return { path: old.path, identity: old.identity, objectFormat }
|
|
464
|
-
const
|
|
528
|
+
return { path: old.path, identity: old.identity, interpretation: old.interpretation, objectFormat }
|
|
529
|
+
const interpretation = createHash('sha256')
|
|
465
530
|
.update(`${EVENT_CACHE_SCHEMA}\0${objectFormat}\0${shallow}\0${grafts}\0${replacements}`)
|
|
466
|
-
.digest('hex')
|
|
531
|
+
.digest('hex')
|
|
532
|
+
const identity = interpretation.slice(0, 16)
|
|
467
533
|
// projectRuntimeRoot derives checkout identity from dirname(common). A bare repository is its own common
|
|
468
534
|
// dir, so a synthetic `.git` suffix preserves the repository path instead of collapsing sibling bares.
|
|
469
535
|
const gitDir = gitDirOf(root)
|
|
470
536
|
const storeIdentity = gitDir === root && common === root ? join(common, '.git') : common
|
|
471
537
|
const path = join(projectRuntimeRoot(storeIdentity), `${EVENT_CACHE_SCHEMA}-${identity}.ndjson`)
|
|
472
|
-
eventPathMemo.set(rootId, { common, shallowPath, grafts, shallow, replacementStorage, replacements, path, identity, objectFormat })
|
|
473
|
-
return { path, identity, objectFormat }
|
|
474
|
-
}
|
|
538
|
+
eventPathMemo.set(rootId, { common, shallowPath, grafts, shallow, replacementStorage, replacements, path, identity, interpretation, objectFormat })
|
|
539
|
+
return { path, identity, interpretation, objectFormat }
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// One existing source-of-truth identity governs every Git reader that interprets commit images. Consumers
|
|
543
|
+
// may retain the full digest in memory while the ledger keeps its established short directory name.
|
|
544
|
+
export function gitObjectInterpretation(root: string): {
|
|
545
|
+
identity: string
|
|
546
|
+
objectFormat: GitObjectFormat
|
|
547
|
+
replacements: ReadonlyMap<string, string>
|
|
548
|
+
} {
|
|
549
|
+
const location = eventCacheLocation(root)
|
|
550
|
+
const raw = eventPathMemo.get(rootKey(root))?.replacements ?? ''
|
|
551
|
+
const replacements = new Map<string, string>()
|
|
552
|
+
for (const line of raw.split('\n').filter(Boolean)) {
|
|
553
|
+
const match = line.match(/^refs\/replace\/([0-9a-f]+) ([0-9a-f]+)$/)
|
|
554
|
+
if (!match || !isGitObjectIdForFormat(location.objectFormat, match[1]) || !isGitObjectIdForFormat(location.objectFormat, match[2]))
|
|
555
|
+
throw new Error(`malformed refs/replace projection '${line}' at ${root}`)
|
|
556
|
+
replacements.set(match[1], match[2])
|
|
557
|
+
}
|
|
558
|
+
return { identity: location.interpretation, objectFormat: location.objectFormat, replacements }
|
|
559
|
+
}
|
|
560
|
+
export function gitInterpretationIdentity(root: string): string { return eventCacheLocation(root).identity }
|
|
475
561
|
function emptyEventCache(): EventCache {
|
|
476
|
-
return { streams: new Map(), streamTips: new Map() }
|
|
562
|
+
return { streams: new Map(), streamTips: new Map(), hunks: new Map() }
|
|
477
563
|
}
|
|
478
564
|
function exactKeys(value: Record<string, unknown>, expected: string[]): boolean {
|
|
479
565
|
const actual = Object.keys(value).sort()
|
|
@@ -498,6 +584,28 @@ function eventStreamKind(value: unknown): EventStreamKind | null {
|
|
|
498
584
|
? value as EventStreamKind
|
|
499
585
|
: null
|
|
500
586
|
}
|
|
587
|
+
function decodeHunkRanges(value: unknown): DiffLineRange[] | null {
|
|
588
|
+
if (!Array.isArray(value)) return null
|
|
589
|
+
const ranges: DiffLineRange[] = []
|
|
590
|
+
for (const row of value) {
|
|
591
|
+
if (!Array.isArray(row) || row.length !== 2 || !Number.isSafeInteger(row[0]) || !Number.isSafeInteger(row[1])
|
|
592
|
+
|| row[0] <= 0 || row[1] < row[0]) return null
|
|
593
|
+
ranges.push([row[0], row[1]])
|
|
594
|
+
}
|
|
595
|
+
return ranges
|
|
596
|
+
}
|
|
597
|
+
function decodeImmutableHunkFact(row: Record<string, unknown>): { key: string; ranges: ImmutableHunkRanges } | null {
|
|
598
|
+
if (!exactKeys(row, ['a', 'b', 'i', 'k']) || row.k !== IMMUTABLE_HUNK_FACT || typeof row.i !== 'string' || !row.i) return null
|
|
599
|
+
const after = decodeHunkRanges(row.a)
|
|
600
|
+
if (!after || !Array.isArray(row.b)) return null
|
|
601
|
+
const before: DiffLineRange[][] = []
|
|
602
|
+
for (const parent of row.b) {
|
|
603
|
+
const ranges = decodeHunkRanges(parent)
|
|
604
|
+
if (!ranges) return null
|
|
605
|
+
before.push(ranges)
|
|
606
|
+
}
|
|
607
|
+
return { key: row.i, ranges: { after, before } }
|
|
608
|
+
}
|
|
501
609
|
function decodeEventPayload(payload: Buffer, location: EventCacheLocation): EventCache | null {
|
|
502
610
|
const state = emptyEventCache()
|
|
503
611
|
const text = payload.toString('utf8')
|
|
@@ -511,6 +619,12 @@ function decodeEventPayload(payload: Buffer, location: EventCacheLocation): Even
|
|
|
511
619
|
try { value = JSON.parse(line) } catch { return null }
|
|
512
620
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
513
621
|
const row = value as Record<string, unknown>
|
|
622
|
+
if (row.k === IMMUTABLE_HUNK_FACT) {
|
|
623
|
+
const fact = decodeImmutableHunkFact(row)
|
|
624
|
+
if (!fact || state.hunks.has(fact.key)) return null
|
|
625
|
+
state.hunks.set(fact.key, fact.ranges)
|
|
626
|
+
continue
|
|
627
|
+
}
|
|
514
628
|
if (exactKeys(row, ['k', 'tip'])) {
|
|
515
629
|
const kind = typeof row.k === 'string' && row.k.startsWith('tip:') ? eventStreamKind(row.k.slice(4)) : null
|
|
516
630
|
if (!kind || typeof row.tip !== 'string' || !isGitObjectIdForFormat(location.objectFormat, row.tip)) return null
|
|
@@ -545,6 +659,7 @@ function decodeEventPayload(payload: Buffer, location: EventCacheLocation): Even
|
|
|
545
659
|
return state
|
|
546
660
|
}
|
|
547
661
|
function loadEventLedger(location: EventCacheLocation): EventLedgerSnapshot {
|
|
662
|
+
eventLedgerDiagnostics.reads++
|
|
548
663
|
let file: Buffer
|
|
549
664
|
try { file = readFileSync(location.path) }
|
|
550
665
|
catch (error: any) {
|
|
@@ -572,18 +687,29 @@ function loadEventLedger(location: EventCacheLocation): EventLedgerSnapshot {
|
|
|
572
687
|
}
|
|
573
688
|
}
|
|
574
689
|
|
|
575
|
-
type EventLockOwner = { pid: number;
|
|
690
|
+
type EventLockOwner = { pid: number; startToken: string; nonce: string }
|
|
576
691
|
function readEventLockOwner(lock: string): EventLockOwner | null {
|
|
577
692
|
try {
|
|
578
|
-
const value = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8')) as { pid?: unknown;
|
|
579
|
-
return Number.isInteger(value.pid) && (value.pid as number) > 0
|
|
580
|
-
|
|
693
|
+
const value = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8')) as { pid?: unknown; startToken?: unknown; nonce?: unknown }
|
|
694
|
+
return Number.isInteger(value.pid) && (value.pid as number) > 0
|
|
695
|
+
&& typeof value.startToken === 'string' && !!value.startToken
|
|
696
|
+
&& typeof value.nonce === 'string' && !!value.nonce
|
|
697
|
+
? { pid: value.pid as number, startToken: value.startToken, nonce: value.nonce }
|
|
581
698
|
: null
|
|
582
699
|
} catch { return null }
|
|
583
700
|
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
701
|
+
const sameEventLockOwner = (left: EventLockOwner | null, right: EventLockOwner | null): boolean =>
|
|
702
|
+
!!left && !!right && left.pid === right.pid && left.startToken === right.startToken && left.nonce === right.nonce
|
|
703
|
+
|
|
704
|
+
function eventLockOwnerState(owner: EventLockOwner): 'live' | 'dead' | 'unknown' {
|
|
705
|
+
try { process.kill(owner.pid, 0) }
|
|
706
|
+
catch (error: any) {
|
|
707
|
+
if (error?.code === 'ESRCH') return 'dead'
|
|
708
|
+
if (error?.code !== 'EPERM') return 'unknown'
|
|
709
|
+
}
|
|
710
|
+
const observed = processStartToken(owner.pid)
|
|
711
|
+
if (!observed) return 'unknown'
|
|
712
|
+
return observed === owner.startToken ? 'live' : 'dead'
|
|
587
713
|
}
|
|
588
714
|
function retireLockPath(active: string): boolean {
|
|
589
715
|
const inert = `${active}.inert.${process.pid}.${randomBytes(8).toString('hex')}`
|
|
@@ -596,14 +722,17 @@ function retireLockPath(active: string): boolean {
|
|
|
596
722
|
return true
|
|
597
723
|
}
|
|
598
724
|
function reclaimDeadEventLock(lock: string, held: EventLockOwner, claimant: EventLockOwner): boolean {
|
|
599
|
-
if (
|
|
725
|
+
if (eventLockOwnerState(held) !== 'dead') return false
|
|
600
726
|
const reclaim = join(lock, 'reclaim')
|
|
601
727
|
if (existsSync(reclaim)) {
|
|
602
728
|
const reclaimer = readEventLockOwner(reclaim)
|
|
603
|
-
if (!reclaimer
|
|
729
|
+
if (!reclaimer) throw new Error(`history event cache lock has an unprovable reclaimer: ${lock}`)
|
|
730
|
+
const state = eventLockOwnerState(reclaimer)
|
|
731
|
+
if (state === 'unknown') throw new Error(`history event cache lock reclaimer identity is unreadable: ${lock}`)
|
|
732
|
+
if (state === 'live') return false
|
|
604
733
|
retireLockPath(reclaim)
|
|
605
734
|
}
|
|
606
|
-
const prepared = join(lock, `reclaim-${claimant.pid}-${claimant.
|
|
735
|
+
const prepared = join(lock, `reclaim-${claimant.pid}-${claimant.nonce}`)
|
|
607
736
|
try {
|
|
608
737
|
mkdirSync(prepared)
|
|
609
738
|
writeFileSync(join(prepared, 'owner.json'), JSON.stringify(claimant))
|
|
@@ -614,21 +743,29 @@ function reclaimDeadEventLock(lock: string, held: EventLockOwner, claimant: Even
|
|
|
614
743
|
throw error
|
|
615
744
|
}
|
|
616
745
|
const current = readEventLockOwner(lock)
|
|
617
|
-
if (current
|
|
746
|
+
if (!sameEventLockOwner(current, held) || (current && eventLockOwnerState(current) !== 'dead')) {
|
|
618
747
|
retireLockPath(reclaim)
|
|
619
748
|
return false
|
|
620
749
|
}
|
|
621
750
|
return retireLockPath(lock)
|
|
622
751
|
}
|
|
623
|
-
|
|
752
|
+
const EVENT_LEDGER_BUSY = Symbol('event ledger held by a live writer')
|
|
753
|
+
|
|
754
|
+
async function withEventCacheLock<T>(
|
|
755
|
+
path: string,
|
|
756
|
+
run: () => Promise<T> | T,
|
|
757
|
+
waitForWriter = true,
|
|
758
|
+
): Promise<T | typeof EVENT_LEDGER_BUSY> {
|
|
624
759
|
const lock = `${path}.lock`
|
|
625
760
|
mkdirSync(join(path, '..'), { recursive: true })
|
|
626
|
-
const
|
|
761
|
+
const startToken = processStartToken(process.pid)
|
|
762
|
+
if (!startToken) throw new Error(`cannot prove history event cache lock claimant identity: ${path}`)
|
|
763
|
+
const owner: EventLockOwner = { pid: process.pid, startToken, nonce: randomBytes(16).toString('hex') }
|
|
627
764
|
const attempts = Math.max(1, Math.ceil(GIT_TIMEOUT_MS / 5))
|
|
628
765
|
const signal = inheritedContext()?.signal
|
|
629
766
|
for (let attempt = 0; ; attempt++) {
|
|
630
767
|
if (signal?.aborted) throw gitAbortError()
|
|
631
|
-
const claimant = `${lock}.claim.${owner.pid}.${owner.
|
|
768
|
+
const claimant = `${lock}.claim.${owner.pid}.${owner.nonce}`
|
|
632
769
|
try {
|
|
633
770
|
mkdirSync(claimant)
|
|
634
771
|
writeFileSync(join(claimant, 'owner.json'), JSON.stringify(owner))
|
|
@@ -638,7 +775,30 @@ async function withEventCacheLock<T>(path: string, run: () => Promise<T> | T): P
|
|
|
638
775
|
rmSync(claimant, { recursive: true, force: true })
|
|
639
776
|
if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error
|
|
640
777
|
const held = readEventLockOwner(lock)
|
|
641
|
-
if (held
|
|
778
|
+
if (!held) {
|
|
779
|
+
if (!existsSync(lock)) continue
|
|
780
|
+
throw new Error(`history event cache lock has no provable exact owner: ${path}`)
|
|
781
|
+
}
|
|
782
|
+
const heldState = eventLockOwnerState(held)
|
|
783
|
+
if (heldState === 'unknown')
|
|
784
|
+
throw new Error(`history event cache lock owner identity is unreadable: ${path}`)
|
|
785
|
+
if (heldState === 'dead') {
|
|
786
|
+
if (reclaimDeadEventLock(lock, held, owner)) continue
|
|
787
|
+
if (!existsSync(lock)) continue
|
|
788
|
+
}
|
|
789
|
+
if (!waitForWriter) {
|
|
790
|
+
const current = readEventLockOwner(lock)
|
|
791
|
+
if (!current) {
|
|
792
|
+
if (!existsSync(lock)) continue
|
|
793
|
+
throw new Error(`history event cache lock has no provable exact owner: ${path}`)
|
|
794
|
+
}
|
|
795
|
+
const state = eventLockOwnerState(current)
|
|
796
|
+
if (state === 'live') return EVENT_LEDGER_BUSY
|
|
797
|
+
// A live reclaimer may be arbitrating this exact dead owner. Foreground demand can safely derive
|
|
798
|
+
// from the atomic snapshot, but must not spin synchronously until that separate lease finishes.
|
|
799
|
+
if (state === 'dead') return EVENT_LEDGER_BUSY
|
|
800
|
+
throw new Error(`history event cache lock owner identity is unreadable: ${path}`)
|
|
801
|
+
}
|
|
642
802
|
if (attempt >= attempts) {
|
|
643
803
|
const heldBy = readEventLockOwner(lock)?.pid
|
|
644
804
|
throw new Error(`timed out waiting for history event cache lock held by ${heldBy ? `pid ${heldBy}` : 'unknown owner'}: ${path}`)
|
|
@@ -646,8 +806,9 @@ async function withEventCacheLock<T>(path: string, run: () => Promise<T> | T): P
|
|
|
646
806
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
647
807
|
}
|
|
648
808
|
}
|
|
809
|
+
eventLedgerDiagnostics.locks++
|
|
649
810
|
try { return await run() } finally {
|
|
650
|
-
if (readEventLockOwner(lock)
|
|
811
|
+
if (sameEventLockOwner(readEventLockOwner(lock), owner)) retireLockPath(lock)
|
|
651
812
|
}
|
|
652
813
|
}
|
|
653
814
|
function removeEventTemps(path: string): void {
|
|
@@ -668,12 +829,78 @@ function replaceEventLedger(path: string, payload: Buffer, additions: string[]):
|
|
|
668
829
|
writeFileSync(fd, eventIntegrityFooter(payload, addition))
|
|
669
830
|
const written = fd; fd = null; closeSync(written)
|
|
670
831
|
renameSync(tmp, path)
|
|
832
|
+
eventLedgerDiagnostics.replaces++
|
|
671
833
|
} catch (error) {
|
|
672
834
|
if (fd !== null) closeSync(fd)
|
|
673
835
|
rmSync(tmp, { force: true })
|
|
674
836
|
throw error
|
|
675
837
|
}
|
|
676
838
|
}
|
|
839
|
+
const EVENT_LEDGER_RETRY = Symbol('event ledger identity moved')
|
|
840
|
+
|
|
841
|
+
function activeEventLedger(root: string): EventLedgerBuild | null {
|
|
842
|
+
const build = eventLedgerBuild.getStore()
|
|
843
|
+
if (!build) return null
|
|
844
|
+
return eventCacheLocation(root).path === build.location.path ? build : null
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
async function runEventLedgerAttempt<T>(
|
|
848
|
+
root: string,
|
|
849
|
+
location: EventCacheLocation,
|
|
850
|
+
run: () => Promise<T>,
|
|
851
|
+
persist: boolean,
|
|
852
|
+
): Promise<T | typeof EVENT_LEDGER_RETRY> {
|
|
853
|
+
const build: EventLedgerBuild = { location, snapshot: loadEventLedger(location), additions: [] }
|
|
854
|
+
const result = await eventLedgerBuild.run(build, run)
|
|
855
|
+
if (eventCacheLocation(root).identity !== location.identity) return EVENT_LEDGER_RETRY
|
|
856
|
+
if (persist && build.additions.length) {
|
|
857
|
+
removeEventTemps(location.path)
|
|
858
|
+
mkdirSync(join(location.path, '..'), { recursive: true })
|
|
859
|
+
replaceEventLedger(location.path, build.snapshot.payload, build.additions)
|
|
860
|
+
}
|
|
861
|
+
return result
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
async function eventLedgerTransaction<T>(
|
|
865
|
+
root: string,
|
|
866
|
+
run: () => Promise<T>,
|
|
867
|
+
demand: boolean,
|
|
868
|
+
): Promise<T> {
|
|
869
|
+
if (activeEventLedger(root)) return run()
|
|
870
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
871
|
+
const location = eventCacheLocation(root)
|
|
872
|
+
const locked = await withEventCacheLock(
|
|
873
|
+
location.path,
|
|
874
|
+
() => runEventLedgerAttempt(root, location, run, true),
|
|
875
|
+
!demand,
|
|
876
|
+
)
|
|
877
|
+
const value = locked === EVENT_LEDGER_BUSY
|
|
878
|
+
? await runEventLedgerAttempt(root, location, run, false)
|
|
879
|
+
: locked
|
|
880
|
+
if (value !== EVENT_LEDGER_RETRY) return value as T
|
|
881
|
+
}
|
|
882
|
+
throw new Error('history event cache identity changed repeatedly during one ledger build')
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// The event ledger is one build transaction, not one transaction per consumer: stream extraction and
|
|
886
|
+
// immutable hunk derivation share the snapshot, integrity verdict, lock, and final replacement.
|
|
887
|
+
export function withEventLedgerBuild<T>(root: string, run: () => Promise<T>): Promise<T> {
|
|
888
|
+
const demand = eventLedgerDemandPolicy.getStore()
|
|
889
|
+
return eventLedgerTransaction(root, run, !!demand && demand.path === eventCacheLocation(root).path)
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Demand is an ambient acquisition POLICY, not an eager lock. Nested ledger consumers therefore use the
|
|
893
|
+
// read-only path under a live writer, while observer waits, revision reads, and stable-cut replay take no lock.
|
|
894
|
+
export function withEventLedgerDemand<T>(root: string, run: () => Promise<T>): Promise<T> {
|
|
895
|
+
const path = eventCacheLocation(root).path
|
|
896
|
+
if (eventLedgerDemandPolicy.getStore()?.path === path) return run()
|
|
897
|
+
return eventLedgerDemandPolicy.run({ path }, run)
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
export function eventLedgerDiagnosticsForTests(): EventLedgerDiagnostics { return { ...eventLedgerDiagnostics } }
|
|
901
|
+
export function resetEventLedgerDiagnosticsForTests(): void {
|
|
902
|
+
eventLedgerDiagnostics.reads = 0; eventLedgerDiagnostics.locks = 0; eventLedgerDiagnostics.replaces = 0
|
|
903
|
+
}
|
|
677
904
|
function sortedEventRecords(records: Iterable<EventRecord>, request: EventStreamRequest): EventRecord[] {
|
|
678
905
|
return [...records].filter((record) => request.reachable.has(record.hash))
|
|
679
906
|
.sort((a, b) => (request.order.get(a.hash) ?? Number.MAX_SAFE_INTEGER) - (request.order.get(b.hash) ?? Number.MAX_SAFE_INTEGER))
|
|
@@ -711,6 +938,16 @@ function appendEventTip(state: EventCache, kind: EventStreamKind, tip: string, a
|
|
|
711
938
|
state.streamTips.set(kind, tips)
|
|
712
939
|
additions.push(JSON.stringify({ k: `tip:${kind}`, tip }) + '\n')
|
|
713
940
|
}
|
|
941
|
+
function appendImmutableHunkFact(state: EventCache, key: string, ranges: ImmutableHunkRanges, additions: string[]): void {
|
|
942
|
+
const existing = state.hunks.get(key)
|
|
943
|
+
if (existing) {
|
|
944
|
+
if (JSON.stringify(existing) !== JSON.stringify(ranges))
|
|
945
|
+
throw new Error(`immutable hunk ledger fact disagrees for image identity ${JSON.stringify(key)}`)
|
|
946
|
+
return
|
|
947
|
+
}
|
|
948
|
+
state.hunks.set(key, ranges)
|
|
949
|
+
additions.push(JSON.stringify({ k: IMMUTABLE_HUNK_FACT, i: key, a: ranges.after, b: ranges.before }) + '\n')
|
|
950
|
+
}
|
|
714
951
|
function parseEventRecords(out: string, kind: EventStreamKind, location: EventCacheLocation): EventRecord[] {
|
|
715
952
|
if (kind === 'identity-raw') return parseIdentityRawEventRecords(out, location)
|
|
716
953
|
const records: EventRecord[] = []
|
|
@@ -771,39 +1008,46 @@ async function deriveEventStreams(
|
|
|
771
1008
|
}
|
|
772
1009
|
if (new Set(requests.map((request) => request.kind)).size !== requests.length)
|
|
773
1010
|
throw new Error('one event-ledger transaction cannot request the same stream twice')
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
const
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
for (
|
|
801
|
-
const
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
1011
|
+
const build = activeEventLedger(root)
|
|
1012
|
+
if (!build) return withEventLedgerBuild(root, () => deriveEventStreams(root, tip, requests, persist, cache))
|
|
1013
|
+
const { location, snapshot, additions } = build
|
|
1014
|
+
const missing = requests.filter((request) => !(snapshot.state.streamTips.get(request.kind) ?? []).includes(tip))
|
|
1015
|
+
const outputs = await Promise.all(missing.map((request) => {
|
|
1016
|
+
const base = [...(snapshot.state.streamTips.get(request.kind) ?? [])].reverse()
|
|
1017
|
+
.find((candidate) => request.reachable.has(candidate)) ?? ''
|
|
1018
|
+
return strictEventGit(request.argsFor(base))
|
|
1019
|
+
}))
|
|
1020
|
+
for (let index = 0; index < missing.length; index++) {
|
|
1021
|
+
const request = missing[index]
|
|
1022
|
+
for (const record of parseEventRecords(outputs[index], request.kind, location))
|
|
1023
|
+
appendEventRecord(snapshot.state, request.kind, record, additions)
|
|
1024
|
+
if (persist) appendEventTip(snapshot.state, request.kind, tip, additions)
|
|
1025
|
+
}
|
|
1026
|
+
return new Map(requests.map((request) => [request.kind, renderEventStream(snapshot.state, request)]))
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Immutable hunk ranges are a ledger fact, not a second anchor cache: callers name their whole image-key
|
|
1030
|
+
// demand, receive only facts the shared ledger already certified, and derive/persist misses in their own read.
|
|
1031
|
+
export function readImmutableHunkFacts(root: string, keys: Iterable<string>): Map<string, ImmutableHunkRanges> {
|
|
1032
|
+
const wanted = new Set(keys)
|
|
1033
|
+
if (!wanted.size) return new Map()
|
|
1034
|
+
const build = activeEventLedger(root)
|
|
1035
|
+
if (!build) return new Map()
|
|
1036
|
+
const found = new Map<string, ImmutableHunkRanges>()
|
|
1037
|
+
for (const key of wanted) {
|
|
1038
|
+
const ranges = build.snapshot.state.hunks.get(key)
|
|
1039
|
+
if (ranges) found.set(key, ranges)
|
|
1040
|
+
}
|
|
1041
|
+
return found
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
export async function persistImmutableHunkFacts(root: string, facts: ReadonlyMap<string, ImmutableHunkRanges>): Promise<void> {
|
|
1045
|
+
if (!facts.size) return
|
|
1046
|
+
const build = activeEventLedger(root)
|
|
1047
|
+
if (!build) return withEventLedgerBuild(root, () => persistImmutableHunkFacts(root, facts))
|
|
1048
|
+
for (const [key, ranges] of facts) appendImmutableHunkFact(build.snapshot.state, key, ranges, build.additions)
|
|
806
1049
|
}
|
|
1050
|
+
|
|
807
1051
|
async function eventStream(
|
|
808
1052
|
root: string,
|
|
809
1053
|
tip: string,
|
|
@@ -826,13 +1070,17 @@ async function identityRawEventStream(root: string, tip: string, request: EventS
|
|
|
826
1070
|
return value as IdentityRawRecord[]
|
|
827
1071
|
}
|
|
828
1072
|
export type GitTryFailure = 'exit' | 'spawn' | 'timeout'
|
|
829
|
-
export async function gitTry(args: string[], options: { indexFile?: string } = {}): Promise<{ ok: boolean; stdout: string; stderr: string; failure?: GitTryFailure }> {
|
|
1073
|
+
export async function gitTry(args: string[], options: { indexFile?: string; extraEnv?: Record<string, string | undefined>; input?: string } = {}): Promise<{ ok: boolean; stdout: string; stderr: string; failure?: GitTryFailure }> {
|
|
830
1074
|
const env = { ...process.env }
|
|
1075
|
+
for (const [key, value] of Object.entries(options.extraEnv ?? {})) {
|
|
1076
|
+
if (value === undefined) delete env[key]
|
|
1077
|
+
else env[key] = value
|
|
1078
|
+
}
|
|
831
1079
|
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
832
1080
|
if (options.indexFile) env.GIT_INDEX_FILE = options.indexFile
|
|
833
1081
|
const context = inheritedContext()
|
|
834
1082
|
try {
|
|
835
|
-
const { stdout, stderr } = await execGitForCaller(args, env)
|
|
1083
|
+
const { stdout, stderr } = await execGitForCaller(args, env, undefined, options.input)
|
|
836
1084
|
return { ok: true, stdout: stdout.toString('utf8'), stderr }
|
|
837
1085
|
} catch (e: any) {
|
|
838
1086
|
if (context?.signal.aborted || e?.name === 'AbortError') throw e
|
|
@@ -842,10 +1090,17 @@ export async function gitTry(args: string[], options: { indexFile?: string } = {
|
|
|
842
1090
|
}
|
|
843
1091
|
}
|
|
844
1092
|
|
|
845
|
-
|
|
1093
|
+
// A walk whose OUTPUT is a projection the caller is intentionally building reads through the streamed
|
|
1094
|
+
// transport (no fixed stdout bound); `input` lets its revision roster ride stdin, so argv cannot grow with
|
|
1095
|
+
// the roster and needs no chunking.
|
|
1096
|
+
export async function gitRequiredA(args: string[], purpose: string, options: { input?: string; extraEnv?: Record<string, string | undefined> } = {}): Promise<string> {
|
|
846
1097
|
const env = { ...process.env }
|
|
1098
|
+
for (const [key, value] of Object.entries(options.extraEnv ?? {})) {
|
|
1099
|
+
if (value === undefined) delete env[key]
|
|
1100
|
+
else env[key] = value
|
|
1101
|
+
}
|
|
847
1102
|
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
848
|
-
try { return (await execGitStreamForCaller(args, env)).stdout.toString('utf8') }
|
|
1103
|
+
try { return (await execGitStreamForCaller(args, env, options.input)).stdout.toString('utf8') }
|
|
849
1104
|
catch (error: any) {
|
|
850
1105
|
if (error?.name === 'AbortError') throw error
|
|
851
1106
|
warnIfTimedOut(error, args)
|
|
@@ -1445,6 +1700,15 @@ export type DriftPathEvent = {
|
|
|
1445
1700
|
parents: { commit: string; historicalPath: string }[]
|
|
1446
1701
|
}
|
|
1447
1702
|
|
|
1703
|
+
// The ancestry substrate the reachability functions below actually read: a topology projection plus its
|
|
1704
|
+
// memoized closures. `DriftIndex` is one instance of it (HEAD's history); `unionTopology` builds another for
|
|
1705
|
+
// revisions HEAD cannot reach. Sharing the type is what keeps ONE reachability rule for both.
|
|
1706
|
+
export type Reachability = {
|
|
1707
|
+
ord: Map<string, number>
|
|
1708
|
+
parents: Map<string, string[]>
|
|
1709
|
+
anc: Map<string, Uint8Array>
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1448
1712
|
export type DiffLineRange = [number, number]
|
|
1449
1713
|
export type CombinedDiffOwnedChanges = { after: DiffLineRange[]; before: DiffLineRange[][]; parentPaths: string[] }
|
|
1450
1714
|
|
|
@@ -1832,7 +2096,7 @@ export function historyEventCachePathForTests(root: string): string { return eve
|
|
|
1832
2096
|
// this direct parent DFS, so neither path introduces a second reachability representation or persistent fact.
|
|
1833
2097
|
// undefined when `sha` is not reachable from HEAD (rebased away, an unmerged branch, or never on any
|
|
1834
2098
|
// ref) — callers apply their own conservative rule to that "can't prove" case.
|
|
1835
|
-
export function ancestorsOf(idx:
|
|
2099
|
+
export function ancestorsOf(idx: Reachability, sha: string): Uint8Array | undefined {
|
|
1836
2100
|
const hit = idx.anc.get(sha)
|
|
1837
2101
|
if (hit) return hit
|
|
1838
2102
|
const start = idx.ord.get(sha)
|
|
@@ -1857,7 +2121,7 @@ export function ancestorsOf(idx: DriftIndex, sha: string): Uint8Array | undefine
|
|
|
1857
2121
|
// Fill the existing ancestry memo for a known roster in one child-before-parent topology pass. At each
|
|
1858
2122
|
// commit the transient row names requested descendants; emitting those bits into the ordinary closures makes
|
|
1859
2123
|
// the resulting bytes identical to calling ancestorsOf() independently for every requested SHA.
|
|
1860
|
-
export function primeAncestorClosures(idx:
|
|
2124
|
+
export function primeAncestorClosures(idx: Reachability, shas: Iterable<string>): void {
|
|
1861
2125
|
const endpoints = [...new Set(shas)].filter((sha) => !idx.anc.has(sha) && idx.ord.has(sha))
|
|
1862
2126
|
if (!endpoints.length) return
|
|
1863
2127
|
const count = idx.ord.size
|
|
@@ -1916,11 +2180,31 @@ export function primeAncestorClosures(idx: DriftIndex, shas: Iterable<string>):
|
|
|
1916
2180
|
throw new Error(`cannot prime ancestry closures: topology yielded ${visited} of ${count} reachable commits`)
|
|
1917
2181
|
for (let position = 0; position < endpoints.length; position++) idx.anc.set(endpoints[position], closures[position])
|
|
1918
2182
|
}
|
|
1919
|
-
export function inAncestors(idx:
|
|
2183
|
+
export function inAncestors(idx: Reachability, bits: Uint8Array, sha: string): boolean {
|
|
1920
2184
|
const o = idx.ord.get(sha)
|
|
1921
2185
|
return o !== undefined && (bits[o >> 3] & (1 << (o & 7))) !== 0
|
|
1922
2186
|
}
|
|
1923
2187
|
|
|
2188
|
+
// @@@ one walk for a whole roster of revisions, HEAD-reachable or not - the index above projects HEAD's
|
|
2189
|
+
// history, so a revision HEAD cannot reach (an unmerged branch, a rebased-away measurement anchor) has NO
|
|
2190
|
+
// ancestry there and `ancestorsOf` correctly answers undefined. A caller that needs those revisions' own
|
|
2191
|
+
// past gets it from the SAME projection shape, built by one `rev-list --parents` walk over the union of the
|
|
2192
|
+
// roster's histories, with the roster on stdin so argv never grows with it. Deliberately a SEPARATE
|
|
2193
|
+
// structure, never a graft into the shared index: making off-history tips reachable there would silently
|
|
2194
|
+
// switch every ancestry-vs-content decision that keys on `undefined`.
|
|
2195
|
+
export async function unionTopology(root: string, revisions: readonly string[]): Promise<Reachability> {
|
|
2196
|
+
const roster = [...new Set(revisions)].filter(Boolean)
|
|
2197
|
+
const reach: Reachability = { ord: new Map(), parents: new Map(), anc: new Map() }
|
|
2198
|
+
if (!roster.length) return reach
|
|
2199
|
+
const out = await gitRequiredA(['-C', root, 'rev-list', '--parents', '--stdin'], 'cannot walk revision topology', {
|
|
2200
|
+
input: roster.map((revision) => `${revision}\n`).join(''),
|
|
2201
|
+
// the roster is already exact object ids; do not reinterpret them if refs/replace moves mid-read.
|
|
2202
|
+
extraEnv: { GIT_NO_REPLACE_OBJECTS: '1' },
|
|
2203
|
+
})
|
|
2204
|
+
const projection = topologyProjection(out)
|
|
2205
|
+
return { ord: projection.order, parents: projection.parents, anc: new Map() }
|
|
2206
|
+
}
|
|
2207
|
+
|
|
1924
2208
|
// @@@ reachability is membership, not a closure - `ancestorsOf` returns undefined for EXACTLY the shas
|
|
1925
2209
|
// absent from `idx.ord` (both writers of `idx.anc` gate on ord: the single-sha path after its ord lookup
|
|
1926
2210
|
// succeeds, the batch after an explicit `ord.has` filter), so asking it here answered a hash-table
|
|
@@ -2060,13 +2344,13 @@ function parseStatPath(token: string): { from: string; to: string } {
|
|
|
2060
2344
|
const arrowAt = token.indexOf(' => ')
|
|
2061
2345
|
return arrowAt >= 0 ? { from: token.slice(0, arrowAt), to: token.slice(arrowAt + 4) } : { from: token, to: token }
|
|
2062
2346
|
}
|
|
2063
|
-
export async function mergeBaseDiff(wtPath: string, mainRef = 'main'): Promise<ReviewDiffFile[]> {
|
|
2347
|
+
export async function mergeBaseDiff(wtPath: string, mainRef = 'main', headRef = 'HEAD'): Promise<ReviewDiffFile[]> {
|
|
2064
2348
|
const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
|
|
2065
|
-
const base = (await run(['merge-base', mainRef,
|
|
2349
|
+
const base = (await run(['merge-base', mainRef, headRef])).trim()
|
|
2066
2350
|
if (!base) return []
|
|
2067
2351
|
const [numstatOut, statusOut] = await Promise.all([
|
|
2068
|
-
run(['diff', '--numstat', '-M', `${base}
|
|
2069
|
-
run(['diff', '--name-status', '-M', `${base}
|
|
2352
|
+
run(['diff', '--numstat', '-M', `${base}..${headRef}`]),
|
|
2353
|
+
run(['diff', '--name-status', '-M', `${base}..${headRef}`]),
|
|
2070
2354
|
])
|
|
2071
2355
|
const status = new Map<string, { status: string; from: string }>()
|
|
2072
2356
|
for (const r of parseNameStatus(statusOut)) status.set(r.to, { status: DIFF_STATUS[r.code] ?? r.code, from: r.from })
|
|
@@ -2087,11 +2371,11 @@ export async function mergeBaseDiff(wtPath: string, mainRef = 'main'): Promise<R
|
|
|
2087
2371
|
return files
|
|
2088
2372
|
}
|
|
2089
2373
|
|
|
2090
|
-
export function mergeConflicts(wtPath: string, mainRef = 'main'): Promise<boolean> {
|
|
2374
|
+
export function mergeConflicts(wtPath: string, mainRef = 'main', headRef = 'HEAD'): Promise<boolean> {
|
|
2091
2375
|
return new Promise((resolve) => {
|
|
2092
2376
|
const env = { ...process.env }
|
|
2093
2377
|
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
2094
|
-
execFile(
|
|
2378
|
+
execFile(gitBinary(env), ['-C', wtPath, 'merge-tree', '--write-tree', '--no-messages', mainRef, headRef],
|
|
2095
2379
|
{ encoding: 'utf8', env, maxBuffer: 1 << 24 },
|
|
2096
2380
|
// execFile sets err.code to the numeric EXIT code on a non-zero exit (1 = conflicts), or a string
|
|
2097
2381
|
// errno (e.g. 'ENOENT') if git can't be spawned — only the exit-1 case is a real conflict verdict.
|
|
@@ -2104,19 +2388,7 @@ export function mergeConflicts(wtPath: string, mainRef = 'main'): Promise<boolea
|
|
|
2104
2388
|
// spoken: content equal to main is no op, an existing node reads `edited` never `added`) AND have been
|
|
2105
2389
|
// touched by this branch since its fork point (attribution — main's own post-fork movement is not this
|
|
2106
2390
|
// worktree's op). A `status --porcelain` pass adds untracked spec.md, a third diff vs HEAD marks committed.
|
|
2107
|
-
|
|
2108
|
-
const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
|
|
2109
|
-
// fork point = where this worktree branched from main; '' (no common ancestor / unreadable ref) falls
|
|
2110
|
-
// back to mainRef so we still surface changes rather than going silent. The caller (cachedDelta) already
|
|
2111
|
-
// computes this same merge-base to key its cache, so it passes it in to avoid a redundant subprocess.
|
|
2112
|
-
const base = baseHint || (await run(['merge-base', mainRef, 'HEAD'])).trim() || mainRef
|
|
2113
|
-
// the four queries are independent — run them in parallel.
|
|
2114
|
-
const [mainOut, workOut, commOut, statusOut] = await Promise.all([
|
|
2115
|
-
run(['diff', '--name-status', '-M', mainRef, '--', '.spec']),
|
|
2116
|
-
run(['diff', '--name-status', '-M', base, '--', '.spec']),
|
|
2117
|
-
run(['diff', '--name-status', '-M', `${base}...HEAD`, '--', '.spec']),
|
|
2118
|
-
run(['status', '--porcelain', '--untracked-files=all', '--', '.spec']),
|
|
2119
|
-
])
|
|
2391
|
+
function projectWorktreeSpecDelta(mainOut: string, workOut: string, commOut: string, statusOut: string): NodeOp[] {
|
|
2120
2392
|
const proposals = parseNameStatus(mainOut)
|
|
2121
2393
|
// the branch's own footprint since its fork point — both sides of every row, so a rename matches
|
|
2122
2394
|
// whichever side the vs-main diff names.
|
|
@@ -2140,7 +2412,7 @@ export async function worktreeSpecDelta(wtPath: string, mainRef: string, baseHin
|
|
|
2140
2412
|
for (const r of proposals) {
|
|
2141
2413
|
const path = r.code === 'D' ? r.from : r.to
|
|
2142
2414
|
if (!isSpecMd(path)) continue
|
|
2143
|
-
if (!touched.has(r.to) && !touched.has(r.from)) continue
|
|
2415
|
+
if (!touched.has(r.to) && !touched.has(r.from)) continue
|
|
2144
2416
|
seen.add(path)
|
|
2145
2417
|
const op = codeFor[r.code] ?? 'edited'
|
|
2146
2418
|
ops.push({
|
|
@@ -2156,3 +2428,129 @@ export async function worktreeSpecDelta(wtPath: string, mainRef: string, baseHin
|
|
|
2156
2428
|
}
|
|
2157
2429
|
return ops
|
|
2158
2430
|
}
|
|
2431
|
+
|
|
2432
|
+
type WorktreeSpecDemand = { path: string; head: string }
|
|
2433
|
+
export type WorktreeSpecDeltaOutcome = { base: string; ops: NodeOp[] } | { error: unknown }
|
|
2434
|
+
|
|
2435
|
+
async function boundedMap<T, R>(values: T[], concurrency: number, fn: (value: T) => Promise<R>): Promise<R[]> {
|
|
2436
|
+
const results = new Array<R>(values.length)
|
|
2437
|
+
let next = 0
|
|
2438
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, async () => {
|
|
2439
|
+
while (true) {
|
|
2440
|
+
const index = next++
|
|
2441
|
+
if (index >= values.length) return
|
|
2442
|
+
results[index] = await fn(values[index])
|
|
2443
|
+
}
|
|
2444
|
+
}))
|
|
2445
|
+
return results
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
// One cold layout demand across many linked worktrees. Merge-base and working-status are path facts, so they
|
|
2449
|
+
// stay per worktree under a fixed concurrency. Clean rows then collapse to immutable main→HEAD and base→HEAD
|
|
2450
|
+
// pairs in one framed child; dirty rows retain the ordinary worktree-aware projection.
|
|
2451
|
+
export async function worktreeSpecDeltas(root: string, mainSha: string, demands: WorktreeSpecDemand[], interpretation = gitInterpretationIdentity(root)): Promise<Map<string, WorktreeSpecDeltaOutcome>> {
|
|
2452
|
+
const results = new Map<string, WorktreeSpecDeltaOutcome>()
|
|
2453
|
+
if (!demands.length) return results
|
|
2454
|
+
if (gitInterpretationIdentity(root) !== interpretation) {
|
|
2455
|
+
const error = new Error('Git interpretation changed before the worktree overlay batch')
|
|
2456
|
+
for (const demand of demands) results.set(demand.path, { error })
|
|
2457
|
+
return results
|
|
2458
|
+
}
|
|
2459
|
+
const prepared = await boundedMap(demands, 4, async (demand) => {
|
|
2460
|
+
try {
|
|
2461
|
+
const run = (args: string[]) => gitARequired(['-C', demand.path, '-c', 'core.quotePath=false', ...args])
|
|
2462
|
+
const [baseOut, statusOut] = await Promise.all([
|
|
2463
|
+
run(['merge-base', mainSha, demand.head]),
|
|
2464
|
+
run(['status', '--porcelain', '--untracked-files=all', '--', '.spec']),
|
|
2465
|
+
])
|
|
2466
|
+
const base = baseOut.trim()
|
|
2467
|
+
if (!base) throw new Error(`git merge-base returned no base for ${demand.path}`)
|
|
2468
|
+
return { ...demand, base, statusOut }
|
|
2469
|
+
} catch (error) {
|
|
2470
|
+
results.set(demand.path, { error })
|
|
2471
|
+
return null
|
|
2472
|
+
}
|
|
2473
|
+
})
|
|
2474
|
+
|
|
2475
|
+
const clean = prepared.filter((row): row is NonNullable<typeof row> => !!row && row.statusOut.trim() === '')
|
|
2476
|
+
const dirty = prepared.filter((row): row is NonNullable<typeof row> => !!row && row.statusOut.trim() !== '')
|
|
2477
|
+
const pairs: Array<{ from: string; to: string }> = []
|
|
2478
|
+
const pairIndex = new Map<string, number>()
|
|
2479
|
+
const addPair = (from: string, to: string): number => {
|
|
2480
|
+
const key = `${from}\0${to}`
|
|
2481
|
+
const hit = pairIndex.get(key)
|
|
2482
|
+
if (hit != null) return hit
|
|
2483
|
+
const index = pairs.length
|
|
2484
|
+
pairIndex.set(key, index)
|
|
2485
|
+
pairs.push({ from, to })
|
|
2486
|
+
return index
|
|
2487
|
+
}
|
|
2488
|
+
const cleanIndexes = clean.map((row) => ({
|
|
2489
|
+
row,
|
|
2490
|
+
main: addPair(mainSha, row.head),
|
|
2491
|
+
branch: addPair(row.base, row.head),
|
|
2492
|
+
}))
|
|
2493
|
+
|
|
2494
|
+
if (pairs.length) {
|
|
2495
|
+
try {
|
|
2496
|
+
// diff-tree's stdin pair syntax is `<new> <old>`; reverse each ordinary from→to pair deliberately.
|
|
2497
|
+
// --always is load-bearing: it emits a frame for an empty pair, preserving positional ownership.
|
|
2498
|
+
const input = pairs.map(({ from, to }) => `${to} ${from}`).join('\n') + '\n'
|
|
2499
|
+
const out = await gitARequired([
|
|
2500
|
+
'-C', root, '-c', 'core.quotePath=false', 'diff-tree', '--stdin', '--no-commit-id', '--always',
|
|
2501
|
+
'-r', '--name-status', '-M', `--format=${RS}%H`, '--', '.spec',
|
|
2502
|
+
], input)
|
|
2503
|
+
// For two-tree stdin rows, diff-tree writes that row's name-status payload BEFORE its --format marker.
|
|
2504
|
+
// The first split segment is therefore pair 0; each later pair lives after the previous marker, while
|
|
2505
|
+
// the final marker carries no following pair. Treating markers as openers shifts every non-empty result.
|
|
2506
|
+
const records = out.split(RS)
|
|
2507
|
+
if (records.length - 1 !== pairs.length) throw new Error(`git diff-tree --stdin returned ${records.length - 1} frames for ${pairs.length} pairs`)
|
|
2508
|
+
const frames = pairs.map((_, index) => {
|
|
2509
|
+
if (index === 0) return records[0].split('\n').filter(Boolean).join('\n')
|
|
2510
|
+
const lines = records[index].replace(/^\n/, '').split('\n')
|
|
2511
|
+
lines.shift()
|
|
2512
|
+
return lines.filter(Boolean).join('\n')
|
|
2513
|
+
})
|
|
2514
|
+
for (const { row, main, branch } of cleanIndexes) {
|
|
2515
|
+
results.set(row.path, { base: row.base, ops: projectWorktreeSpecDelta(frames[main], frames[branch], frames[branch], '') })
|
|
2516
|
+
}
|
|
2517
|
+
} catch (error) {
|
|
2518
|
+
for (const { row } of cleanIndexes) results.set(row.path, { error })
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
await Promise.all(dirty.map(async (row) => {
|
|
2523
|
+
try {
|
|
2524
|
+
const run = (args: string[]) => gitARequired(['-C', row.path, '-c', 'core.quotePath=false', ...args])
|
|
2525
|
+
const [mainOut, workOut, commOut] = await Promise.all([
|
|
2526
|
+
run(['diff', '--name-status', '-M', mainSha, '--', '.spec']),
|
|
2527
|
+
run(['diff', '--name-status', '-M', row.base, '--', '.spec']),
|
|
2528
|
+
run(['diff', '--name-status', '-M', `${row.base}...${row.head}`, '--', '.spec']),
|
|
2529
|
+
])
|
|
2530
|
+
results.set(row.path, { base: row.base, ops: projectWorktreeSpecDelta(mainOut, workOut, commOut, row.statusOut) })
|
|
2531
|
+
} catch (error) {
|
|
2532
|
+
results.set(row.path, { error })
|
|
2533
|
+
}
|
|
2534
|
+
}))
|
|
2535
|
+
if (gitInterpretationIdentity(root) !== interpretation) {
|
|
2536
|
+
const error = new Error('Git interpretation changed during the worktree overlay batch')
|
|
2537
|
+
for (const demand of demands) results.set(demand.path, { error })
|
|
2538
|
+
}
|
|
2539
|
+
return results
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
export async function worktreeSpecDelta(wtPath: string, mainRef: string, baseHint?: string): Promise<NodeOp[]> {
|
|
2543
|
+
const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
|
|
2544
|
+
// fork point = where this worktree branched from main; '' (no common ancestor / unreadable ref) falls
|
|
2545
|
+
// back to mainRef so we still surface changes rather than going silent. The caller (cachedDelta) already
|
|
2546
|
+
// computes this same merge-base to key its cache, so it passes it in to avoid a redundant subprocess.
|
|
2547
|
+
const base = baseHint || (await run(['merge-base', mainRef, 'HEAD'])).trim() || mainRef
|
|
2548
|
+
// the four queries are independent — run them in parallel.
|
|
2549
|
+
const [mainOut, workOut, commOut, statusOut] = await Promise.all([
|
|
2550
|
+
run(['diff', '--name-status', '-M', mainRef, '--', '.spec']),
|
|
2551
|
+
run(['diff', '--name-status', '-M', base, '--', '.spec']),
|
|
2552
|
+
run(['diff', '--name-status', '-M', `${base}...HEAD`, '--', '.spec']),
|
|
2553
|
+
run(['status', '--porcelain', '--untracked-files=all', '--', '.spec']),
|
|
2554
|
+
])
|
|
2555
|
+
return projectWorktreeSpecDelta(mainOut, workOut, commOut, statusOut)
|
|
2556
|
+
}
|