spexcode 0.5.6 → 0.5.8
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/package.json +1 -1
- package/spec-cli/src/anchors.ts +48 -38
- package/spec-cli/src/cli.ts +18 -1
- package/spec-cli/src/client.ts +21 -0
- package/spec-cli/src/git.ts +380 -198
- package/spec-cli/src/harness.ts +116 -24
- package/spec-cli/src/help.ts +3 -1
- package/spec-cli/src/index.ts +41 -7
- package/spec-cli/src/layout.ts +2 -0
- package/spec-cli/src/lint.ts +38 -28
- package/spec-cli/src/mentions.ts +10 -5
- package/spec-cli/src/process-identity.ts +20 -0
- package/spec-cli/src/session-maintenance.ts +2 -1
- package/spec-cli/src/sessions.ts +898 -140
- package/spec-cli/src/specs.ts +3 -3
- package/spec-dashboard/dist/assets/{App-B72LuS5I.js → App-u2P7KdSg.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-C5X4Va3V.js → Dashboard-B8wp5_61.js} +3 -3
- package/spec-dashboard/dist/assets/{EvalsPage-BTvJIW8Q.js → EvalsPage-Bq1Tkb8y.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-Bn94h_HQ.js → IssuesPage-BlkPSkmv.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-ClbtwZ1e.js → MobileApp-B1GxRZXK.js} +2 -2
- package/spec-dashboard/dist/assets/{Modal-6l_QtCKF.js → Modal-bAkq9IIT.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-B2kxcqJJ.js → PageScroll-px_rUZVJ.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-C8IPsMKV.js → ProjectsPage-8uGqYM12.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-CswwbewF.js +39 -0
- package/spec-dashboard/dist/assets/{SessionWindow-Dag_GiJB.js → SessionWindow-IspcLjFA.js} +1 -1
- package/spec-dashboard/dist/assets/{Settings-J3aibcXo.js → Settings-bpAbfnmS.js} +1 -1
- package/spec-dashboard/dist/assets/{Thread-Dg35J-Pu.js → Thread-BpL3N3kw.js} +11 -11
- package/spec-dashboard/dist/assets/{TimelineChat-f0UF9fXq.js → TimelineChat-Ckmb1Ez2.js} +1 -1
- package/spec-dashboard/dist/assets/{data-SNi0AmVT.js → data-CQFbQEMH.js} +1 -1
- package/spec-dashboard/dist/assets/index-CixSnz1H.css +1 -0
- package/spec-dashboard/dist/assets/{index-BUKLPN_4.js → index-Di1ch5dd.js} +5 -5
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/dist/assets/SessionInterface-B5jf7dW7.js +0 -39
- package/spec-dashboard/dist/assets/index-CzutlTDf.css +0 -1
package/spec-cli/src/git.ts
CHANGED
|
@@ -235,44 +235,62 @@ type GitExec = { stdout: string; stderr: string }
|
|
|
235
235
|
// deterministic tests use a shell + sleep), so async git runs in their own process group and abort/timeout
|
|
236
236
|
// kills the whole group. The callback still carries the same stdout/stderr/error shape to gitA/gitTry.
|
|
237
237
|
const GIT_MAX_BUFFER = 1 << 24
|
|
238
|
-
function execGit(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal, maxBuffer = GIT_MAX_BUFFER): Promise<GitExec> {
|
|
238
|
+
function execGit(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal, maxBuffer = GIT_MAX_BUFFER, input?: string): Promise<GitExec> {
|
|
239
239
|
return new Promise((resolve, reject) => {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
let timedOut = false
|
|
240
|
+
if (signal?.aborted) { reject(gitAbortError()); return }
|
|
241
|
+
const child = spawn('git', args, { env, detached: true, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] })
|
|
242
|
+
const stdout: Buffer[] = [], stderr: Buffer[] = []
|
|
243
|
+
let stdoutBytes = 0, stderrBytes = 0, aborted = false, timedOut = false, overflow = false
|
|
244
|
+
let spawnError: Error | null = null
|
|
244
245
|
const killTree = () => {
|
|
245
|
-
if (!child
|
|
246
|
+
if (!child.pid) return
|
|
246
247
|
try { process.kill(-child.pid, 'SIGKILL') } catch { /* group may already be gone */ }
|
|
247
248
|
try { child.kill('SIGKILL') } catch { /* already exited */ }
|
|
248
249
|
}
|
|
249
250
|
const onAbort = () => { aborted = true; killTree() }
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
251
|
+
const append = (chunks: Buffer[], chunk: Buffer, stream: 'stdout' | 'stderr') => {
|
|
252
|
+
const total = stream === 'stdout' ? (stdoutBytes += chunk.length) : (stderrBytes += chunk.length)
|
|
253
|
+
if (total > maxBuffer) { overflow = true; killTree(); return }
|
|
254
|
+
chunks.push(chunk)
|
|
255
|
+
}
|
|
256
|
+
child.stdout!.on('data', (chunk: Buffer) => append(stdout, chunk, 'stdout'))
|
|
257
|
+
child.stderr!.on('data', (chunk: Buffer) => append(stderr, chunk, 'stderr'))
|
|
258
|
+
child.once('error', (error) => { spawnError = error })
|
|
259
|
+
if (input !== undefined) {
|
|
260
|
+
// A command can reject its input before the pipe drains. Keep that write failure on the same
|
|
261
|
+
// close/reject path as spawn and exit failures instead of letting Node raise an unhandled EPIPE.
|
|
262
|
+
child.stdin!.once('error', (error) => { if (!spawnError) spawnError = error })
|
|
263
|
+
child.stdin!.end(input)
|
|
264
|
+
}
|
|
265
|
+
child.once('close', (code, childSignal) => {
|
|
266
|
+
clearTimeout(timer)
|
|
255
267
|
signal?.removeEventListener('abort', onAbort)
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
268
|
+
const result = { stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8') }
|
|
269
|
+
if (code === 0 && !aborted && !timedOut && !overflow && !spawnError) { resolve(result); return }
|
|
270
|
+
const error: any = spawnError ?? new Error(overflow
|
|
271
|
+
? `git output exceeded ${maxBuffer} bytes`
|
|
272
|
+
: `git exited with ${code ?? childSignal ?? 'unknown status'}`)
|
|
273
|
+
error.code = overflow ? 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' : code
|
|
274
|
+
error.signal = childSignal
|
|
275
|
+
error.stdout = result.stdout
|
|
276
|
+
error.stderr = result.stderr
|
|
277
|
+
if (aborted) error.name = 'AbortError'
|
|
278
|
+
if (timedOut) error.spexcodeGitTimeout = true
|
|
279
|
+
reject(error)
|
|
263
280
|
})
|
|
264
281
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
265
|
-
|
|
282
|
+
if (signal?.aborted) onAbort()
|
|
283
|
+
const timer = setTimeout(() => { timedOut = true; killTree() }, GIT_TIMEOUT_MS)
|
|
266
284
|
timer.unref?.()
|
|
267
285
|
})
|
|
268
286
|
}
|
|
269
287
|
|
|
270
|
-
async function execGitForCaller(args: string[], env: NodeJS.ProcessEnv, maxBuffer?: number): Promise<GitExec> {
|
|
288
|
+
async function execGitForCaller(args: string[], env: NodeJS.ProcessEnv, maxBuffer?: number, input?: string): Promise<GitExec> {
|
|
271
289
|
const context = inheritedContext()
|
|
272
|
-
if (!context) return execGit(args, env, undefined, maxBuffer)
|
|
290
|
+
if (!context) return execGit(args, env, undefined, maxBuffer, input)
|
|
273
291
|
const release = await context.permits.acquire(context.signal)
|
|
274
292
|
try {
|
|
275
|
-
return await execGit(withBuildLimits(args), env, context.signal, maxBuffer)
|
|
293
|
+
return await execGit(withBuildLimits(args), env, context.signal, maxBuffer, input)
|
|
276
294
|
} finally {
|
|
277
295
|
release()
|
|
278
296
|
}
|
|
@@ -329,12 +347,12 @@ async function execGitStreamForCaller(args: string[], env: NodeJS.ProcessEnv): P
|
|
|
329
347
|
finally { release() }
|
|
330
348
|
}
|
|
331
349
|
|
|
332
|
-
export async function gitA(args: string[]): Promise<string> {
|
|
350
|
+
export async function gitA(args: string[], input?: string): Promise<string> {
|
|
333
351
|
const env = { ...process.env }
|
|
334
352
|
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
335
353
|
const context = inheritedContext()
|
|
336
354
|
try {
|
|
337
|
-
const { stdout } = await execGitForCaller(args, env)
|
|
355
|
+
const { stdout } = await execGitForCaller(args, env, undefined, input)
|
|
338
356
|
return stdout
|
|
339
357
|
} catch (e: any) {
|
|
340
358
|
if (context?.signal.aborted || e?.name === 'AbortError') throw e
|
|
@@ -342,10 +360,13 @@ export async function gitA(args: string[]): Promise<string> {
|
|
|
342
360
|
}
|
|
343
361
|
}
|
|
344
362
|
|
|
345
|
-
type
|
|
363
|
+
type TextEventRecord = { hash: string; raw: string }
|
|
364
|
+
type IdentityEventRecord = { hash: string; identity: IdentityRawRecord }
|
|
365
|
+
type EventRecord = TextEventRecord | IdentityEventRecord
|
|
346
366
|
type EventCache = { streams: Map<EventStreamKind, Map<string, EventRecord>>; streamTips: Map<EventStreamKind, string[]> }
|
|
347
|
-
|
|
348
|
-
const
|
|
367
|
+
type EventStreamOutput = string | IdentityRawRecord[]
|
|
368
|
+
const EVENT_CACHE_SCHEMA = 'history-events-v15'
|
|
369
|
+
const EVENT_STREAM_KINDS = ['merge', 'identity-raw'] as const
|
|
349
370
|
type EventStreamKind = typeof EVENT_STREAM_KINDS[number]
|
|
350
371
|
type EventCacheLocation = { path: string; identity: string; objectFormat: GitObjectFormat }
|
|
351
372
|
type EventLedgerSnapshot = {
|
|
@@ -464,15 +485,26 @@ function decodeEventPayload(payload: Buffer, location: EventCacheLocation): Even
|
|
|
464
485
|
continue
|
|
465
486
|
}
|
|
466
487
|
const kind = eventStreamKind(row.k)
|
|
467
|
-
if (!
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
488
|
+
if (!kind || typeof row.h !== 'string' || !isGitObjectIdForFormat(location.objectFormat, row.h)) return null
|
|
489
|
+
const record: EventRecord | null = kind === 'identity-raw'
|
|
490
|
+
? (() => {
|
|
491
|
+
const payload = exactKeys(row, ['a', 'c', 'd', 'h', 'k', 'r', 's'])
|
|
492
|
+
? { a: row.a, c: row.c, d: row.d, r: row.r, s: row.s }
|
|
493
|
+
: null
|
|
494
|
+
const identity = payload ? decodeIdentityRawRecord(payload, row.h, location) : null
|
|
495
|
+
return identity ? { hash: row.h, identity } : null
|
|
496
|
+
})()
|
|
497
|
+
: (!exactKeys(row, ['h', 'k', 'r']) || typeof row.r !== 'string' || !row.r
|
|
498
|
+
? null
|
|
499
|
+
: (() => {
|
|
500
|
+
const rawHash = row.r.split(US, 1)[0].split('\n', 1)[0].trim()
|
|
501
|
+
return rawHash === row.h ? { hash: row.h, raw: row.r } : null
|
|
502
|
+
})())
|
|
503
|
+
if (!record) return null
|
|
472
504
|
let stream = state.streams.get(kind)
|
|
473
505
|
if (!stream) { stream = new Map(); state.streams.set(kind, stream) }
|
|
474
506
|
if (stream.has(row.h)) return null
|
|
475
|
-
stream.set(row.h,
|
|
507
|
+
stream.set(row.h, record)
|
|
476
508
|
}
|
|
477
509
|
return state
|
|
478
510
|
}
|
|
@@ -606,22 +638,35 @@ function replaceEventLedger(path: string, payload: Buffer, additions: string[]):
|
|
|
606
638
|
throw error
|
|
607
639
|
}
|
|
608
640
|
}
|
|
609
|
-
function
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
return
|
|
617
|
-
})
|
|
641
|
+
function sortedEventRecords(records: Iterable<EventRecord>, request: EventStreamRequest): EventRecord[] {
|
|
642
|
+
return [...records].filter((record) => request.reachable.has(record.hash))
|
|
643
|
+
.sort((a, b) => (request.order.get(a.hash) ?? Number.MAX_SAFE_INTEGER) - (request.order.get(b.hash) ?? Number.MAX_SAFE_INTEGER))
|
|
644
|
+
}
|
|
645
|
+
function identityRecords(records: Iterable<EventRecord>, request: EventStreamRequest): IdentityRawRecord[] {
|
|
646
|
+
return sortedEventRecords(records, request).map((record) => {
|
|
647
|
+
if (!('identity' in record)) throw new Error('identity-raw ledger contained a text event')
|
|
648
|
+
return record.identity
|
|
649
|
+
})
|
|
650
|
+
}
|
|
651
|
+
function renderEventStream(state: EventCache, request: EventStreamRequest): EventStreamOutput {
|
|
652
|
+
const records = sortedEventRecords(state.streams.get(request.kind)?.values() ?? [], request)
|
|
653
|
+
if (request.kind === 'identity-raw') return records.map((record) => {
|
|
654
|
+
if (!('identity' in record)) throw new Error('identity-raw ledger contained a text event')
|
|
655
|
+
return record.identity
|
|
656
|
+
})
|
|
657
|
+
return records.map((record) => {
|
|
658
|
+
if (!('raw' in record)) throw new Error(`history event stream '${request.kind}' contained a structured event`)
|
|
659
|
+
return RS + record.raw
|
|
660
|
+
}).join('')
|
|
618
661
|
}
|
|
619
662
|
function appendEventRecord(state: EventCache, kind: EventStreamKind, record: EventRecord, additions: string[]): void {
|
|
620
663
|
let stream = state.streams.get(kind)
|
|
621
664
|
if (!stream) { stream = new Map(); state.streams.set(kind, stream) }
|
|
622
665
|
if (stream.has(record.hash)) return
|
|
623
666
|
stream.set(record.hash, record)
|
|
624
|
-
additions.push(
|
|
667
|
+
additions.push('identity' in record
|
|
668
|
+
? JSON.stringify({ k: kind, h: record.hash, d: record.identity.d, r: record.identity.r, s: record.identity.s, a: record.identity.a, c: record.identity.c.flat() }) + '\n'
|
|
669
|
+
: JSON.stringify({ k: kind, h: record.hash, r: record.raw }) + '\n')
|
|
625
670
|
}
|
|
626
671
|
function appendEventTip(state: EventCache, kind: EventStreamKind, tip: string, additions: string[]): void {
|
|
627
672
|
const tips = state.streamTips.get(kind) ?? []
|
|
@@ -631,6 +676,7 @@ function appendEventTip(state: EventCache, kind: EventStreamKind, tip: string, a
|
|
|
631
676
|
additions.push(JSON.stringify({ k: `tip:${kind}`, tip }) + '\n')
|
|
632
677
|
}
|
|
633
678
|
function parseEventRecords(out: string, kind: EventStreamKind, location: EventCacheLocation): EventRecord[] {
|
|
679
|
+
if (kind === 'identity-raw') return parseIdentityRawEventRecords(out, location)
|
|
634
680
|
const records: EventRecord[] = []
|
|
635
681
|
for (const rec of out.split(RS)) {
|
|
636
682
|
const raw = rec.replace(/^\n/, '')
|
|
@@ -649,17 +695,11 @@ function indexEventRequests(
|
|
|
649
695
|
reachable: Set<string>,
|
|
650
696
|
): Record<EventStreamKind, EventStreamRequest> {
|
|
651
697
|
return {
|
|
652
|
-
|
|
653
|
-
kind: '
|
|
654
|
-
argsFor: (base) => ['-C', root, '-c', 'core.quotePath=false',
|
|
655
|
-
'log', '--full-history', '--date-order', '--no-diff-merges', '-M', '--numstat',
|
|
656
|
-
`--format=${RS}%H${US}%aI${US}%s${US}%b`, ...(base ? [`^${base}`] : []), tip, '--', '.spec'],
|
|
657
|
-
},
|
|
658
|
-
'drift-numstat': {
|
|
659
|
-
kind: 'drift-numstat', order, reachable,
|
|
698
|
+
'identity-raw': {
|
|
699
|
+
kind: 'identity-raw', order, reachable,
|
|
660
700
|
argsFor: (base) => ['-C', root, '-c', 'core.quotePath=false',
|
|
661
|
-
'log', '--full-history', '--date-order', '--no-diff-merges', '-M', '--
|
|
662
|
-
`--format=${RS}%H
|
|
701
|
+
'log', '--root', '--full-history', '--date-order', '--no-diff-merges', '-M', '-l0', '--raw', '-z', '--no-abbrev', '--no-ext-diff', '--no-textconv',
|
|
702
|
+
`--format=${RS}%H%x00%aI%x00%s%x00%b%x00%(trailers:key=Spec-OK,valueonly,separator=%x2C)%x00`,
|
|
663
703
|
...(base ? [`^${base}`] : []), tip],
|
|
664
704
|
},
|
|
665
705
|
merge: {
|
|
@@ -680,15 +720,23 @@ async function deriveEventStreams(
|
|
|
680
720
|
requests: EventStreamRequest[],
|
|
681
721
|
persist = true,
|
|
682
722
|
cache = true,
|
|
683
|
-
): Promise<Map<EventStreamKind,
|
|
723
|
+
): Promise<Map<EventStreamKind, EventStreamOutput>> {
|
|
684
724
|
if (!cache) {
|
|
725
|
+
const location = eventCacheLocation(root)
|
|
685
726
|
const outputs = await Promise.all(requests.map((request) => strictEventGit(request.argsFor(''))))
|
|
686
|
-
|
|
727
|
+
const rendered = new Map<EventStreamKind, EventStreamOutput>()
|
|
728
|
+
for (let index = 0; index < requests.length; index++) {
|
|
729
|
+
const request = requests[index]
|
|
730
|
+
rendered.set(request.kind, request.kind === 'identity-raw'
|
|
731
|
+
? identityRecords(parseIdentityRawEventRecords(outputs[index], location), request)
|
|
732
|
+
: outputs[index])
|
|
733
|
+
}
|
|
734
|
+
return rendered
|
|
687
735
|
}
|
|
688
736
|
if (new Set(requests.map((request) => request.kind)).size !== requests.length)
|
|
689
737
|
throw new Error('one event-ledger transaction cannot request the same stream twice')
|
|
690
738
|
|
|
691
|
-
const run = async (location: EventCacheLocation): Promise<Map<EventStreamKind,
|
|
739
|
+
const run = async (location: EventCacheLocation): Promise<Map<EventStreamKind, EventStreamOutput> | null> => {
|
|
692
740
|
const snapshot = loadEventLedger(location)
|
|
693
741
|
const missing = requests.filter((request) => !(snapshot.state.streamTips.get(request.kind) ?? []).includes(tip))
|
|
694
742
|
const outputs = await Promise.all(missing.map((request) => {
|
|
@@ -726,8 +774,20 @@ async function eventStream(
|
|
|
726
774
|
request: EventStreamRequest,
|
|
727
775
|
persist = true,
|
|
728
776
|
cache = true,
|
|
729
|
-
): Promise<
|
|
730
|
-
|
|
777
|
+
): Promise<EventStreamOutput> {
|
|
778
|
+
const value = (await deriveEventStreams(root, tip, [request], persist, cache)).get(request.kind)
|
|
779
|
+
if (value === undefined) throw new Error(`history event stream '${request.kind}' was not rendered`)
|
|
780
|
+
return value
|
|
781
|
+
}
|
|
782
|
+
async function textEventStream(root: string, tip: string, request: EventStreamRequest, persist = true, cache = true): Promise<string> {
|
|
783
|
+
const value = await eventStream(root, tip, request, persist, cache)
|
|
784
|
+
if (typeof value !== 'string') throw new Error(`history event stream '${request.kind}' rendered structured data`)
|
|
785
|
+
return value
|
|
786
|
+
}
|
|
787
|
+
async function identityRawEventStream(root: string, tip: string, request: EventStreamRequest, persist = true, cache = true): Promise<IdentityRawRecord[]> {
|
|
788
|
+
const value = await eventStream(root, tip, request, persist, cache)
|
|
789
|
+
if (!Array.isArray(value) || value.some((record) => !('a' in record))) throw new Error(`history event stream '${request.kind}' rendered text`)
|
|
790
|
+
return value as IdentityRawRecord[]
|
|
731
791
|
}
|
|
732
792
|
export type GitTryFailure = 'exit' | 'spawn' | 'timeout'
|
|
733
793
|
export async function gitTry(args: string[], options: { indexFile?: string } = {}): Promise<{ ok: boolean; stdout: string; stderr: string; failure?: GitTryFailure }> {
|
|
@@ -847,27 +907,76 @@ export type DiffStat = { additions: number; deletions: number; files: number }
|
|
|
847
907
|
|
|
848
908
|
export type HistoryIndex = {
|
|
849
909
|
versions: Map<string, Version[]> // headPath -> rows newest-first (incl. pure-rename rows)
|
|
850
|
-
|
|
910
|
+
contentVersions: Set<string> // headPath\0hash rows whose immutable blob changed
|
|
911
|
+
versionPaths: Map<string, string> // headPath\0hash -> path at that version commit
|
|
851
912
|
mergeVersions?: Set<string> // path\0hash pairs with an all-parent combined-diff line
|
|
852
913
|
}
|
|
853
914
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
function
|
|
857
|
-
const
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
915
|
+
type IdentityRawRecord = { h: string; d: string; r: string; s: string | null; a: string; c: [string, string, string, string, string][] }
|
|
916
|
+
|
|
917
|
+
function isRawObjectId(value: string, format: GitObjectFormat): boolean {
|
|
918
|
+
const length = format === 'sha256' ? 64 : 40
|
|
919
|
+
return isGitObjectIdForFormat(format, value) || (value.length === length && /^0+$/.test(value))
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function parseIdentityRawEventRecords(out: string, location: EventCacheLocation): EventRecord[] {
|
|
923
|
+
const parsed: IdentityRawRecord[] = []
|
|
924
|
+
let current: IdentityRawRecord | null = null
|
|
925
|
+
const tokens = out.split('\0')
|
|
926
|
+
let index = 0
|
|
927
|
+
const metadata = (field: string): string => {
|
|
928
|
+
const token = tokens[index++]
|
|
929
|
+
if (token === undefined) throw new Error(`raw identity stream ended before commit ${field}`)
|
|
930
|
+
return token
|
|
867
931
|
}
|
|
868
|
-
const
|
|
869
|
-
|
|
870
|
-
|
|
932
|
+
const begin = (token: string): IdentityRawRecord => {
|
|
933
|
+
const header = token.startsWith('\n') ? token.slice(1) : token
|
|
934
|
+
if (!header.startsWith(RS)) throw new Error(`raw identity stream expected a commit header, got '${header}'`)
|
|
935
|
+
const hash = header.slice(RS.length)
|
|
936
|
+
if (!isGitObjectIdForFormat(location.objectFormat, hash))
|
|
937
|
+
throw new Error(`history event stream 'identity-raw' returned malformed object id '${hash || 'empty'}'`)
|
|
938
|
+
if (current) parsed.push(current)
|
|
939
|
+
const date = metadata('date'), reason = metadata('subject'), body = metadata('body'), ack = metadata('trailers')
|
|
940
|
+
const session = body.match(/Session:\s*(\S+)/)
|
|
941
|
+
return { h: hash, d: date, r: reason, s: session ? session[1] : null, a: ack, c: [] }
|
|
942
|
+
}
|
|
943
|
+
while (index < tokens.length) {
|
|
944
|
+
const token = tokens[index++]
|
|
945
|
+
if (!current) {
|
|
946
|
+
if (!token) continue
|
|
947
|
+
current = begin(token)
|
|
948
|
+
continue
|
|
949
|
+
}
|
|
950
|
+
const value = token.startsWith('\n') ? token.slice(1) : token
|
|
951
|
+
if (!value) continue
|
|
952
|
+
if (value.startsWith(RS)) { current = begin(value); continue }
|
|
953
|
+
const raw = value.match(/^:([0-7]{6}) ([0-7]{6}) ([0-9a-f]+) ([0-9a-f]+) ([A-Z])(?:\d+)?$/)
|
|
954
|
+
if (!raw || !isRawObjectId(raw[3], location.objectFormat) || !isRawObjectId(raw[4], location.objectFormat))
|
|
955
|
+
throw new Error(`raw identity event ${current.h} has malformed raw record '${value}'`)
|
|
956
|
+
const from = tokens[index++]
|
|
957
|
+
if (from === undefined) throw new Error(`raw identity event ${current.h} ended before its path`)
|
|
958
|
+
const to = raw[5] === 'R' ? tokens[index++] : from
|
|
959
|
+
if (to === undefined) throw new Error(`raw identity event ${current.h} ended before its rename destination`)
|
|
960
|
+
current.c.push([raw[5], from, to, raw[3], raw[4]])
|
|
961
|
+
}
|
|
962
|
+
if (current) parsed.push(current)
|
|
963
|
+
return parsed.map((identity) => ({ hash: identity.h, identity }))
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function decodeIdentityRawRecord(value: unknown, hash: string, location: EventCacheLocation): IdentityRawRecord | null {
|
|
967
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
968
|
+
const record = value as Record<string, unknown>
|
|
969
|
+
if (!exactKeys(record, ['a', 'c', 'd', 'r', 's']) || typeof record.a !== 'string' || typeof record.d !== 'string'
|
|
970
|
+
|| typeof record.r !== 'string' || (typeof record.s !== 'string' && record.s !== null) || !Array.isArray(record.c) || record.c.length % 5) return null
|
|
971
|
+
const changes: [string, string, string, string, string][] = []
|
|
972
|
+
for (let index = 0; index < record.c.length; index += 5) {
|
|
973
|
+
if (typeof record.c[index] !== 'string' || typeof record.c[index + 1] !== 'string' || typeof record.c[index + 2] !== 'string'
|
|
974
|
+
|| typeof record.c[index + 3] !== 'string' || typeof record.c[index + 4] !== 'string'
|
|
975
|
+
|| !/^[A-Z](?:\d+)?$/.test(record.c[index] as string) || !isRawObjectId(record.c[index + 3] as string, location.objectFormat)
|
|
976
|
+
|| !isRawObjectId(record.c[index + 4] as string, location.objectFormat)) return null
|
|
977
|
+
changes.push([record.c[index], record.c[index + 1], record.c[index + 2], record.c[index + 3], record.c[index + 4]] as [string, string, string, string, string])
|
|
978
|
+
}
|
|
979
|
+
return { h: hash, d: record.d, r: record.r, s: record.s, a: record.a, c: changes }
|
|
871
980
|
}
|
|
872
981
|
|
|
873
982
|
// Both bulk indices are pure functions of a checkout's HEAD, and they are read for SEVERAL roots at
|
|
@@ -885,11 +994,11 @@ const INDEX_ROOT_SLOTS = Math.max(4, Number(process.env.SPEXCODE_INDEX_CACHE_ROO
|
|
|
885
994
|
|
|
886
995
|
function rootKey(root: string): string { return resolve(root) }
|
|
887
996
|
|
|
888
|
-
function
|
|
997
|
+
function indexCacheKey(root: string, head: string): string { return `${eventCacheLocation(root).path}\0${head}` }
|
|
889
998
|
|
|
890
|
-
//
|
|
891
|
-
//
|
|
892
|
-
//
|
|
999
|
+
// A project-namespaced ledger path plus HEAD identifies immutable index contents. Its path is scoped to the
|
|
1000
|
+
// common Git store and interpretation state, so linked worktrees share while independent same-HEAD clones do not.
|
|
1001
|
+
// The checkout root owns which live view is useful; the root bound keeps closed worktrees from leaking.
|
|
893
1002
|
function touchRoot(roots: Map<string, string>, cache: Map<string, Promise<unknown>>, root: string, cacheKey: string): void {
|
|
894
1003
|
const key = rootKey(root)
|
|
895
1004
|
const previous = roots.get(key)
|
|
@@ -953,7 +1062,7 @@ export function historyIndex(root: string, tip = 'HEAD'): Promise<HistoryIndex>
|
|
|
953
1062
|
}
|
|
954
1063
|
const head = headOrEmpty(root)
|
|
955
1064
|
if (!head) return buildIndex(root, 'HEAD', false, true)
|
|
956
|
-
const cacheKey =
|
|
1065
|
+
const cacheKey = indexCacheKey(root, head)
|
|
957
1066
|
touchRoot(indexRoots, indexCache, root, cacheKey)
|
|
958
1067
|
const hit = indexCache.get(cacheKey)
|
|
959
1068
|
if (hit) return hit
|
|
@@ -973,39 +1082,82 @@ function headOrEmpty(root: string): string {
|
|
|
973
1082
|
}
|
|
974
1083
|
}
|
|
975
1084
|
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1085
|
+
// @@@ reachability memoized on the rename side, never the event side - every comparison the projector makes
|
|
1086
|
+
// has a RENAME commit on one end, and a history holds far fewer renames than file events. Keying the memo on
|
|
1087
|
+
// the OTHER end rebuilds a history-wide ancestor set per distinct event commit: 2.3M parent-edge visits and
|
|
1088
|
+
// 1,219 retained bitsets served 9k one-bit questions on this repository. That end builds one closure per
|
|
1089
|
+
// distinct event commit actually compared against a rename — C of them, O(C(H+G)) construction and Θ(CH) bits
|
|
1090
|
+
// — which a linear history whose events all sit on one renamed path drives to Θ(H²). Asking the rename end
|
|
1091
|
+
// instead — its descendants when it is the older commit, its ancestors when it is the newer one — moves ONLY
|
|
1092
|
+
// the closure term onto the rename count K: at most 2K full-size closures, O(K(H+G)) construction over H
|
|
1093
|
+
// reachable commits and G parent edges, O(KH) bits. That 2K ceiling bounds closure buffers, count and bytes,
|
|
1094
|
+
// against C; it does NOT bound runtime or edge visits, since a rename with many unrelated descendants
|
|
1095
|
+
// traverses ground the event-side ancestor walk never touched. The projector's own work is unchanged and is
|
|
1096
|
+
// NOT covered by that term: one scan of the N events, plus a lineage walk whose frontier compares each step's
|
|
1097
|
+
// applicable renames pairwise — Σ d(candidate)² O(1) queries, worst case Θ(NK²) when one path carries K
|
|
1098
|
+
// mutually incomparable renames. So: no linear-in-history promise — at K≈H the closure term is O(H(H+G)),
|
|
1099
|
+
// quadratic only where the DAG is sparse enough that G=O(H), and the untouched frontier term is cubic when
|
|
1100
|
+
// N, K and H grow together. It is also why a
|
|
1101
|
+
// reachability matrix over the rename commits is not worth it: same O(KH) bits, but eagerly.
|
|
1102
|
+
function renameSideReachability(
|
|
1103
|
+
renameCommits: Set<string>,
|
|
1104
|
+
topology: TopologyProjection,
|
|
1105
|
+
): (older: string, newer: string) => boolean {
|
|
1106
|
+
const { order, parents } = topology
|
|
1107
|
+
const size = (order.size + 7) >> 3
|
|
1108
|
+
let childEdges: Map<string, string[]> | null = null
|
|
1109
|
+
const children = (): Map<string, string[]> => {
|
|
1110
|
+
if (childEdges) return childEdges
|
|
1111
|
+
childEdges = new Map()
|
|
1112
|
+
for (const [hash] of order) for (const parent of parents.get(hash) ?? []) {
|
|
1113
|
+
if (!order.has(parent)) continue // shallow boundary: an unwalked parent ends the chain
|
|
1114
|
+
const kids = childEdges.get(parent)
|
|
1115
|
+
if (kids) kids.push(hash)
|
|
1116
|
+
else childEdges.set(parent, [hash])
|
|
1117
|
+
}
|
|
1118
|
+
return childEdges
|
|
1119
|
+
}
|
|
1120
|
+
const closure = (start: string, edges: Map<string, string[]>, memo: Map<string, Uint8Array>): Uint8Array => {
|
|
1121
|
+
const hit = memo.get(start)
|
|
985
1122
|
if (hit) return hit
|
|
986
|
-
const
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
const stack
|
|
991
|
-
|
|
992
|
-
const position = topologyOrd.get(parent)
|
|
1123
|
+
const bits = new Uint8Array(size)
|
|
1124
|
+
const at = order.get(start)!
|
|
1125
|
+
bits[at >> 3] |= 1 << (at & 7)
|
|
1126
|
+
const stack = [start]
|
|
1127
|
+
while (stack.length) for (const next of edges.get(stack.pop()!) ?? []) {
|
|
1128
|
+
const position = order.get(next)
|
|
993
1129
|
if (position === undefined) continue
|
|
994
1130
|
const mask = 1 << (position & 7)
|
|
995
1131
|
if (bits[position >> 3] & mask) continue
|
|
996
1132
|
bits[position >> 3] |= mask
|
|
997
|
-
stack.push(
|
|
1133
|
+
stack.push(next)
|
|
998
1134
|
}
|
|
999
|
-
|
|
1135
|
+
memo.set(start, bits)
|
|
1000
1136
|
return bits
|
|
1001
1137
|
}
|
|
1002
|
-
const
|
|
1003
|
-
|
|
1004
|
-
|
|
1138
|
+
const ancestors = new Map<string, Uint8Array>(), descendants = new Map<string, Uint8Array>()
|
|
1139
|
+
return (older, newer) => {
|
|
1140
|
+
const from = order.get(older), to = order.get(newer)
|
|
1141
|
+
if (from === undefined || to === undefined)
|
|
1005
1142
|
throw new Error(`rename projection cannot place ${older} against ${newer} in the current topology`)
|
|
1006
|
-
|
|
1143
|
+
// Whichever end is the rename owns the closure; the projector always puts one there.
|
|
1144
|
+
return renameCommits.has(older)
|
|
1145
|
+
? (closure(older, children(), descendants)[to >> 3] & (1 << (to & 7))) !== 0
|
|
1146
|
+
: (closure(newer, parents, ancestors)[from >> 3] & (1 << (from & 7))) !== 0
|
|
1007
1147
|
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
type RenameProjectionEvent = { hash: string; to: string }
|
|
1151
|
+
function canonicalPathProjector(
|
|
1152
|
+
renamesByFrom: Map<string, RenameProjectionEvent[]>,
|
|
1153
|
+
topology: TopologyProjection,
|
|
1154
|
+
): (path: string, event: string) => string[] {
|
|
1155
|
+
const renameCommits = new Set<string>()
|
|
1156
|
+
for (const renames of renamesByFrom.values()) for (const rename of renames) renameCommits.add(rename.hash)
|
|
1157
|
+
const precedes = renameSideReachability(renameCommits, topology)
|
|
1008
1158
|
return (path, event) => {
|
|
1159
|
+
// A path no rename ever left keeps its own identity at every event; there is no lineage to walk.
|
|
1160
|
+
if (!renamesByFrom.has(path)) return [path]
|
|
1009
1161
|
const pending = [path], resolved = new Set<string>(), seen = new Set<string>()
|
|
1010
1162
|
while (pending.length) {
|
|
1011
1163
|
const candidate = pending.pop()!
|
|
@@ -1035,8 +1187,7 @@ type SharedIndexInputs = {
|
|
|
1035
1187
|
allPaths: Set<string>
|
|
1036
1188
|
specPaths: Set<string>
|
|
1037
1189
|
topology: TopologyProjection
|
|
1038
|
-
|
|
1039
|
-
driftOut: string
|
|
1190
|
+
identityRecords: IdentityRawRecord[]
|
|
1040
1191
|
mergeIndex: MergeHistoryEvents
|
|
1041
1192
|
}
|
|
1042
1193
|
|
|
@@ -1048,12 +1199,12 @@ type TopologyProjection = {
|
|
|
1048
1199
|
|
|
1049
1200
|
async function buildIndex(root: string, tip: string, transient: boolean, useCache = true, shared?: SharedIndexInputs): Promise<HistoryIndex> {
|
|
1050
1201
|
const versions = new Map<string, Version[]>()
|
|
1051
|
-
const
|
|
1202
|
+
const contentVersions = new Set<string>()
|
|
1203
|
+
const versionPaths = new Map<string, string>()
|
|
1052
1204
|
const mergeVersions = new Set<string>()
|
|
1053
1205
|
const commitVersions = new Map<string, Version>()
|
|
1054
1206
|
const commitOrder = new Map<string, number>()
|
|
1055
1207
|
const rawVersions = new Map<string, Version[]>()
|
|
1056
|
-
const rawStats = new Map<string, Map<string, DiffStat>>()
|
|
1057
1208
|
let currentPaths: Set<string>
|
|
1058
1209
|
let topology: TopologyProjection
|
|
1059
1210
|
if (shared) {
|
|
@@ -1071,36 +1222,23 @@ async function buildIndex(root: string, tip: string, transient: boolean, useCach
|
|
|
1071
1222
|
const topologyOrd = topology.order
|
|
1072
1223
|
const topologyParents = topology.parents
|
|
1073
1224
|
const topologyReachable = topology.reachable
|
|
1074
|
-
const
|
|
1075
|
-
indexEventRequests(root, tip, topologyOrd, topologyReachable)
|
|
1076
|
-
if (!
|
|
1225
|
+
const identityRecords = shared?.identityRecords ?? await identityRawEventStream(root, tip,
|
|
1226
|
+
indexEventRequests(root, tip, topologyOrd, topologyReachable)['identity-raw'], !transient, useCache)
|
|
1227
|
+
if (!identityRecords.length) return { versions, contentVersions, versionPaths, mergeVersions }
|
|
1077
1228
|
let commitPosition = 0
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
const
|
|
1084
|
-
|
|
1085
|
-
const version: Version = { hash, date, reason, session: sm ? sm[1] : null }
|
|
1086
|
-
commitVersions.set(hash, version)
|
|
1087
|
-
if (!commitOrder.has(hash)) commitOrder.set(hash, commitPosition++)
|
|
1088
|
-
for (const line of rest.split('\n')) {
|
|
1089
|
-
const m = line.match(/^(-|\d+)\t(-|\d+)\t(.+)$/)
|
|
1090
|
-
if (!m) continue
|
|
1091
|
-
const add = m[1] === '-' ? 0 : +m[1]
|
|
1092
|
-
const del = m[2] === '-' ? 0 : +m[2]
|
|
1093
|
-
const { from, to } = parseStatPath(m[3])
|
|
1229
|
+
const rawContent = new Set<string>()
|
|
1230
|
+
for (const record of identityRecords) {
|
|
1231
|
+
const version: Version = { hash: record.h, date: record.d, reason: record.r, session: record.s }
|
|
1232
|
+
commitVersions.set(record.h, version)
|
|
1233
|
+
if (!commitOrder.has(record.h)) commitOrder.set(record.h, commitPosition++)
|
|
1234
|
+
for (const [, from, to, oldOid, newOid] of record.c) {
|
|
1235
|
+
if (!from.startsWith('.spec/') && !to.startsWith('.spec/')) continue
|
|
1094
1236
|
if (!rawVersions.has(to)) rawVersions.set(to, [])
|
|
1095
1237
|
rawVersions.get(to)!.push(version)
|
|
1096
|
-
|
|
1097
|
-
if (!hs) { hs = new Map(); rawStats.set(to, hs) }
|
|
1098
|
-
const s = hs.get(hash) ?? { additions: 0, deletions: 0, files: 0 }
|
|
1099
|
-
s.additions += add; s.deletions += del; s.files += 1
|
|
1100
|
-
hs.set(hash, s)
|
|
1238
|
+
if (oldOid !== newOid) rawContent.add(`${to}\0${record.h}`)
|
|
1101
1239
|
if (from !== to) {
|
|
1102
1240
|
const renames = renamesByFrom.get(from) ?? []
|
|
1103
|
-
renames.push({ hash, to })
|
|
1241
|
+
renames.push({ hash: record.h, to })
|
|
1104
1242
|
renamesByFrom.set(from, renames)
|
|
1105
1243
|
}
|
|
1106
1244
|
}
|
|
@@ -1167,56 +1305,41 @@ async function buildIndex(root: string, tip: string, transient: boolean, useCach
|
|
|
1167
1305
|
renames.push({ hash: rename.hash, to: rename.to })
|
|
1168
1306
|
renamesByFrom.set(rename.from, renames)
|
|
1169
1307
|
}
|
|
1170
|
-
const canonical = canonicalPathProjector(renamesByFrom,
|
|
1308
|
+
const canonical = canonicalPathProjector(renamesByFrom, topology)
|
|
1171
1309
|
for (const [path, pathRows] of rawVersions) {
|
|
1172
1310
|
for (const row of pathRows) for (const head of canonical(path, row.hash).filter((candidate) => currentPaths.has(candidate))) {
|
|
1173
1311
|
const rows = versions.get(head) ?? []
|
|
1174
1312
|
if (!rows.some((existing) => existing.hash === row.hash)) rows.push(row)
|
|
1175
1313
|
versions.set(head, rows)
|
|
1176
|
-
const
|
|
1177
|
-
|
|
1178
|
-
if (
|
|
1179
|
-
const existing = hs.get(row.hash)
|
|
1180
|
-
hs.set(row.hash, existing ? {
|
|
1181
|
-
additions: existing.additions + value.additions,
|
|
1182
|
-
deletions: existing.deletions + value.deletions,
|
|
1183
|
-
files: existing.files + value.files,
|
|
1184
|
-
} : value)
|
|
1185
|
-
}
|
|
1186
|
-
stats.set(head, hs)
|
|
1314
|
+
const key = `${head}\0${row.hash}`
|
|
1315
|
+
if (rawContent.has(`${path}\0${row.hash}`)) contentVersions.add(key)
|
|
1316
|
+
if (!versionPaths.has(key)) versionPaths.set(key, path)
|
|
1187
1317
|
}
|
|
1188
1318
|
}
|
|
1189
1319
|
for (const [path, mergeEvents] of mergeIndex.resolutions) {
|
|
1190
1320
|
if (!isSpecMd(path)) continue
|
|
1191
1321
|
for (const { hash } of mergeEvents) for (const head of canonical(path, hash)) {
|
|
1192
1322
|
const rows = versions.get(head) ?? []
|
|
1193
|
-
const hs = stats.get(head) ?? new Map<string, DiffStat>()
|
|
1194
1323
|
const version = commitVersions.get(hash)
|
|
1195
1324
|
if (!version || rows.some((row) => row.hash === hash)) continue
|
|
1196
1325
|
rows.push(version)
|
|
1197
|
-
|
|
1198
|
-
mergeVersions.add(
|
|
1326
|
+
const key = `${head}\0${hash}`
|
|
1327
|
+
mergeVersions.add(key)
|
|
1328
|
+
if (!versionPaths.has(key)) versionPaths.set(key, path)
|
|
1199
1329
|
versions.set(head, rows)
|
|
1200
|
-
stats.set(head, hs)
|
|
1201
1330
|
}
|
|
1202
1331
|
}
|
|
1203
1332
|
for (const rows of versions.values()) {
|
|
1204
1333
|
rows.sort((a, b) => (commitOrder.get(a.hash) ?? Number.MAX_SAFE_INTEGER) - (commitOrder.get(b.hash) ?? Number.MAX_SAFE_INTEGER))
|
|
1205
1334
|
}
|
|
1206
|
-
return { versions,
|
|
1335
|
+
return { versions, contentVersions, versionPaths, mergeVersions }
|
|
1207
1336
|
}
|
|
1208
1337
|
|
|
1209
|
-
//
|
|
1338
|
+
// Pure lookups over a prebuilt index. Blob identity decides whether a one-parent row is a content version;
|
|
1339
|
+
// numstat remains display data and must not let attributes erase a version window.
|
|
1210
1340
|
export function rowsFor(idx: HistoryIndex, relPath: string): Version[] {
|
|
1211
1341
|
const rows = idx.versions.get(relPath) ?? []
|
|
1212
|
-
|
|
1213
|
-
return rows.filter((v) => {
|
|
1214
|
-
const s = st?.get(v.hash)
|
|
1215
|
-
return s != null && (s.additions + s.deletions > 0 || idx.mergeVersions?.has(`${relPath}\0${v.hash}`))
|
|
1216
|
-
})
|
|
1217
|
-
}
|
|
1218
|
-
export function statsFor(idx: HistoryIndex, relPath: string): Map<string, DiffStat> {
|
|
1219
|
-
return idx.stats.get(relPath) ?? new Map()
|
|
1342
|
+
return rows.filter((v) => idx.contentVersions.has(`${relPath}\0${v.hash}`) || idx.mergeVersions?.has(`${relPath}\0${v.hash}`))
|
|
1220
1343
|
}
|
|
1221
1344
|
|
|
1222
1345
|
// per-commit numstat summed over a SET of paths in one `git log` walk. No `--follow` (it takes a single
|
|
@@ -1240,6 +1363,31 @@ export async function pathsStats(root: string, paths: string[]): Promise<Map<str
|
|
|
1240
1363
|
return m
|
|
1241
1364
|
}
|
|
1242
1365
|
|
|
1366
|
+
// History display stats are intentionally read only for the selected node: their text interpretation may
|
|
1367
|
+
// depend on working-tree attributes, so they cannot live in the shared immutable event ledger. One diff-tree
|
|
1368
|
+
// batch preserves every selected commit's historical path without turning a history page into N processes.
|
|
1369
|
+
export async function historyStats(root: string, idx: HistoryIndex, relPath: string): Promise<Map<string, DiffStat>> {
|
|
1370
|
+
const rows = rowsFor(idx, relPath)
|
|
1371
|
+
if (!rows.length) return new Map()
|
|
1372
|
+
const paths = new Map(rows.map((row) => [row.hash, idx.versionPaths.get(`${relPath}\0${row.hash}`) ?? relPath]))
|
|
1373
|
+
const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'diff-tree', '--stdin', '--root', '-r', '--numstat', '-M', '-l0', `--format=${RS}%H`], `${rows.map((row) => row.hash).join('\n')}\n`)
|
|
1374
|
+
const stats = new Map<string, DiffStat>()
|
|
1375
|
+
for (const record of out.split(RS)) {
|
|
1376
|
+
const lines = record.replace(/^\n/, '').split('\n')
|
|
1377
|
+
const hash = lines.shift()?.trim() ?? ''
|
|
1378
|
+
const path = paths.get(hash)
|
|
1379
|
+
if (!path) continue
|
|
1380
|
+
const stat = { additions: 0, deletions: 0, files: 0 }
|
|
1381
|
+
for (const line of lines) {
|
|
1382
|
+
const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/)
|
|
1383
|
+
if (!match || parseStatPath(match[3]).to !== path) continue
|
|
1384
|
+
stat.files++; stat.additions += match[1] === '-' ? 0 : Number(match[1]); stat.deletions += match[2] === '-' ? 0 : Number(match[2])
|
|
1385
|
+
}
|
|
1386
|
+
stats.set(hash, stat)
|
|
1387
|
+
}
|
|
1388
|
+
return stats
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1243
1391
|
// the patch a spec.md got in one commit (vs parent); resolve its path AT that commit (reparents move it)
|
|
1244
1392
|
// via the stable leaf dir `…/<id>/spec.md`, then `git show` that path. `-M` keeps a rename+edit's body. '' on error.
|
|
1245
1393
|
export async function fileDiffAt(root: string, relPath: string, hash: string): Promise<string> {
|
|
@@ -1278,6 +1426,27 @@ export type DriftPathEvent = {
|
|
|
1278
1426
|
export type DiffLineRange = [number, number]
|
|
1279
1427
|
export type CombinedDiffOwnedChanges = { after: DiffLineRange[]; before: DiffLineRange[][]; parentPaths: string[] }
|
|
1280
1428
|
|
|
1429
|
+
function decodeGitCPath(value: string): string {
|
|
1430
|
+
if (!value.startsWith('"')) return value
|
|
1431
|
+
if (value.length < 2 || !value.endsWith('"')) throw new Error(`malformed Git C-quoted path '${value}'`)
|
|
1432
|
+
const bytes: number[] = []
|
|
1433
|
+
const plain = (part: string) => bytes.push(...Buffer.from(part, 'utf8'))
|
|
1434
|
+
for (let index = 1; index < value.length - 1;) {
|
|
1435
|
+
const point = value.codePointAt(index)!
|
|
1436
|
+
const char = String.fromCodePoint(point)
|
|
1437
|
+
index += char.length
|
|
1438
|
+
if (char !== '\\') { plain(char); continue }
|
|
1439
|
+
const escaped = value[index++]
|
|
1440
|
+
if (escaped === undefined) throw new Error(`malformed Git C-quoted path '${value}'`)
|
|
1441
|
+
const simple: Record<string, number> = { a: 7, b: 8, f: 12, n: 10, r: 13, t: 9, v: 11, '"': 34, '\\': 92 }
|
|
1442
|
+
if (escaped in simple) { bytes.push(simple[escaped]); continue }
|
|
1443
|
+
const octal = `${escaped}${value.slice(index, index + 2)}`
|
|
1444
|
+
if (!/^[0-7]{3}$/.test(octal)) throw new Error(`malformed Git C-quoted path '${value}'`)
|
|
1445
|
+
bytes.push(Number.parseInt(octal, 8)); index += 2
|
|
1446
|
+
}
|
|
1447
|
+
return Buffer.from(bytes).toString('utf8')
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1281
1450
|
// Dense combined diff prefixes have one column per parent. Ownership is a LINE fact, not a hunk fact:
|
|
1282
1451
|
// all `+` means the result authored a line absent from every parent; all `-` means it deleted a line present
|
|
1283
1452
|
// in every parent. Mixed columns inherit from at least one parent. Track every cursor through all displayed
|
|
@@ -1293,14 +1462,14 @@ export function combinedDiffOwnedChanges(patch: string): Map<string, CombinedDif
|
|
|
1293
1462
|
|
|
1294
1463
|
for (const line of patch.split('\n')) {
|
|
1295
1464
|
if (line.startsWith('diff --cc ')) {
|
|
1296
|
-
path = line.slice('diff --cc '.length)
|
|
1465
|
+
path = decodeGitCPath(line.slice('diff --cc '.length))
|
|
1297
1466
|
parents = 0
|
|
1298
1467
|
parentPaths = []
|
|
1299
1468
|
inHunk = false
|
|
1300
1469
|
continue
|
|
1301
1470
|
}
|
|
1302
1471
|
if (!inHunk && path !== null && line.startsWith('--- ')) {
|
|
1303
|
-
const raw = line.slice(4)
|
|
1472
|
+
const raw = decodeGitCPath(line.slice(4))
|
|
1304
1473
|
parentPaths.push(raw === '/dev/null' ? path : raw.replace(/^a\//, ''))
|
|
1305
1474
|
continue
|
|
1306
1475
|
}
|
|
@@ -1361,7 +1530,7 @@ function parseMergeHistoryEvents(out: string): MergeHistoryEvents {
|
|
|
1361
1530
|
const parentCount = raw[1].length
|
|
1362
1531
|
const fields = line.split('\t')
|
|
1363
1532
|
const status = fields[0].trim().split(/\s+/).at(-1) ?? ''
|
|
1364
|
-
const paths = fields.slice(1)
|
|
1533
|
+
const paths = fields.slice(1).map(decodeGitCPath)
|
|
1365
1534
|
if (status.length !== parentCount || paths.length !== parentCount + 1)
|
|
1366
1535
|
throw new Error(`combined raw diff for ${hash} exposed ${status.length} statuses and ${paths.length} paths for ${parentCount} parents`)
|
|
1367
1536
|
const resultPath = paths[parentCount]
|
|
@@ -1389,7 +1558,7 @@ async function mergeHistoryEvents(
|
|
|
1389
1558
|
): Promise<MergeHistoryEvents> {
|
|
1390
1559
|
if (!useCache) return parseMergeHistoryEvents(await strictEventGit(['-C', root, '-c', 'core.quotePath=false',
|
|
1391
1560
|
'log', '--merges', '--raw', '--patch', '--cc', '--combined-all-paths', '--unified=0', '--no-color', '--no-ext-diff', '-M', `--format=${RS}%H`, tip]))
|
|
1392
|
-
return parseMergeHistoryEvents(await
|
|
1561
|
+
return parseMergeHistoryEvents(await textEventStream(root, tip,
|
|
1393
1562
|
indexEventRequests(root, tip, order, reachable).merge, !transient, useCache))
|
|
1394
1563
|
}
|
|
1395
1564
|
|
|
@@ -1398,7 +1567,7 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
|
|
|
1398
1567
|
const fileEvents = new Map<string, DriftPathEvent[]>()
|
|
1399
1568
|
const lineageEvents = new Map<string, DriftPathEvent[]>()
|
|
1400
1569
|
const acks = new Map<string, Set<string>>(), selfAcks = new Map<string, Set<string>>(), specNodes = new Map<string, Set<string>>()
|
|
1401
|
-
const ackCandidates = new Map<string, Set<string>>(),
|
|
1570
|
+
const ackCandidates = new Map<string, Set<string>>(), ackCheckpoints = new Map<string, boolean>()
|
|
1402
1571
|
const idx: DriftIndex = { tip, ord, parents, fileEvents, lineageEvents, lineageKeys: (path) => [path], resolutionEvents: new Map(), acks, selfAcks, specNodes, anc: new Map() }
|
|
1403
1572
|
let currentPaths: Set<string>
|
|
1404
1573
|
let topology: TopologyProjection
|
|
@@ -1416,32 +1585,25 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
|
|
|
1416
1585
|
const topologyOrder = topology.order
|
|
1417
1586
|
const topologyParents = topology.parents
|
|
1418
1587
|
const topologyReachable = topology.reachable
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1588
|
+
for (const [hash, position] of topologyOrder) {
|
|
1589
|
+
ord.set(hash, position)
|
|
1590
|
+
parents.set(hash, topologyParents.get(hash) ?? [])
|
|
1591
|
+
}
|
|
1592
|
+
// Raw identity records path pairs and immutable object ids once; projection owns forks and reuse.
|
|
1593
|
+
const records = shared?.identityRecords ?? await identityRawEventStream(root, tip,
|
|
1594
|
+
indexEventRequests(root, tip, topologyOrder, topologyReachable)['identity-raw'], !transient, useCache)
|
|
1595
|
+
if (!records.length) return idx
|
|
1424
1596
|
const rawFileEvents = new Map<string, DriftPathEvent[]>()
|
|
1425
1597
|
const renamesByFrom = new Map<string, RenameProjectionEvent[]>()
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
const r = rec.replace(/^\n/, '')
|
|
1429
|
-
if (!r) continue
|
|
1430
|
-
const lines = r.split('\n')
|
|
1431
|
-
const [hash, parentStr = '', tree = '', ackStr = ''] = lines[0].split(US)
|
|
1432
|
-
if (!hash) continue
|
|
1433
|
-
trees.set(hash, tree)
|
|
1434
|
-
if (!ord.has(hash)) {
|
|
1435
|
-
ord.set(hash, i++)
|
|
1436
|
-
parents.set(hash, parentStr.split(' ').filter(Boolean))
|
|
1437
|
-
}
|
|
1598
|
+
for (const record of records) {
|
|
1599
|
+
const { h: hash, a: ackStr, c: changes } = record
|
|
1438
1600
|
const ackSet = new Set(ackStr.split(',').map((s) => s.trim()).filter(Boolean))
|
|
1439
|
-
if (ackSet.size)
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1601
|
+
if (ackSet.size) {
|
|
1602
|
+
ackCandidates.set(hash, ackSet)
|
|
1603
|
+
ackCheckpoints.set(hash, changes.length === 0)
|
|
1604
|
+
}
|
|
1605
|
+
const merge = (parents.get(hash) ?? []).length > 1
|
|
1606
|
+
for (const [, from, to] of changes) {
|
|
1445
1607
|
if (!merge) {
|
|
1446
1608
|
const events = rawFileEvents.get(to) ?? []
|
|
1447
1609
|
const event: DriftPathEvent = {
|
|
@@ -1466,10 +1628,11 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
|
|
|
1466
1628
|
renames.push({ hash: rename.hash, to: rename.to })
|
|
1467
1629
|
renamesByFrom.set(rename.from, renames)
|
|
1468
1630
|
}
|
|
1469
|
-
const canonical = canonicalPathProjector(renamesByFrom,
|
|
1631
|
+
const canonical = canonicalPathProjector(renamesByFrom, topology)
|
|
1470
1632
|
idx.lineageKeys = canonical
|
|
1471
|
-
|
|
1472
|
-
|
|
1633
|
+
// One projection per event serves both the lineage index and the current-path index; asking the
|
|
1634
|
+
// projector the same question twice per event only pays for it twice.
|
|
1635
|
+
const addEvent = (keys: string[], event: DriftPathEvent, target: Map<string, DriftPathEvent[]>) => {
|
|
1473
1636
|
for (const key of keys) {
|
|
1474
1637
|
const events = target.get(key) ?? []
|
|
1475
1638
|
if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath)) events.push(event)
|
|
@@ -1477,8 +1640,9 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
|
|
|
1477
1640
|
}
|
|
1478
1641
|
}
|
|
1479
1642
|
for (const [path, rawEvents] of rawFileEvents) for (const event of rawEvents) {
|
|
1480
|
-
|
|
1481
|
-
|
|
1643
|
+
const keys = canonical(path, event.commit)
|
|
1644
|
+
addEvent(keys, event, lineageEvents)
|
|
1645
|
+
for (const head of keys.filter((candidate) => currentPaths.has(candidate))) {
|
|
1482
1646
|
const events = fileEvents.get(head) ?? []
|
|
1483
1647
|
if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath)) events.push(event)
|
|
1484
1648
|
fileEvents.set(head, events)
|
|
@@ -1492,7 +1656,8 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
|
|
|
1492
1656
|
for (const [hash, nodes] of ackCandidates) {
|
|
1493
1657
|
const parentList = parents.get(hash) ?? []
|
|
1494
1658
|
const firstParent = parentList[0]
|
|
1495
|
-
|
|
1659
|
+
// A non-merge commit with no raw identity entries has the same tree as its sole parent.
|
|
1660
|
+
const checkpoint = parentList.length === 1 && !!firstParent && ackCheckpoints.get(hash) === true
|
|
1496
1661
|
;(checkpoint ? acks : selfAcks).set(hash, nodes)
|
|
1497
1662
|
}
|
|
1498
1663
|
for (const [path, mergeEvents] of mergeIndex.resolutions) for (const mergeEvent of mergeEvents) {
|
|
@@ -1504,8 +1669,9 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
|
|
|
1504
1669
|
historicalPath: path,
|
|
1505
1670
|
parents: mergeParents.map((commit, index) => ({ commit, historicalPath: mergeEvent.parentPaths[index] })),
|
|
1506
1671
|
}
|
|
1507
|
-
|
|
1508
|
-
|
|
1672
|
+
const keys = canonical(path, mergeEvent.hash)
|
|
1673
|
+
addEvent(keys, event, lineageEvents)
|
|
1674
|
+
for (const head of keys.filter((candidate) => currentPaths.has(candidate))) {
|
|
1509
1675
|
const events = idx.resolutionEvents!.get(head) ?? []
|
|
1510
1676
|
if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath)) events.push(event)
|
|
1511
1677
|
idx.resolutionEvents!.set(head, events)
|
|
@@ -1533,7 +1699,7 @@ export function driftIndex(root: string, tip = 'HEAD'): Promise<DriftIndex> {
|
|
|
1533
1699
|
}
|
|
1534
1700
|
const head = headOrEmpty(root) // filesystem HEAD, no subprocess — see historyIndex
|
|
1535
1701
|
if (!head) return buildDriftIndex(root, 'HEAD', false, true)
|
|
1536
|
-
const cacheKey =
|
|
1702
|
+
const cacheKey = indexCacheKey(root, head)
|
|
1537
1703
|
touchRoot(driftRoots, driftIdxCache, root, cacheKey)
|
|
1538
1704
|
const hit = driftIdxCache.get(cacheKey)
|
|
1539
1705
|
if (hit) return hit
|
|
@@ -1556,6 +1722,14 @@ function topologyProjection(out: string): TopologyProjection {
|
|
|
1556
1722
|
return { order, parents, reachable }
|
|
1557
1723
|
}
|
|
1558
1724
|
|
|
1725
|
+
function textStream(value: EventStreamOutput | undefined, kind: EventStreamKind): string {
|
|
1726
|
+
if (typeof value !== 'string') throw new Error(`history event stream '${kind}' did not render text`)
|
|
1727
|
+
return value
|
|
1728
|
+
}
|
|
1729
|
+
function identityRawStream(value: EventStreamOutput | undefined, kind: EventStreamKind): IdentityRawRecord[] {
|
|
1730
|
+
if (!Array.isArray(value) || value.some((record) => !('a' in record))) throw new Error(`history event stream '${kind}' did not render identity records`)
|
|
1731
|
+
return value as IdentityRawRecord[]
|
|
1732
|
+
}
|
|
1559
1733
|
async function buildIndexPair(root: string, tip: string, transient: boolean, useCache = true): Promise<[HistoryIndex, DriftIndex]> {
|
|
1560
1734
|
const [allPathsOut, topologyOut] = await Promise.all([
|
|
1561
1735
|
strictEventGit(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--name-only', tip]),
|
|
@@ -1570,9 +1744,8 @@ async function buildIndexPair(root: string, tip: string, transient: boolean, use
|
|
|
1570
1744
|
allPaths,
|
|
1571
1745
|
specPaths,
|
|
1572
1746
|
topology,
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
mergeIndex: parseMergeHistoryEvents(streams.get('merge') ?? ''),
|
|
1747
|
+
identityRecords: identityRawStream(streams.get('identity-raw'), 'identity-raw'),
|
|
1748
|
+
mergeIndex: parseMergeHistoryEvents(textStream(streams.get('merge'), 'merge')),
|
|
1576
1749
|
}
|
|
1577
1750
|
streams.clear()
|
|
1578
1751
|
return Promise.all([
|
|
@@ -1605,7 +1778,7 @@ export function sourceIndexes(root: string, tip = 'HEAD'): Promise<[HistoryIndex
|
|
|
1605
1778
|
}
|
|
1606
1779
|
const head = headOrEmpty(root)
|
|
1607
1780
|
if (!head) return buildIndexPair(root, 'HEAD', false, true)
|
|
1608
|
-
const cacheKey =
|
|
1781
|
+
const cacheKey = indexCacheKey(root, head)
|
|
1609
1782
|
touchRoot(indexRoots, indexCache, root, cacheKey)
|
|
1610
1783
|
touchRoot(driftRoots, driftIdxCache, root, cacheKey)
|
|
1611
1784
|
const historyHit = indexCache.get(cacheKey), driftHit = driftIdxCache.get(cacheKey)
|
|
@@ -1775,6 +1948,15 @@ function parseNameStatus(out: string): { code: string; from: string; to: string
|
|
|
1775
1948
|
|
|
1776
1949
|
export type ReviewDiffFile = { path: string; oldPath?: string; status: string; additions: number; deletions: number }
|
|
1777
1950
|
const DIFF_STATUS: Record<string, string> = { A: 'added', M: 'modified', D: 'deleted', R: 'renamed', C: 'copied', T: 'type-changed' }
|
|
1951
|
+
function parseStatPath(token: string): { from: string; to: string } {
|
|
1952
|
+
const b = token.indexOf('{'), arrow = token.indexOf(' => ', b), close = token.indexOf('}', arrow)
|
|
1953
|
+
if (b >= 0 && arrow > b && close > arrow) {
|
|
1954
|
+
const pre = token.slice(0, b), post = token.slice(close + 1)
|
|
1955
|
+
return { from: `${pre}${token.slice(b + 1, arrow)}${post}`.replace(/\/\//g, '/'), to: `${pre}${token.slice(arrow + 4, close)}${post}`.replace(/\/\//g, '/') }
|
|
1956
|
+
}
|
|
1957
|
+
const arrowAt = token.indexOf(' => ')
|
|
1958
|
+
return arrowAt >= 0 ? { from: token.slice(0, arrowAt), to: token.slice(arrowAt + 4) } : { from: token, to: token }
|
|
1959
|
+
}
|
|
1778
1960
|
export async function mergeBaseDiff(wtPath: string, mainRef = 'main'): Promise<ReviewDiffFile[]> {
|
|
1779
1961
|
const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
|
|
1780
1962
|
const base = (await run(['merge-base', mainRef, 'HEAD'])).trim()
|