spexcode 0.5.5 → 0.5.7
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/cli.ts +18 -1
- package/spec-cli/src/client.ts +21 -0
- package/spec-cli/src/git.ts +460 -238
- package/spec-cli/src/harness.ts +116 -24
- package/spec-cli/src/help.ts +3 -1
- package/spec-cli/src/index.ts +48 -9
- package/spec-cli/src/layout.ts +2 -0
- package/spec-cli/src/mentions.ts +10 -5
- package/spec-cli/src/process-identity.ts +20 -0
- package/spec-cli/src/reviewSnapshot.ts +4 -0
- package/spec-cli/src/reviews.ts +10 -5
- 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-C5vbTw8Q.js → App-u2P7KdSg.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-B8wp5_61.js +27 -0
- package/spec-dashboard/dist/assets/{EvalsPage-BS7ITcNo.js → EvalsPage-Bq1Tkb8y.js} +2 -2
- package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +1 -0
- package/spec-dashboard/dist/assets/{MobileApp-DVLnk9hz.js → MobileApp-B1GxRZXK.js} +2 -2
- package/spec-dashboard/dist/assets/{Modal-6mHq6fbZ.js → Modal-bAkq9IIT.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-CAY4S4g4.js → PageScroll-px_rUZVJ.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-UQyzsTWN.js → ProjectsPage-8uGqYM12.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-CswwbewF.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +1 -0
- package/spec-dashboard/dist/assets/{Settings-igR17pns.js → Settings-bpAbfnmS.js} +1 -1
- package/spec-dashboard/dist/assets/{Thread-B-ZUarN1.js → Thread-BpL3N3kw.js} +11 -11
- package/spec-dashboard/dist/assets/{TimelineChat-sc49Qj5d.js → TimelineChat-Ckmb1Ez2.js} +1 -1
- package/spec-dashboard/dist/assets/{data-B1ot4PF0.js → data-CQFbQEMH.js} +1 -1
- package/spec-dashboard/dist/assets/index-CixSnz1H.css +1 -0
- package/spec-dashboard/dist/assets/index-Di1ch5dd.js +41 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +0 -27
- package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +0 -39
- package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +0 -1
- package/spec-dashboard/dist/assets/index-BqBNCa1V.js +0 -41
- 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 = {
|
|
@@ -360,9 +381,36 @@ type EventStreamRequest = {
|
|
|
360
381
|
}
|
|
361
382
|
|
|
362
383
|
type EventPathMemo = EventCacheLocation & {
|
|
363
|
-
common: string; shallowPath: string; grafts: string; shallow: string
|
|
384
|
+
common: string; shallowPath: string; grafts: string; shallow: string
|
|
385
|
+
replacementStorage: string; replacements: string
|
|
364
386
|
}
|
|
365
387
|
const eventPathMemo = new Map<string, EventPathMemo>()
|
|
388
|
+
function replacementStorageIdentity(common: string): string {
|
|
389
|
+
const hash = createHash('sha256')
|
|
390
|
+
const addTree = (root: string, rel: string) => {
|
|
391
|
+
if (!existsSync(root)) return
|
|
392
|
+
const stack = [{ dir: root, rel }]
|
|
393
|
+
while (stack.length) {
|
|
394
|
+
const current = stack.pop()!
|
|
395
|
+
const entries = readdirSync(current.dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))
|
|
396
|
+
for (const entry of entries) {
|
|
397
|
+
const path = join(current.dir, entry.name)
|
|
398
|
+
const name = `${current.rel}/${entry.name}`
|
|
399
|
+
if (entry.isDirectory()) stack.push({ dir: path, rel: name })
|
|
400
|
+
else hash.update(`\0${name}\0`).update(readFileSync(path))
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
addTree(join(common, 'refs', 'replace'), 'refs/replace')
|
|
405
|
+
for (const name of ['packed-refs']) {
|
|
406
|
+
const path = join(common, name)
|
|
407
|
+
if (existsSync(path)) hash.update(`\0${name}\0`).update(readFileSync(path))
|
|
408
|
+
}
|
|
409
|
+
// Reftable is opaque here by design: its bytes are only an invalidation signal. Git remains the one
|
|
410
|
+
// parser and supplies the canonical refs/replace targets when those bytes change.
|
|
411
|
+
addTree(join(common, 'reftable'), 'reftable')
|
|
412
|
+
return hash.digest('hex')
|
|
413
|
+
}
|
|
366
414
|
function eventCacheLocation(root: string): EventCacheLocation {
|
|
367
415
|
const rootId = rootKey(root), old = eventPathMemo.get(rootId)
|
|
368
416
|
const common = old?.common ?? git(['-C', root, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
|
|
@@ -370,7 +418,10 @@ function eventCacheLocation(root: string): EventCacheLocation {
|
|
|
370
418
|
const shallow = existsSync(shallowPath) ? readFileSync(shallowPath, 'utf8') : 'unshallow'
|
|
371
419
|
const graftsPath = join(common, 'info', 'grafts')
|
|
372
420
|
const grafts = existsSync(graftsPath) ? readFileSync(graftsPath, 'utf8') : ''
|
|
373
|
-
const
|
|
421
|
+
const replacementStorage = replacementStorageIdentity(common)
|
|
422
|
+
const replacements = old?.replacementStorage === replacementStorage
|
|
423
|
+
? old.replacements
|
|
424
|
+
: git(['-C', root, 'for-each-ref', 'refs/replace', '--format=%(refname) %(objectname)'])
|
|
374
425
|
const objectFormat = gitObjectFormat(root)
|
|
375
426
|
if (old && old.shallow === shallow && old.grafts === grafts && old.replacements === replacements && old.objectFormat === objectFormat)
|
|
376
427
|
return { path: old.path, identity: old.identity, objectFormat }
|
|
@@ -382,7 +433,7 @@ function eventCacheLocation(root: string): EventCacheLocation {
|
|
|
382
433
|
const gitDir = gitDirOf(root)
|
|
383
434
|
const storeIdentity = gitDir === root && common === root ? join(common, '.git') : common
|
|
384
435
|
const path = join(projectRuntimeRoot(storeIdentity), `${EVENT_CACHE_SCHEMA}-${identity}.ndjson`)
|
|
385
|
-
eventPathMemo.set(rootId, { common, shallowPath, grafts, shallow, replacements, path, identity, objectFormat })
|
|
436
|
+
eventPathMemo.set(rootId, { common, shallowPath, grafts, shallow, replacementStorage, replacements, path, identity, objectFormat })
|
|
386
437
|
return { path, identity, objectFormat }
|
|
387
438
|
}
|
|
388
439
|
function emptyEventCache(): EventCache {
|
|
@@ -434,15 +485,26 @@ function decodeEventPayload(payload: Buffer, location: EventCacheLocation): Even
|
|
|
434
485
|
continue
|
|
435
486
|
}
|
|
436
487
|
const kind = eventStreamKind(row.k)
|
|
437
|
-
if (!
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
|
442
504
|
let stream = state.streams.get(kind)
|
|
443
505
|
if (!stream) { stream = new Map(); state.streams.set(kind, stream) }
|
|
444
506
|
if (stream.has(row.h)) return null
|
|
445
|
-
stream.set(row.h,
|
|
507
|
+
stream.set(row.h, record)
|
|
446
508
|
}
|
|
447
509
|
return state
|
|
448
510
|
}
|
|
@@ -576,22 +638,35 @@ function replaceEventLedger(path: string, payload: Buffer, additions: string[]):
|
|
|
576
638
|
throw error
|
|
577
639
|
}
|
|
578
640
|
}
|
|
579
|
-
function
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
return
|
|
587
|
-
})
|
|
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('')
|
|
588
661
|
}
|
|
589
662
|
function appendEventRecord(state: EventCache, kind: EventStreamKind, record: EventRecord, additions: string[]): void {
|
|
590
663
|
let stream = state.streams.get(kind)
|
|
591
664
|
if (!stream) { stream = new Map(); state.streams.set(kind, stream) }
|
|
592
665
|
if (stream.has(record.hash)) return
|
|
593
666
|
stream.set(record.hash, record)
|
|
594
|
-
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')
|
|
595
670
|
}
|
|
596
671
|
function appendEventTip(state: EventCache, kind: EventStreamKind, tip: string, additions: string[]): void {
|
|
597
672
|
const tips = state.streamTips.get(kind) ?? []
|
|
@@ -601,6 +676,7 @@ function appendEventTip(state: EventCache, kind: EventStreamKind, tip: string, a
|
|
|
601
676
|
additions.push(JSON.stringify({ k: `tip:${kind}`, tip }) + '\n')
|
|
602
677
|
}
|
|
603
678
|
function parseEventRecords(out: string, kind: EventStreamKind, location: EventCacheLocation): EventRecord[] {
|
|
679
|
+
if (kind === 'identity-raw') return parseIdentityRawEventRecords(out, location)
|
|
604
680
|
const records: EventRecord[] = []
|
|
605
681
|
for (const rec of out.split(RS)) {
|
|
606
682
|
const raw = rec.replace(/^\n/, '')
|
|
@@ -619,17 +695,11 @@ function indexEventRequests(
|
|
|
619
695
|
reachable: Set<string>,
|
|
620
696
|
): Record<EventStreamKind, EventStreamRequest> {
|
|
621
697
|
return {
|
|
622
|
-
|
|
623
|
-
kind: '
|
|
624
|
-
argsFor: (base) => ['-C', root, '-c', 'core.quotePath=false',
|
|
625
|
-
'log', '--full-history', '--date-order', '--no-diff-merges', '-M', '--numstat',
|
|
626
|
-
`--format=${RS}%H${US}%aI${US}%s${US}%b`, ...(base ? [`^${base}`] : []), tip, '--', '.spec'],
|
|
627
|
-
},
|
|
628
|
-
'drift-numstat': {
|
|
629
|
-
kind: 'drift-numstat', order, reachable,
|
|
698
|
+
'identity-raw': {
|
|
699
|
+
kind: 'identity-raw', order, reachable,
|
|
630
700
|
argsFor: (base) => ['-C', root, '-c', 'core.quotePath=false',
|
|
631
|
-
'log', '--full-history', '--date-order', '--no-diff-merges', '-M', '--
|
|
632
|
-
`--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`,
|
|
633
703
|
...(base ? [`^${base}`] : []), tip],
|
|
634
704
|
},
|
|
635
705
|
merge: {
|
|
@@ -650,15 +720,23 @@ async function deriveEventStreams(
|
|
|
650
720
|
requests: EventStreamRequest[],
|
|
651
721
|
persist = true,
|
|
652
722
|
cache = true,
|
|
653
|
-
): Promise<Map<EventStreamKind,
|
|
723
|
+
): Promise<Map<EventStreamKind, EventStreamOutput>> {
|
|
654
724
|
if (!cache) {
|
|
725
|
+
const location = eventCacheLocation(root)
|
|
655
726
|
const outputs = await Promise.all(requests.map((request) => strictEventGit(request.argsFor(''))))
|
|
656
|
-
|
|
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
|
|
657
735
|
}
|
|
658
736
|
if (new Set(requests.map((request) => request.kind)).size !== requests.length)
|
|
659
737
|
throw new Error('one event-ledger transaction cannot request the same stream twice')
|
|
660
738
|
|
|
661
|
-
const run = async (location: EventCacheLocation): Promise<Map<EventStreamKind,
|
|
739
|
+
const run = async (location: EventCacheLocation): Promise<Map<EventStreamKind, EventStreamOutput> | null> => {
|
|
662
740
|
const snapshot = loadEventLedger(location)
|
|
663
741
|
const missing = requests.filter((request) => !(snapshot.state.streamTips.get(request.kind) ?? []).includes(tip))
|
|
664
742
|
const outputs = await Promise.all(missing.map((request) => {
|
|
@@ -696,8 +774,20 @@ async function eventStream(
|
|
|
696
774
|
request: EventStreamRequest,
|
|
697
775
|
persist = true,
|
|
698
776
|
cache = true,
|
|
699
|
-
): Promise<
|
|
700
|
-
|
|
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[]
|
|
701
791
|
}
|
|
702
792
|
export type GitTryFailure = 'exit' | 'spawn' | 'timeout'
|
|
703
793
|
export async function gitTry(args: string[], options: { indexFile?: string } = {}): Promise<{ ok: boolean; stdout: string; stderr: string; failure?: GitTryFailure }> {
|
|
@@ -817,27 +907,76 @@ export type DiffStat = { additions: number; deletions: number; files: number }
|
|
|
817
907
|
|
|
818
908
|
export type HistoryIndex = {
|
|
819
909
|
versions: Map<string, Version[]> // headPath -> rows newest-first (incl. pure-rename rows)
|
|
820
|
-
|
|
910
|
+
contentVersions: Set<string> // headPath\0hash rows whose immutable blob changed
|
|
911
|
+
versionPaths: Map<string, string> // headPath\0hash -> path at that version commit
|
|
821
912
|
mergeVersions?: Set<string> // path\0hash pairs with an all-parent combined-diff line
|
|
822
913
|
}
|
|
823
914
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
function
|
|
827
|
-
const
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
|
837
931
|
}
|
|
838
|
-
const
|
|
839
|
-
|
|
840
|
-
|
|
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 }
|
|
841
980
|
}
|
|
842
981
|
|
|
843
982
|
// Both bulk indices are pure functions of a checkout's HEAD, and they are read for SEVERAL roots at
|
|
@@ -855,11 +994,11 @@ const INDEX_ROOT_SLOTS = Math.max(4, Number(process.env.SPEXCODE_INDEX_CACHE_ROO
|
|
|
855
994
|
|
|
856
995
|
function rootKey(root: string): string { return resolve(root) }
|
|
857
996
|
|
|
858
|
-
function
|
|
997
|
+
function indexCacheKey(root: string, head: string): string { return `${eventCacheLocation(root).path}\0${head}` }
|
|
859
998
|
|
|
860
|
-
//
|
|
861
|
-
//
|
|
862
|
-
//
|
|
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.
|
|
863
1002
|
function touchRoot(roots: Map<string, string>, cache: Map<string, Promise<unknown>>, root: string, cacheKey: string): void {
|
|
864
1003
|
const key = rootKey(root)
|
|
865
1004
|
const previous = roots.get(key)
|
|
@@ -923,7 +1062,7 @@ export function historyIndex(root: string, tip = 'HEAD'): Promise<HistoryIndex>
|
|
|
923
1062
|
}
|
|
924
1063
|
const head = headOrEmpty(root)
|
|
925
1064
|
if (!head) return buildIndex(root, 'HEAD', false, true)
|
|
926
|
-
const cacheKey =
|
|
1065
|
+
const cacheKey = indexCacheKey(root, head)
|
|
927
1066
|
touchRoot(indexRoots, indexCache, root, cacheKey)
|
|
928
1067
|
const hit = indexCache.get(cacheKey)
|
|
929
1068
|
if (hit) return hit
|
|
@@ -943,39 +1082,82 @@ function headOrEmpty(root: string): string {
|
|
|
943
1082
|
}
|
|
944
1083
|
}
|
|
945
1084
|
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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)
|
|
955
1122
|
if (hit) return hit
|
|
956
|
-
const
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
const stack
|
|
961
|
-
|
|
962
|
-
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)
|
|
963
1129
|
if (position === undefined) continue
|
|
964
1130
|
const mask = 1 << (position & 7)
|
|
965
1131
|
if (bits[position >> 3] & mask) continue
|
|
966
1132
|
bits[position >> 3] |= mask
|
|
967
|
-
stack.push(
|
|
1133
|
+
stack.push(next)
|
|
968
1134
|
}
|
|
969
|
-
|
|
1135
|
+
memo.set(start, bits)
|
|
970
1136
|
return bits
|
|
971
1137
|
}
|
|
972
|
-
const
|
|
973
|
-
|
|
974
|
-
|
|
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)
|
|
975
1142
|
throw new Error(`rename projection cannot place ${older} against ${newer} in the current topology`)
|
|
976
|
-
|
|
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
|
|
977
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)
|
|
978
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]
|
|
979
1161
|
const pending = [path], resolved = new Set<string>(), seen = new Set<string>()
|
|
980
1162
|
while (pending.length) {
|
|
981
1163
|
const candidate = pending.pop()!
|
|
@@ -1002,68 +1184,61 @@ function canonicalPathProjector(
|
|
|
1002
1184
|
}
|
|
1003
1185
|
|
|
1004
1186
|
type SharedIndexInputs = {
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1187
|
+
allPaths: Set<string>
|
|
1188
|
+
specPaths: Set<string>
|
|
1189
|
+
topology: TopologyProjection
|
|
1190
|
+
identityRecords: IdentityRawRecord[]
|
|
1009
1191
|
mergeIndex: MergeHistoryEvents
|
|
1010
1192
|
}
|
|
1011
1193
|
|
|
1194
|
+
type TopologyProjection = {
|
|
1195
|
+
order: Map<string, number>
|
|
1196
|
+
parents: Map<string, string[]>
|
|
1197
|
+
reachable: Set<string>
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1012
1200
|
async function buildIndex(root: string, tip: string, transient: boolean, useCache = true, shared?: SharedIndexInputs): Promise<HistoryIndex> {
|
|
1013
1201
|
const versions = new Map<string, Version[]>()
|
|
1014
|
-
const
|
|
1202
|
+
const contentVersions = new Set<string>()
|
|
1203
|
+
const versionPaths = new Map<string, string>()
|
|
1015
1204
|
const mergeVersions = new Set<string>()
|
|
1016
1205
|
const commitVersions = new Map<string, Version>()
|
|
1017
1206
|
const commitOrder = new Map<string, number>()
|
|
1018
|
-
const
|
|
1019
|
-
|
|
1020
|
-
|
|
1207
|
+
const rawVersions = new Map<string, Version[]>()
|
|
1208
|
+
let currentPaths: Set<string>
|
|
1209
|
+
let topology: TopologyProjection
|
|
1210
|
+
if (shared) {
|
|
1211
|
+
currentPaths = shared.specPaths
|
|
1212
|
+
topology = shared.topology
|
|
1213
|
+
} else {
|
|
1214
|
+
const [tipPathsOut, topologyOut] = await Promise.all([
|
|
1021
1215
|
strictEventGit(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--name-only', tip, '--', '.spec']),
|
|
1022
1216
|
strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
|
|
1023
1217
|
])
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1218
|
+
currentPaths = new Set(tipPathsOut.split('\0').filter((path) => path.startsWith('.spec/')))
|
|
1219
|
+
topology = topologyProjection(topologyOut)
|
|
1220
|
+
}
|
|
1027
1221
|
const renamesByFrom = new Map<string, { hash: string; to: string }[]>()
|
|
1028
|
-
const topologyOrd =
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
topologyParents.set(hash, parents)
|
|
1035
|
-
}
|
|
1036
|
-
const topologyReachable = new Set(topologyOrd.keys())
|
|
1037
|
-
const out = shared?.historyOut ?? await eventStream(root, tip,
|
|
1038
|
-
indexEventRequests(root, tip, topologyOrd, topologyReachable).numstat, !transient, useCache)
|
|
1039
|
-
if (!out) return { versions, stats, mergeVersions }
|
|
1222
|
+
const topologyOrd = topology.order
|
|
1223
|
+
const topologyParents = topology.parents
|
|
1224
|
+
const topologyReachable = topology.reachable
|
|
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 }
|
|
1040
1228
|
let commitPosition = 0
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
const
|
|
1047
|
-
|
|
1048
|
-
const version: Version = { hash, date, reason, session: sm ? sm[1] : null }
|
|
1049
|
-
commitVersions.set(hash, version)
|
|
1050
|
-
if (!commitOrder.has(hash)) commitOrder.set(hash, commitPosition++)
|
|
1051
|
-
for (const line of rest.split('\n')) {
|
|
1052
|
-
const m = line.match(/^(-|\d+)\t(-|\d+)\t(.+)$/)
|
|
1053
|
-
if (!m) continue
|
|
1054
|
-
const add = m[1] === '-' ? 0 : +m[1]
|
|
1055
|
-
const del = m[2] === '-' ? 0 : +m[2]
|
|
1056
|
-
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
|
|
1057
1236
|
if (!rawVersions.has(to)) rawVersions.set(to, [])
|
|
1058
1237
|
rawVersions.get(to)!.push(version)
|
|
1059
|
-
|
|
1060
|
-
if (!hs) { hs = new Map(); rawStats.set(to, hs) }
|
|
1061
|
-
const s = hs.get(hash) ?? { additions: 0, deletions: 0, files: 0 }
|
|
1062
|
-
s.additions += add; s.deletions += del; s.files += 1
|
|
1063
|
-
hs.set(hash, s)
|
|
1238
|
+
if (oldOid !== newOid) rawContent.add(`${to}\0${record.h}`)
|
|
1064
1239
|
if (from !== to) {
|
|
1065
1240
|
const renames = renamesByFrom.get(from) ?? []
|
|
1066
|
-
renames.push({ hash, to })
|
|
1241
|
+
renames.push({ hash: record.h, to })
|
|
1067
1242
|
renamesByFrom.set(from, renames)
|
|
1068
1243
|
}
|
|
1069
1244
|
}
|
|
@@ -1130,56 +1305,41 @@ async function buildIndex(root: string, tip: string, transient: boolean, useCach
|
|
|
1130
1305
|
renames.push({ hash: rename.hash, to: rename.to })
|
|
1131
1306
|
renamesByFrom.set(rename.from, renames)
|
|
1132
1307
|
}
|
|
1133
|
-
const canonical = canonicalPathProjector(renamesByFrom,
|
|
1308
|
+
const canonical = canonicalPathProjector(renamesByFrom, topology)
|
|
1134
1309
|
for (const [path, pathRows] of rawVersions) {
|
|
1135
1310
|
for (const row of pathRows) for (const head of canonical(path, row.hash).filter((candidate) => currentPaths.has(candidate))) {
|
|
1136
1311
|
const rows = versions.get(head) ?? []
|
|
1137
1312
|
if (!rows.some((existing) => existing.hash === row.hash)) rows.push(row)
|
|
1138
1313
|
versions.set(head, rows)
|
|
1139
|
-
const
|
|
1140
|
-
|
|
1141
|
-
if (
|
|
1142
|
-
const existing = hs.get(row.hash)
|
|
1143
|
-
hs.set(row.hash, existing ? {
|
|
1144
|
-
additions: existing.additions + value.additions,
|
|
1145
|
-
deletions: existing.deletions + value.deletions,
|
|
1146
|
-
files: existing.files + value.files,
|
|
1147
|
-
} : value)
|
|
1148
|
-
}
|
|
1149
|
-
stats.set(head, hs)
|
|
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)
|
|
1150
1317
|
}
|
|
1151
1318
|
}
|
|
1152
1319
|
for (const [path, mergeEvents] of mergeIndex.resolutions) {
|
|
1153
1320
|
if (!isSpecMd(path)) continue
|
|
1154
1321
|
for (const { hash } of mergeEvents) for (const head of canonical(path, hash)) {
|
|
1155
1322
|
const rows = versions.get(head) ?? []
|
|
1156
|
-
const hs = stats.get(head) ?? new Map<string, DiffStat>()
|
|
1157
1323
|
const version = commitVersions.get(hash)
|
|
1158
1324
|
if (!version || rows.some((row) => row.hash === hash)) continue
|
|
1159
1325
|
rows.push(version)
|
|
1160
|
-
|
|
1161
|
-
mergeVersions.add(
|
|
1326
|
+
const key = `${head}\0${hash}`
|
|
1327
|
+
mergeVersions.add(key)
|
|
1328
|
+
if (!versionPaths.has(key)) versionPaths.set(key, path)
|
|
1162
1329
|
versions.set(head, rows)
|
|
1163
|
-
stats.set(head, hs)
|
|
1164
1330
|
}
|
|
1165
1331
|
}
|
|
1166
1332
|
for (const rows of versions.values()) {
|
|
1167
1333
|
rows.sort((a, b) => (commitOrder.get(a.hash) ?? Number.MAX_SAFE_INTEGER) - (commitOrder.get(b.hash) ?? Number.MAX_SAFE_INTEGER))
|
|
1168
1334
|
}
|
|
1169
|
-
return { versions,
|
|
1335
|
+
return { versions, contentVersions, versionPaths, mergeVersions }
|
|
1170
1336
|
}
|
|
1171
1337
|
|
|
1172
|
-
//
|
|
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.
|
|
1173
1340
|
export function rowsFor(idx: HistoryIndex, relPath: string): Version[] {
|
|
1174
1341
|
const rows = idx.versions.get(relPath) ?? []
|
|
1175
|
-
|
|
1176
|
-
return rows.filter((v) => {
|
|
1177
|
-
const s = st?.get(v.hash)
|
|
1178
|
-
return s != null && (s.additions + s.deletions > 0 || idx.mergeVersions?.has(`${relPath}\0${v.hash}`))
|
|
1179
|
-
})
|
|
1180
|
-
}
|
|
1181
|
-
export function statsFor(idx: HistoryIndex, relPath: string): Map<string, DiffStat> {
|
|
1182
|
-
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}`))
|
|
1183
1343
|
}
|
|
1184
1344
|
|
|
1185
1345
|
// per-commit numstat summed over a SET of paths in one `git log` walk. No `--follow` (it takes a single
|
|
@@ -1203,6 +1363,31 @@ export async function pathsStats(root: string, paths: string[]): Promise<Map<str
|
|
|
1203
1363
|
return m
|
|
1204
1364
|
}
|
|
1205
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
|
+
|
|
1206
1391
|
// the patch a spec.md got in one commit (vs parent); resolve its path AT that commit (reparents move it)
|
|
1207
1392
|
// via the stable leaf dir `…/<id>/spec.md`, then `git show` that path. `-M` keeps a rename+edit's body. '' on error.
|
|
1208
1393
|
export async function fileDiffAt(root: string, relPath: string, hash: string): Promise<string> {
|
|
@@ -1241,6 +1426,27 @@ export type DriftPathEvent = {
|
|
|
1241
1426
|
export type DiffLineRange = [number, number]
|
|
1242
1427
|
export type CombinedDiffOwnedChanges = { after: DiffLineRange[]; before: DiffLineRange[][]; parentPaths: string[] }
|
|
1243
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
|
+
|
|
1244
1450
|
// Dense combined diff prefixes have one column per parent. Ownership is a LINE fact, not a hunk fact:
|
|
1245
1451
|
// all `+` means the result authored a line absent from every parent; all `-` means it deleted a line present
|
|
1246
1452
|
// in every parent. Mixed columns inherit from at least one parent. Track every cursor through all displayed
|
|
@@ -1256,14 +1462,14 @@ export function combinedDiffOwnedChanges(patch: string): Map<string, CombinedDif
|
|
|
1256
1462
|
|
|
1257
1463
|
for (const line of patch.split('\n')) {
|
|
1258
1464
|
if (line.startsWith('diff --cc ')) {
|
|
1259
|
-
path = line.slice('diff --cc '.length)
|
|
1465
|
+
path = decodeGitCPath(line.slice('diff --cc '.length))
|
|
1260
1466
|
parents = 0
|
|
1261
1467
|
parentPaths = []
|
|
1262
1468
|
inHunk = false
|
|
1263
1469
|
continue
|
|
1264
1470
|
}
|
|
1265
1471
|
if (!inHunk && path !== null && line.startsWith('--- ')) {
|
|
1266
|
-
const raw = line.slice(4)
|
|
1472
|
+
const raw = decodeGitCPath(line.slice(4))
|
|
1267
1473
|
parentPaths.push(raw === '/dev/null' ? path : raw.replace(/^a\//, ''))
|
|
1268
1474
|
continue
|
|
1269
1475
|
}
|
|
@@ -1324,7 +1530,7 @@ function parseMergeHistoryEvents(out: string): MergeHistoryEvents {
|
|
|
1324
1530
|
const parentCount = raw[1].length
|
|
1325
1531
|
const fields = line.split('\t')
|
|
1326
1532
|
const status = fields[0].trim().split(/\s+/).at(-1) ?? ''
|
|
1327
|
-
const paths = fields.slice(1)
|
|
1533
|
+
const paths = fields.slice(1).map(decodeGitCPath)
|
|
1328
1534
|
if (status.length !== parentCount || paths.length !== parentCount + 1)
|
|
1329
1535
|
throw new Error(`combined raw diff for ${hash} exposed ${status.length} statuses and ${paths.length} paths for ${parentCount} parents`)
|
|
1330
1536
|
const resultPath = paths[parentCount]
|
|
@@ -1352,7 +1558,7 @@ async function mergeHistoryEvents(
|
|
|
1352
1558
|
): Promise<MergeHistoryEvents> {
|
|
1353
1559
|
if (!useCache) return parseMergeHistoryEvents(await strictEventGit(['-C', root, '-c', 'core.quotePath=false',
|
|
1354
1560
|
'log', '--merges', '--raw', '--patch', '--cc', '--combined-all-paths', '--unified=0', '--no-color', '--no-ext-diff', '-M', `--format=${RS}%H`, tip]))
|
|
1355
|
-
return parseMergeHistoryEvents(await
|
|
1561
|
+
return parseMergeHistoryEvents(await textEventStream(root, tip,
|
|
1356
1562
|
indexEventRequests(root, tip, order, reachable).merge, !transient, useCache))
|
|
1357
1563
|
}
|
|
1358
1564
|
|
|
@@ -1361,51 +1567,43 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
|
|
|
1361
1567
|
const fileEvents = new Map<string, DriftPathEvent[]>()
|
|
1362
1568
|
const lineageEvents = new Map<string, DriftPathEvent[]>()
|
|
1363
1569
|
const acks = new Map<string, Set<string>>(), selfAcks = new Map<string, Set<string>>(), specNodes = new Map<string, Set<string>>()
|
|
1364
|
-
const ackCandidates = new Map<string, Set<string>>(),
|
|
1570
|
+
const ackCandidates = new Map<string, Set<string>>(), ackCheckpoints = new Map<string, boolean>()
|
|
1365
1571
|
const idx: DriftIndex = { tip, ord, parents, fileEvents, lineageEvents, lineageKeys: (path) => [path], resolutionEvents: new Map(), acks, selfAcks, specNodes, anc: new Map() }
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1572
|
+
let currentPaths: Set<string>
|
|
1573
|
+
let topology: TopologyProjection
|
|
1574
|
+
if (shared) {
|
|
1575
|
+
currentPaths = shared.allPaths
|
|
1576
|
+
topology = shared.topology
|
|
1577
|
+
} else {
|
|
1578
|
+
const [tipPathsOut, topologyOut] = await Promise.all([
|
|
1579
|
+
strictEventGit(['-C', root, 'ls-tree', '-r', '-z', '--name-only', tip]),
|
|
1370
1580
|
strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
|
|
1371
1581
|
])
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
indexEventRequests(root, tip, topologyOrder, topologyReachable)['drift-numstat'], !transient, useCache)
|
|
1387
|
-
if (!out) return idx
|
|
1582
|
+
currentPaths = new Set(tipPathsOut.split('\0').filter(Boolean))
|
|
1583
|
+
topology = topologyProjection(topologyOut)
|
|
1584
|
+
}
|
|
1585
|
+
const topologyOrder = topology.order
|
|
1586
|
+
const topologyParents = topology.parents
|
|
1587
|
+
const topologyReachable = topology.reachable
|
|
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
|
|
1388
1596
|
const rawFileEvents = new Map<string, DriftPathEvent[]>()
|
|
1389
1597
|
const renamesByFrom = new Map<string, RenameProjectionEvent[]>()
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
const r = rec.replace(/^\n/, '')
|
|
1393
|
-
if (!r) continue
|
|
1394
|
-
const lines = r.split('\n')
|
|
1395
|
-
const [hash, parentStr = '', tree = '', ackStr = ''] = lines[0].split(US)
|
|
1396
|
-
if (!hash) continue
|
|
1397
|
-
trees.set(hash, tree)
|
|
1398
|
-
if (!ord.has(hash)) {
|
|
1399
|
-
ord.set(hash, i++)
|
|
1400
|
-
parents.set(hash, parentStr.split(' ').filter(Boolean))
|
|
1401
|
-
}
|
|
1598
|
+
for (const record of records) {
|
|
1599
|
+
const { h: hash, a: ackStr, c: changes } = record
|
|
1402
1600
|
const ackSet = new Set(ackStr.split(',').map((s) => s.trim()).filter(Boolean))
|
|
1403
|
-
if (ackSet.size)
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
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) {
|
|
1409
1607
|
if (!merge) {
|
|
1410
1608
|
const events = rawFileEvents.get(to) ?? []
|
|
1411
1609
|
const event: DriftPathEvent = {
|
|
@@ -1430,10 +1628,11 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
|
|
|
1430
1628
|
renames.push({ hash: rename.hash, to: rename.to })
|
|
1431
1629
|
renamesByFrom.set(rename.from, renames)
|
|
1432
1630
|
}
|
|
1433
|
-
const canonical = canonicalPathProjector(renamesByFrom,
|
|
1631
|
+
const canonical = canonicalPathProjector(renamesByFrom, topology)
|
|
1434
1632
|
idx.lineageKeys = canonical
|
|
1435
|
-
|
|
1436
|
-
|
|
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[]>) => {
|
|
1437
1636
|
for (const key of keys) {
|
|
1438
1637
|
const events = target.get(key) ?? []
|
|
1439
1638
|
if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath)) events.push(event)
|
|
@@ -1441,8 +1640,9 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
|
|
|
1441
1640
|
}
|
|
1442
1641
|
}
|
|
1443
1642
|
for (const [path, rawEvents] of rawFileEvents) for (const event of rawEvents) {
|
|
1444
|
-
|
|
1445
|
-
|
|
1643
|
+
const keys = canonical(path, event.commit)
|
|
1644
|
+
addEvent(keys, event, lineageEvents)
|
|
1645
|
+
for (const head of keys.filter((candidate) => currentPaths.has(candidate))) {
|
|
1446
1646
|
const events = fileEvents.get(head) ?? []
|
|
1447
1647
|
if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath)) events.push(event)
|
|
1448
1648
|
fileEvents.set(head, events)
|
|
@@ -1456,7 +1656,8 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
|
|
|
1456
1656
|
for (const [hash, nodes] of ackCandidates) {
|
|
1457
1657
|
const parentList = parents.get(hash) ?? []
|
|
1458
1658
|
const firstParent = parentList[0]
|
|
1459
|
-
|
|
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
|
|
1460
1661
|
;(checkpoint ? acks : selfAcks).set(hash, nodes)
|
|
1461
1662
|
}
|
|
1462
1663
|
for (const [path, mergeEvents] of mergeIndex.resolutions) for (const mergeEvent of mergeEvents) {
|
|
@@ -1468,8 +1669,9 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
|
|
|
1468
1669
|
historicalPath: path,
|
|
1469
1670
|
parents: mergeParents.map((commit, index) => ({ commit, historicalPath: mergeEvent.parentPaths[index] })),
|
|
1470
1671
|
}
|
|
1471
|
-
|
|
1472
|
-
|
|
1672
|
+
const keys = canonical(path, mergeEvent.hash)
|
|
1673
|
+
addEvent(keys, event, lineageEvents)
|
|
1674
|
+
for (const head of keys.filter((candidate) => currentPaths.has(candidate))) {
|
|
1473
1675
|
const events = idx.resolutionEvents!.get(head) ?? []
|
|
1474
1676
|
if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath)) events.push(event)
|
|
1475
1677
|
idx.resolutionEvents!.set(head, events)
|
|
@@ -1497,7 +1699,7 @@ export function driftIndex(root: string, tip = 'HEAD'): Promise<DriftIndex> {
|
|
|
1497
1699
|
}
|
|
1498
1700
|
const head = headOrEmpty(root) // filesystem HEAD, no subprocess — see historyIndex
|
|
1499
1701
|
if (!head) return buildDriftIndex(root, 'HEAD', false, true)
|
|
1500
|
-
const cacheKey =
|
|
1702
|
+
const cacheKey = indexCacheKey(root, head)
|
|
1501
1703
|
touchRoot(driftRoots, driftIdxCache, root, cacheKey)
|
|
1502
1704
|
const hit = driftIdxCache.get(cacheKey)
|
|
1503
1705
|
if (hit) return hit
|
|
@@ -1507,32 +1709,43 @@ export function driftIndex(root: string, tip = 'HEAD'): Promise<DriftIndex> {
|
|
|
1507
1709
|
return p
|
|
1508
1710
|
}
|
|
1509
1711
|
|
|
1510
|
-
function topologyProjection(out: string):
|
|
1511
|
-
const order = new Map<string, number>(), reachable = new Set<string>()
|
|
1712
|
+
function topologyProjection(out: string): TopologyProjection {
|
|
1713
|
+
const order = new Map<string, number>(), parents = new Map<string, string[]>(), reachable = new Set<string>()
|
|
1512
1714
|
let position = 0
|
|
1513
1715
|
for (const line of out.trim().split('\n')) {
|
|
1514
|
-
const hash = line.split(' '
|
|
1716
|
+
const [hash, ...parentList] = line.split(' ')
|
|
1515
1717
|
if (!hash) continue
|
|
1516
1718
|
order.set(hash, position++)
|
|
1719
|
+
parents.set(hash, parentList)
|
|
1517
1720
|
reachable.add(hash)
|
|
1518
1721
|
}
|
|
1519
|
-
return { order, reachable }
|
|
1722
|
+
return { order, parents, reachable }
|
|
1520
1723
|
}
|
|
1521
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
|
+
}
|
|
1522
1733
|
async function buildIndexPair(root: string, tip: string, transient: boolean, useCache = true): Promise<[HistoryIndex, DriftIndex]> {
|
|
1523
1734
|
const [allPathsOut, topologyOut] = await Promise.all([
|
|
1524
1735
|
strictEventGit(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--name-only', tip]),
|
|
1525
1736
|
strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
|
|
1526
1737
|
])
|
|
1527
1738
|
const topology = topologyProjection(topologyOut)
|
|
1739
|
+
const allPaths = new Set(allPathsOut.split('\0').filter(Boolean))
|
|
1740
|
+
const specPaths = new Set([...allPaths].filter((path) => path.startsWith('.spec/')))
|
|
1528
1741
|
const requests = indexEventRequests(root, tip, topology.order, topology.reachable)
|
|
1529
1742
|
const streams = await deriveEventStreams(root, tip, EVENT_STREAM_KINDS.map((kind) => requests[kind]), !transient, useCache)
|
|
1530
1743
|
const shared: SharedIndexInputs = {
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
mergeIndex: parseMergeHistoryEvents(streams.get('merge')
|
|
1744
|
+
allPaths,
|
|
1745
|
+
specPaths,
|
|
1746
|
+
topology,
|
|
1747
|
+
identityRecords: identityRawStream(streams.get('identity-raw'), 'identity-raw'),
|
|
1748
|
+
mergeIndex: parseMergeHistoryEvents(textStream(streams.get('merge'), 'merge')),
|
|
1536
1749
|
}
|
|
1537
1750
|
streams.clear()
|
|
1538
1751
|
return Promise.all([
|
|
@@ -1565,7 +1778,7 @@ export function sourceIndexes(root: string, tip = 'HEAD'): Promise<[HistoryIndex
|
|
|
1565
1778
|
}
|
|
1566
1779
|
const head = headOrEmpty(root)
|
|
1567
1780
|
if (!head) return buildIndexPair(root, 'HEAD', false, true)
|
|
1568
|
-
const cacheKey =
|
|
1781
|
+
const cacheKey = indexCacheKey(root, head)
|
|
1569
1782
|
touchRoot(indexRoots, indexCache, root, cacheKey)
|
|
1570
1783
|
touchRoot(driftRoots, driftIdxCache, root, cacheKey)
|
|
1571
1784
|
const historyHit = indexCache.get(cacheKey), driftHit = driftIdxCache.get(cacheKey)
|
|
@@ -1735,6 +1948,15 @@ function parseNameStatus(out: string): { code: string; from: string; to: string
|
|
|
1735
1948
|
|
|
1736
1949
|
export type ReviewDiffFile = { path: string; oldPath?: string; status: string; additions: number; deletions: number }
|
|
1737
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
|
+
}
|
|
1738
1960
|
export async function mergeBaseDiff(wtPath: string, mainRef = 'main'): Promise<ReviewDiffFile[]> {
|
|
1739
1961
|
const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
|
|
1740
1962
|
const base = (await run(['merge-base', mainRef, 'HEAD'])).trim()
|