spexcode 0.6.1 → 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 +171 -28
- package/spec-cli/src/client.ts +112 -13
- package/spec-cli/src/delivery-queue.ts +62 -4
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +477 -101
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +1 -1
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +344 -66
- package/spec-cli/src/help.ts +22 -10
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +101 -13
- package/spec-cli/src/layout.ts +116 -33
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +10 -5
- package/spec-cli/src/plugin-harness.ts +10 -10
- package/spec-cli/src/reviews.ts +57 -28
- 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 +584 -104
- package/spec-cli/src/uninstall.ts +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/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-F9uaAVcH.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-CuKLFhH3.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-CQgYymmr.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-hT7UTLvD.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CtXxakF9.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-CixDi4PI.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-DrVao0Ep.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-BBH02b1v.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-eval/src/cli.ts +6 -1
- package/spec-eval/src/evaltab.ts +7 -5
- package/spec-eval/src/freshness.ts +383 -104
- package/spec-eval/src/scenariofresh.ts +2 -2
- package/spec-eval/src/sessioneval.ts +126 -132
- package/spec-dashboard/dist/assets/Dashboard-Ba_jhxp1.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-FixoOg_n.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-CHgEHORJ.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Bpie-9fs.js +0 -39
- package/spec-dashboard/dist/assets/Settings-C2MsucfE.js +0 -1
- package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +0 -13
- package/spec-dashboard/dist/assets/data-B-RQmit6.js +0 -1
- package/spec-dashboard/dist/assets/index-CsI8DElI.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
package/spec-cli/src/git.ts
CHANGED
|
@@ -5,6 +5,7 @@ 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,6 +15,7 @@ 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
|
|
18
20
|
const gitByPath = new Map<string, string>()
|
|
19
21
|
|
|
@@ -125,7 +127,8 @@ export function currentGitBuildAbortSignal(): AbortSignal | undefined {
|
|
|
125
127
|
return inheritedContext()?.signal
|
|
126
128
|
}
|
|
127
129
|
function warnIfTimedOut(e: any, args: string[]): void {
|
|
128
|
-
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`)
|
|
129
132
|
}
|
|
130
133
|
|
|
131
134
|
// strip git's hook-exported env (GIT_DIR etc.) so every call discovers the repo from the filesystem.
|
|
@@ -133,7 +136,14 @@ export function git(args: string[]): string {
|
|
|
133
136
|
const env = { ...process.env }
|
|
134
137
|
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
135
138
|
try {
|
|
136
|
-
return execFileSync(gitBinary(env), withBuildLimits(args), {
|
|
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
|
+
})
|
|
137
147
|
} catch (e: any) { warnIfTimedOut(e, args); throw e }
|
|
138
148
|
}
|
|
139
149
|
|
|
@@ -147,7 +157,7 @@ function gitBuffer(args: string[], input?: string): Buffer {
|
|
|
147
157
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
148
158
|
timeout: GIT_TIMEOUT_MS,
|
|
149
159
|
killSignal: 'SIGKILL',
|
|
150
|
-
maxBuffer:
|
|
160
|
+
maxBuffer: GIT_SYNC_MAX_BUFFER,
|
|
151
161
|
})
|
|
152
162
|
} catch (e: any) { warnIfTimedOut(e, args); throw e }
|
|
153
163
|
}
|
|
@@ -184,9 +194,10 @@ export function isGitObjectId(root: string, value: string): boolean {
|
|
|
184
194
|
// source corpus never approaches the byte ceiling.
|
|
185
195
|
const BATCH_BLOB_CHUNK = 256
|
|
186
196
|
const BATCH_BLOB_MAX_BUFFER = 1 << 26
|
|
187
|
-
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> {
|
|
188
198
|
const env = { ...process.env }
|
|
189
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)
|
|
190
201
|
try { return (await execGitForCaller(args, env, maxBuffer, input)).stdout }
|
|
191
202
|
catch (error: any) {
|
|
192
203
|
if (error?.name === 'AbortError') throw error
|
|
@@ -194,9 +205,10 @@ async function batchBuffer(args: string[], input: string, maxBuffer?: number): P
|
|
|
194
205
|
throw new Error(`git ${args.slice(2, 5).join(' ')} failed: ${String(error?.stderr || error?.message || 'unknown git error').trim()}`)
|
|
195
206
|
}
|
|
196
207
|
}
|
|
197
|
-
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)[]> {
|
|
198
209
|
if (!revisions.length) return []
|
|
199
|
-
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')
|
|
200
212
|
const lines = out.split('\n')
|
|
201
213
|
if (lines.length - 1 !== revisions.length) throw new Error(`git cat-file --batch-check returned ${lines.length - 1} rows for ${revisions.length} revisions`)
|
|
202
214
|
return revisions.map((revision, index) => {
|
|
@@ -353,12 +365,13 @@ async function execGitForCaller(args: string[], env: NodeJS.ProcessEnv, maxBuffe
|
|
|
353
365
|
// Event streams are the index input itself and may legitimately exceed execFile's fixed maxBuffer. Read
|
|
354
366
|
// them through spawn so the only bound is the index the caller is intentionally constructing; timeout,
|
|
355
367
|
// cancellation, process-group cleanup and build permits remain identical to the ordinary async transport.
|
|
356
|
-
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> {
|
|
357
369
|
return new Promise((resolve, reject) => {
|
|
358
370
|
if (signal?.aborted) { reject(gitAbortError()); return }
|
|
359
|
-
const child = spawn(gitBinary(env), args, { env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
371
|
+
const child = spawn(gitBinary(env), args, { env, detached: true, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] })
|
|
360
372
|
const stdout: Buffer[] = [], stderr: Buffer[] = []
|
|
361
373
|
let settled = false, aborted = false, timedOut = false
|
|
374
|
+
let stdinError: any = null
|
|
362
375
|
const killTree = () => {
|
|
363
376
|
if (!child.pid) return
|
|
364
377
|
try { process.kill(-child.pid, 'SIGKILL') } catch { /* group may already be gone */ }
|
|
@@ -368,8 +381,14 @@ function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSig
|
|
|
368
381
|
const timer = setTimeout(() => { timedOut = true; killTree() }, GIT_TIMEOUT_MS)
|
|
369
382
|
timer.unref?.()
|
|
370
383
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
371
|
-
child.stdout
|
|
372
|
-
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
|
+
}
|
|
373
392
|
child.on('error', (error: any) => {
|
|
374
393
|
if (settled) return
|
|
375
394
|
settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort)
|
|
@@ -381,9 +400,9 @@ function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSig
|
|
|
381
400
|
if (settled) return
|
|
382
401
|
settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort)
|
|
383
402
|
const result = { stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr).toString('utf8') }
|
|
384
|
-
if (code === 0 && !aborted && !timedOut) { resolve(result); return }
|
|
385
|
-
const error: any = new Error(`git exited with ${code ?? childSignal ?? 'unknown status'}`)
|
|
386
|
-
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
|
|
387
406
|
error.signal = childSignal
|
|
388
407
|
error.stdout = result.stdout.toString('utf8')
|
|
389
408
|
error.stderr = result.stderr
|
|
@@ -393,11 +412,11 @@ function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSig
|
|
|
393
412
|
})
|
|
394
413
|
})
|
|
395
414
|
}
|
|
396
|
-
async function execGitStreamForCaller(args: string[], env: NodeJS.ProcessEnv): Promise<GitExec> {
|
|
415
|
+
async function execGitStreamForCaller(args: string[], env: NodeJS.ProcessEnv, input?: string): Promise<GitExec> {
|
|
397
416
|
const context = inheritedContext()
|
|
398
|
-
if (!context) return execGitStream(args, env)
|
|
417
|
+
if (!context) return execGitStream(args, env, undefined, input)
|
|
399
418
|
const release = await context.permits.acquire(context.signal)
|
|
400
|
-
try { return await execGitStream(withBuildLimits(args), env, context.signal) }
|
|
419
|
+
try { return await execGitStream(withBuildLimits(args), env, context.signal, input) }
|
|
401
420
|
finally { release() }
|
|
402
421
|
}
|
|
403
422
|
|
|
@@ -414,19 +433,47 @@ export async function gitA(args: string[], input?: string): Promise<string> {
|
|
|
414
433
|
}
|
|
415
434
|
}
|
|
416
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
|
+
|
|
417
447
|
type TextEventRecord = { hash: string; raw: string }
|
|
418
448
|
type IdentityEventRecord = { hash: string; identity: IdentityRawRecord }
|
|
419
449
|
type EventRecord = TextEventRecord | IdentityEventRecord
|
|
420
|
-
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
|
+
}
|
|
421
456
|
type EventStreamOutput = string | IdentityRawRecord[]
|
|
422
|
-
|
|
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'
|
|
423
461
|
const EVENT_STREAM_KINDS = ['merge', 'identity-raw'] as const
|
|
424
462
|
type EventStreamKind = typeof EVENT_STREAM_KINDS[number]
|
|
425
|
-
type EventCacheLocation = { path: string; identity: string; objectFormat: GitObjectFormat }
|
|
463
|
+
type EventCacheLocation = { path: string; identity: string; interpretation: string; objectFormat: GitObjectFormat }
|
|
426
464
|
type EventLedgerSnapshot = {
|
|
427
465
|
payload: Buffer
|
|
428
466
|
state: EventCache
|
|
429
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 }
|
|
430
477
|
type EventStreamRequest = {
|
|
431
478
|
kind: EventStreamKind
|
|
432
479
|
argsFor: (base: string) => string[]
|
|
@@ -478,20 +525,41 @@ function eventCacheLocation(root: string): EventCacheLocation {
|
|
|
478
525
|
: git(['-C', root, 'for-each-ref', 'refs/replace', '--format=%(refname) %(objectname)'])
|
|
479
526
|
const objectFormat = gitObjectFormat(root)
|
|
480
527
|
if (old && old.shallow === shallow && old.grafts === grafts && old.replacements === replacements && old.objectFormat === objectFormat)
|
|
481
|
-
return { path: old.path, identity: old.identity, objectFormat }
|
|
482
|
-
const
|
|
528
|
+
return { path: old.path, identity: old.identity, interpretation: old.interpretation, objectFormat }
|
|
529
|
+
const interpretation = createHash('sha256')
|
|
483
530
|
.update(`${EVENT_CACHE_SCHEMA}\0${objectFormat}\0${shallow}\0${grafts}\0${replacements}`)
|
|
484
|
-
.digest('hex')
|
|
531
|
+
.digest('hex')
|
|
532
|
+
const identity = interpretation.slice(0, 16)
|
|
485
533
|
// projectRuntimeRoot derives checkout identity from dirname(common). A bare repository is its own common
|
|
486
534
|
// dir, so a synthetic `.git` suffix preserves the repository path instead of collapsing sibling bares.
|
|
487
535
|
const gitDir = gitDirOf(root)
|
|
488
536
|
const storeIdentity = gitDir === root && common === root ? join(common, '.git') : common
|
|
489
537
|
const path = join(projectRuntimeRoot(storeIdentity), `${EVENT_CACHE_SCHEMA}-${identity}.ndjson`)
|
|
490
|
-
eventPathMemo.set(rootId, { common, shallowPath, grafts, shallow, replacementStorage, replacements, path, identity, objectFormat })
|
|
491
|
-
return { path, identity, objectFormat }
|
|
492
|
-
}
|
|
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 }
|
|
493
561
|
function emptyEventCache(): EventCache {
|
|
494
|
-
return { streams: new Map(), streamTips: new Map() }
|
|
562
|
+
return { streams: new Map(), streamTips: new Map(), hunks: new Map() }
|
|
495
563
|
}
|
|
496
564
|
function exactKeys(value: Record<string, unknown>, expected: string[]): boolean {
|
|
497
565
|
const actual = Object.keys(value).sort()
|
|
@@ -516,6 +584,28 @@ function eventStreamKind(value: unknown): EventStreamKind | null {
|
|
|
516
584
|
? value as EventStreamKind
|
|
517
585
|
: null
|
|
518
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
|
+
}
|
|
519
609
|
function decodeEventPayload(payload: Buffer, location: EventCacheLocation): EventCache | null {
|
|
520
610
|
const state = emptyEventCache()
|
|
521
611
|
const text = payload.toString('utf8')
|
|
@@ -529,6 +619,12 @@ function decodeEventPayload(payload: Buffer, location: EventCacheLocation): Even
|
|
|
529
619
|
try { value = JSON.parse(line) } catch { return null }
|
|
530
620
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
531
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
|
+
}
|
|
532
628
|
if (exactKeys(row, ['k', 'tip'])) {
|
|
533
629
|
const kind = typeof row.k === 'string' && row.k.startsWith('tip:') ? eventStreamKind(row.k.slice(4)) : null
|
|
534
630
|
if (!kind || typeof row.tip !== 'string' || !isGitObjectIdForFormat(location.objectFormat, row.tip)) return null
|
|
@@ -563,6 +659,7 @@ function decodeEventPayload(payload: Buffer, location: EventCacheLocation): Even
|
|
|
563
659
|
return state
|
|
564
660
|
}
|
|
565
661
|
function loadEventLedger(location: EventCacheLocation): EventLedgerSnapshot {
|
|
662
|
+
eventLedgerDiagnostics.reads++
|
|
566
663
|
let file: Buffer
|
|
567
664
|
try { file = readFileSync(location.path) }
|
|
568
665
|
catch (error: any) {
|
|
@@ -590,18 +687,29 @@ function loadEventLedger(location: EventCacheLocation): EventLedgerSnapshot {
|
|
|
590
687
|
}
|
|
591
688
|
}
|
|
592
689
|
|
|
593
|
-
type EventLockOwner = { pid: number;
|
|
690
|
+
type EventLockOwner = { pid: number; startToken: string; nonce: string }
|
|
594
691
|
function readEventLockOwner(lock: string): EventLockOwner | null {
|
|
595
692
|
try {
|
|
596
|
-
const value = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8')) as { pid?: unknown;
|
|
597
|
-
return Number.isInteger(value.pid) && (value.pid as number) > 0
|
|
598
|
-
|
|
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 }
|
|
599
698
|
: null
|
|
600
699
|
} catch { return null }
|
|
601
700
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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'
|
|
605
713
|
}
|
|
606
714
|
function retireLockPath(active: string): boolean {
|
|
607
715
|
const inert = `${active}.inert.${process.pid}.${randomBytes(8).toString('hex')}`
|
|
@@ -614,14 +722,17 @@ function retireLockPath(active: string): boolean {
|
|
|
614
722
|
return true
|
|
615
723
|
}
|
|
616
724
|
function reclaimDeadEventLock(lock: string, held: EventLockOwner, claimant: EventLockOwner): boolean {
|
|
617
|
-
if (
|
|
725
|
+
if (eventLockOwnerState(held) !== 'dead') return false
|
|
618
726
|
const reclaim = join(lock, 'reclaim')
|
|
619
727
|
if (existsSync(reclaim)) {
|
|
620
728
|
const reclaimer = readEventLockOwner(reclaim)
|
|
621
|
-
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
|
|
622
733
|
retireLockPath(reclaim)
|
|
623
734
|
}
|
|
624
|
-
const prepared = join(lock, `reclaim-${claimant.pid}-${claimant.
|
|
735
|
+
const prepared = join(lock, `reclaim-${claimant.pid}-${claimant.nonce}`)
|
|
625
736
|
try {
|
|
626
737
|
mkdirSync(prepared)
|
|
627
738
|
writeFileSync(join(prepared, 'owner.json'), JSON.stringify(claimant))
|
|
@@ -632,21 +743,29 @@ function reclaimDeadEventLock(lock: string, held: EventLockOwner, claimant: Even
|
|
|
632
743
|
throw error
|
|
633
744
|
}
|
|
634
745
|
const current = readEventLockOwner(lock)
|
|
635
|
-
if (current
|
|
746
|
+
if (!sameEventLockOwner(current, held) || (current && eventLockOwnerState(current) !== 'dead')) {
|
|
636
747
|
retireLockPath(reclaim)
|
|
637
748
|
return false
|
|
638
749
|
}
|
|
639
750
|
return retireLockPath(lock)
|
|
640
751
|
}
|
|
641
|
-
|
|
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> {
|
|
642
759
|
const lock = `${path}.lock`
|
|
643
760
|
mkdirSync(join(path, '..'), { recursive: true })
|
|
644
|
-
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') }
|
|
645
764
|
const attempts = Math.max(1, Math.ceil(GIT_TIMEOUT_MS / 5))
|
|
646
765
|
const signal = inheritedContext()?.signal
|
|
647
766
|
for (let attempt = 0; ; attempt++) {
|
|
648
767
|
if (signal?.aborted) throw gitAbortError()
|
|
649
|
-
const claimant = `${lock}.claim.${owner.pid}.${owner.
|
|
768
|
+
const claimant = `${lock}.claim.${owner.pid}.${owner.nonce}`
|
|
650
769
|
try {
|
|
651
770
|
mkdirSync(claimant)
|
|
652
771
|
writeFileSync(join(claimant, 'owner.json'), JSON.stringify(owner))
|
|
@@ -656,7 +775,30 @@ async function withEventCacheLock<T>(path: string, run: () => Promise<T> | T): P
|
|
|
656
775
|
rmSync(claimant, { recursive: true, force: true })
|
|
657
776
|
if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error
|
|
658
777
|
const held = readEventLockOwner(lock)
|
|
659
|
-
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
|
+
}
|
|
660
802
|
if (attempt >= attempts) {
|
|
661
803
|
const heldBy = readEventLockOwner(lock)?.pid
|
|
662
804
|
throw new Error(`timed out waiting for history event cache lock held by ${heldBy ? `pid ${heldBy}` : 'unknown owner'}: ${path}`)
|
|
@@ -664,8 +806,9 @@ async function withEventCacheLock<T>(path: string, run: () => Promise<T> | T): P
|
|
|
664
806
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
665
807
|
}
|
|
666
808
|
}
|
|
809
|
+
eventLedgerDiagnostics.locks++
|
|
667
810
|
try { return await run() } finally {
|
|
668
|
-
if (readEventLockOwner(lock)
|
|
811
|
+
if (sameEventLockOwner(readEventLockOwner(lock), owner)) retireLockPath(lock)
|
|
669
812
|
}
|
|
670
813
|
}
|
|
671
814
|
function removeEventTemps(path: string): void {
|
|
@@ -686,12 +829,78 @@ function replaceEventLedger(path: string, payload: Buffer, additions: string[]):
|
|
|
686
829
|
writeFileSync(fd, eventIntegrityFooter(payload, addition))
|
|
687
830
|
const written = fd; fd = null; closeSync(written)
|
|
688
831
|
renameSync(tmp, path)
|
|
832
|
+
eventLedgerDiagnostics.replaces++
|
|
689
833
|
} catch (error) {
|
|
690
834
|
if (fd !== null) closeSync(fd)
|
|
691
835
|
rmSync(tmp, { force: true })
|
|
692
836
|
throw error
|
|
693
837
|
}
|
|
694
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
|
+
}
|
|
695
904
|
function sortedEventRecords(records: Iterable<EventRecord>, request: EventStreamRequest): EventRecord[] {
|
|
696
905
|
return [...records].filter((record) => request.reachable.has(record.hash))
|
|
697
906
|
.sort((a, b) => (request.order.get(a.hash) ?? Number.MAX_SAFE_INTEGER) - (request.order.get(b.hash) ?? Number.MAX_SAFE_INTEGER))
|
|
@@ -729,6 +938,16 @@ function appendEventTip(state: EventCache, kind: EventStreamKind, tip: string, a
|
|
|
729
938
|
state.streamTips.set(kind, tips)
|
|
730
939
|
additions.push(JSON.stringify({ k: `tip:${kind}`, tip }) + '\n')
|
|
731
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
|
+
}
|
|
732
951
|
function parseEventRecords(out: string, kind: EventStreamKind, location: EventCacheLocation): EventRecord[] {
|
|
733
952
|
if (kind === 'identity-raw') return parseIdentityRawEventRecords(out, location)
|
|
734
953
|
const records: EventRecord[] = []
|
|
@@ -789,39 +1008,46 @@ async function deriveEventStreams(
|
|
|
789
1008
|
}
|
|
790
1009
|
if (new Set(requests.map((request) => request.kind)).size !== requests.length)
|
|
791
1010
|
throw new Error('one event-ledger transaction cannot request the same stream twice')
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
const
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
for (
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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)
|
|
824
1049
|
}
|
|
1050
|
+
|
|
825
1051
|
async function eventStream(
|
|
826
1052
|
root: string,
|
|
827
1053
|
tip: string,
|
|
@@ -844,7 +1070,7 @@ async function identityRawEventStream(root: string, tip: string, request: EventS
|
|
|
844
1070
|
return value as IdentityRawRecord[]
|
|
845
1071
|
}
|
|
846
1072
|
export type GitTryFailure = 'exit' | 'spawn' | 'timeout'
|
|
847
|
-
export async function gitTry(args: string[], options: { indexFile?: string; extraEnv?: Record<string, string | undefined
|
|
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 }> {
|
|
848
1074
|
const env = { ...process.env }
|
|
849
1075
|
for (const [key, value] of Object.entries(options.extraEnv ?? {})) {
|
|
850
1076
|
if (value === undefined) delete env[key]
|
|
@@ -854,7 +1080,7 @@ export async function gitTry(args: string[], options: { indexFile?: string; extr
|
|
|
854
1080
|
if (options.indexFile) env.GIT_INDEX_FILE = options.indexFile
|
|
855
1081
|
const context = inheritedContext()
|
|
856
1082
|
try {
|
|
857
|
-
const { stdout, stderr } = await execGitForCaller(args, env)
|
|
1083
|
+
const { stdout, stderr } = await execGitForCaller(args, env, undefined, options.input)
|
|
858
1084
|
return { ok: true, stdout: stdout.toString('utf8'), stderr }
|
|
859
1085
|
} catch (e: any) {
|
|
860
1086
|
if (context?.signal.aborted || e?.name === 'AbortError') throw e
|
|
@@ -864,10 +1090,17 @@ export async function gitTry(args: string[], options: { indexFile?: string; extr
|
|
|
864
1090
|
}
|
|
865
1091
|
}
|
|
866
1092
|
|
|
867
|
-
|
|
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> {
|
|
868
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
|
+
}
|
|
869
1102
|
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
870
|
-
try { return (await execGitStreamForCaller(args, env)).stdout.toString('utf8') }
|
|
1103
|
+
try { return (await execGitStreamForCaller(args, env, options.input)).stdout.toString('utf8') }
|
|
871
1104
|
catch (error: any) {
|
|
872
1105
|
if (error?.name === 'AbortError') throw error
|
|
873
1106
|
warnIfTimedOut(error, args)
|
|
@@ -1467,6 +1700,15 @@ export type DriftPathEvent = {
|
|
|
1467
1700
|
parents: { commit: string; historicalPath: string }[]
|
|
1468
1701
|
}
|
|
1469
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
|
+
|
|
1470
1712
|
export type DiffLineRange = [number, number]
|
|
1471
1713
|
export type CombinedDiffOwnedChanges = { after: DiffLineRange[]; before: DiffLineRange[][]; parentPaths: string[] }
|
|
1472
1714
|
|
|
@@ -1854,7 +2096,7 @@ export function historyEventCachePathForTests(root: string): string { return eve
|
|
|
1854
2096
|
// this direct parent DFS, so neither path introduces a second reachability representation or persistent fact.
|
|
1855
2097
|
// undefined when `sha` is not reachable from HEAD (rebased away, an unmerged branch, or never on any
|
|
1856
2098
|
// ref) — callers apply their own conservative rule to that "can't prove" case.
|
|
1857
|
-
export function ancestorsOf(idx:
|
|
2099
|
+
export function ancestorsOf(idx: Reachability, sha: string): Uint8Array | undefined {
|
|
1858
2100
|
const hit = idx.anc.get(sha)
|
|
1859
2101
|
if (hit) return hit
|
|
1860
2102
|
const start = idx.ord.get(sha)
|
|
@@ -1879,7 +2121,7 @@ export function ancestorsOf(idx: DriftIndex, sha: string): Uint8Array | undefine
|
|
|
1879
2121
|
// Fill the existing ancestry memo for a known roster in one child-before-parent topology pass. At each
|
|
1880
2122
|
// commit the transient row names requested descendants; emitting those bits into the ordinary closures makes
|
|
1881
2123
|
// the resulting bytes identical to calling ancestorsOf() independently for every requested SHA.
|
|
1882
|
-
export function primeAncestorClosures(idx:
|
|
2124
|
+
export function primeAncestorClosures(idx: Reachability, shas: Iterable<string>): void {
|
|
1883
2125
|
const endpoints = [...new Set(shas)].filter((sha) => !idx.anc.has(sha) && idx.ord.has(sha))
|
|
1884
2126
|
if (!endpoints.length) return
|
|
1885
2127
|
const count = idx.ord.size
|
|
@@ -1938,11 +2180,31 @@ export function primeAncestorClosures(idx: DriftIndex, shas: Iterable<string>):
|
|
|
1938
2180
|
throw new Error(`cannot prime ancestry closures: topology yielded ${visited} of ${count} reachable commits`)
|
|
1939
2181
|
for (let position = 0; position < endpoints.length; position++) idx.anc.set(endpoints[position], closures[position])
|
|
1940
2182
|
}
|
|
1941
|
-
export function inAncestors(idx:
|
|
2183
|
+
export function inAncestors(idx: Reachability, bits: Uint8Array, sha: string): boolean {
|
|
1942
2184
|
const o = idx.ord.get(sha)
|
|
1943
2185
|
return o !== undefined && (bits[o >> 3] & (1 << (o & 7))) !== 0
|
|
1944
2186
|
}
|
|
1945
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
|
+
|
|
1946
2208
|
// @@@ reachability is membership, not a closure - `ancestorsOf` returns undefined for EXACTLY the shas
|
|
1947
2209
|
// absent from `idx.ord` (both writers of `idx.anc` gate on ord: the single-sha path after its ord lookup
|
|
1948
2210
|
// succeeds, the batch after an explicit `ord.has` filter), so asking it here answered a hash-table
|
|
@@ -2082,13 +2344,13 @@ function parseStatPath(token: string): { from: string; to: string } {
|
|
|
2082
2344
|
const arrowAt = token.indexOf(' => ')
|
|
2083
2345
|
return arrowAt >= 0 ? { from: token.slice(0, arrowAt), to: token.slice(arrowAt + 4) } : { from: token, to: token }
|
|
2084
2346
|
}
|
|
2085
|
-
export async function mergeBaseDiff(wtPath: string, mainRef = 'main'): Promise<ReviewDiffFile[]> {
|
|
2347
|
+
export async function mergeBaseDiff(wtPath: string, mainRef = 'main', headRef = 'HEAD'): Promise<ReviewDiffFile[]> {
|
|
2086
2348
|
const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
|
|
2087
|
-
const base = (await run(['merge-base', mainRef,
|
|
2349
|
+
const base = (await run(['merge-base', mainRef, headRef])).trim()
|
|
2088
2350
|
if (!base) return []
|
|
2089
2351
|
const [numstatOut, statusOut] = await Promise.all([
|
|
2090
|
-
run(['diff', '--numstat', '-M', `${base}
|
|
2091
|
-
run(['diff', '--name-status', '-M', `${base}
|
|
2352
|
+
run(['diff', '--numstat', '-M', `${base}..${headRef}`]),
|
|
2353
|
+
run(['diff', '--name-status', '-M', `${base}..${headRef}`]),
|
|
2092
2354
|
])
|
|
2093
2355
|
const status = new Map<string, { status: string; from: string }>()
|
|
2094
2356
|
for (const r of parseNameStatus(statusOut)) status.set(r.to, { status: DIFF_STATUS[r.code] ?? r.code, from: r.from })
|
|
@@ -2109,11 +2371,11 @@ export async function mergeBaseDiff(wtPath: string, mainRef = 'main'): Promise<R
|
|
|
2109
2371
|
return files
|
|
2110
2372
|
}
|
|
2111
2373
|
|
|
2112
|
-
export function mergeConflicts(wtPath: string, mainRef = 'main'): Promise<boolean> {
|
|
2374
|
+
export function mergeConflicts(wtPath: string, mainRef = 'main', headRef = 'HEAD'): Promise<boolean> {
|
|
2113
2375
|
return new Promise((resolve) => {
|
|
2114
2376
|
const env = { ...process.env }
|
|
2115
2377
|
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
2116
|
-
execFile(gitBinary(env), ['-C', wtPath, 'merge-tree', '--write-tree', '--no-messages', mainRef,
|
|
2378
|
+
execFile(gitBinary(env), ['-C', wtPath, 'merge-tree', '--write-tree', '--no-messages', mainRef, headRef],
|
|
2117
2379
|
{ encoding: 'utf8', env, maxBuffer: 1 << 24 },
|
|
2118
2380
|
// execFile sets err.code to the numeric EXIT code on a non-zero exit (1 = conflicts), or a string
|
|
2119
2381
|
// errno (e.g. 'ENOENT') if git can't be spawned — only the exit-1 case is a real conflict verdict.
|
|
@@ -2126,19 +2388,7 @@ export function mergeConflicts(wtPath: string, mainRef = 'main'): Promise<boolea
|
|
|
2126
2388
|
// spoken: content equal to main is no op, an existing node reads `edited` never `added`) AND have been
|
|
2127
2389
|
// touched by this branch since its fork point (attribution — main's own post-fork movement is not this
|
|
2128
2390
|
// worktree's op). A `status --porcelain` pass adds untracked spec.md, a third diff vs HEAD marks committed.
|
|
2129
|
-
|
|
2130
|
-
const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
|
|
2131
|
-
// fork point = where this worktree branched from main; '' (no common ancestor / unreadable ref) falls
|
|
2132
|
-
// back to mainRef so we still surface changes rather than going silent. The caller (cachedDelta) already
|
|
2133
|
-
// computes this same merge-base to key its cache, so it passes it in to avoid a redundant subprocess.
|
|
2134
|
-
const base = baseHint || (await run(['merge-base', mainRef, 'HEAD'])).trim() || mainRef
|
|
2135
|
-
// the four queries are independent — run them in parallel.
|
|
2136
|
-
const [mainOut, workOut, commOut, statusOut] = await Promise.all([
|
|
2137
|
-
run(['diff', '--name-status', '-M', mainRef, '--', '.spec']),
|
|
2138
|
-
run(['diff', '--name-status', '-M', base, '--', '.spec']),
|
|
2139
|
-
run(['diff', '--name-status', '-M', `${base}...HEAD`, '--', '.spec']),
|
|
2140
|
-
run(['status', '--porcelain', '--untracked-files=all', '--', '.spec']),
|
|
2141
|
-
])
|
|
2391
|
+
function projectWorktreeSpecDelta(mainOut: string, workOut: string, commOut: string, statusOut: string): NodeOp[] {
|
|
2142
2392
|
const proposals = parseNameStatus(mainOut)
|
|
2143
2393
|
// the branch's own footprint since its fork point — both sides of every row, so a rename matches
|
|
2144
2394
|
// whichever side the vs-main diff names.
|
|
@@ -2162,7 +2412,7 @@ export async function worktreeSpecDelta(wtPath: string, mainRef: string, baseHin
|
|
|
2162
2412
|
for (const r of proposals) {
|
|
2163
2413
|
const path = r.code === 'D' ? r.from : r.to
|
|
2164
2414
|
if (!isSpecMd(path)) continue
|
|
2165
|
-
if (!touched.has(r.to) && !touched.has(r.from)) continue
|
|
2415
|
+
if (!touched.has(r.to) && !touched.has(r.from)) continue
|
|
2166
2416
|
seen.add(path)
|
|
2167
2417
|
const op = codeFor[r.code] ?? 'edited'
|
|
2168
2418
|
ops.push({
|
|
@@ -2178,3 +2428,129 @@ export async function worktreeSpecDelta(wtPath: string, mainRef: string, baseHin
|
|
|
2178
2428
|
}
|
|
2179
2429
|
return ops
|
|
2180
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
|
+
}
|