spexcode 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -102
- package/README.zh-CN.md +170 -0
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +24 -1
- package/spec-cli/src/attach.ts +50 -0
- package/spec-cli/src/cli.ts +227 -66
- package/spec-cli/src/client.ts +47 -9
- package/spec-cli/src/{self.ts → doctor.ts} +26 -25
- package/spec-cli/src/gateway.ts +15 -11
- package/spec-cli/src/guide.ts +73 -17
- package/spec-cli/src/harness.ts +48 -19
- package/spec-cli/src/help.ts +141 -51
- package/spec-cli/src/index.ts +41 -14
- package/spec-cli/src/issues.ts +109 -31
- package/spec-cli/src/layout.ts +4 -4
- package/spec-cli/src/localIssues.ts +59 -58
- package/spec-cli/src/materialize.ts +4 -2
- package/spec-cli/src/mentions.ts +22 -1
- package/spec-cli/src/pty-bridge.ts +39 -4
- package/spec-cli/src/ranker.ts +31 -12
- package/spec-cli/src/search.bench.mjs +30 -7
- package/spec-cli/src/search.ts +39 -0
- package/spec-cli/src/sessions.ts +149 -62
- package/spec-cli/src/specs.ts +16 -4
- package/spec-cli/src/supervise.ts +30 -6
- package/spec-cli/src/tree.ts +118 -0
- package/spec-cli/templates/hooks/post-merge +2 -2
- package/spec-cli/templates/hooks/pre-commit +34 -15
- package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
- package/spec-dashboard/dist/assets/Dashboard-BwZ2KzxB.js +27 -0
- package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
- package/spec-dashboard/dist/assets/EvalsPage-DV75EdP4.js +3 -0
- package/spec-dashboard/dist/assets/FoldToggle-GwE0-k1d.js +1 -0
- package/spec-dashboard/dist/assets/IssuesPage-B17pnl9I.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-WEZbR8M1.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-Sh8kHpnj.js +71 -0
- package/spec-dashboard/dist/assets/SessionWindow-EzFq-hLG.js +9 -0
- package/spec-dashboard/dist/assets/Settings-Dgtg-Xb9.js +1 -0
- package/spec-dashboard/dist/assets/index-CCmnCbKS.css +1 -0
- package/spec-dashboard/dist/assets/index-Dd0_U5rk.js +41 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-forge/src/cli.ts +4 -10
- package/spec-forge/src/drivers.ts +13 -0
- package/spec-yatsu/src/cli.ts +89 -15
- package/spec-yatsu/src/scenariofresh.ts +100 -30
- package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +0 -145
- package/spec-dashboard/dist/assets/index-BTU-44Os.css +0 -32
package/spec-yatsu/src/cli.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from 'node:fs'
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
2
2
|
import { join, relative, dirname } from 'node:path'
|
|
3
3
|
import { repoRoot, headSha, driftIndex, stagedFiles, git } from '../../spec-cli/src/git.js'
|
|
4
4
|
import { loadSpecs } from '../../spec-cli/src/specs.js'
|
|
@@ -9,10 +9,11 @@ import { readReadings, readSidecar, appendReading, appendRetraction, latestPerSc
|
|
|
9
9
|
import { staleAxes } from './freshness.js'
|
|
10
10
|
import { scenarioIndex } from './scenariofresh.js'
|
|
11
11
|
import { loadEvalRemarkTracks, trackKey } from '../../spec-cli/src/issues.js'
|
|
12
|
+
import { stripRefSigil } from '../../spec-cli/src/mentions.js'
|
|
12
13
|
import { evaluatorTag } from './evaluator.js'
|
|
13
|
-
import { putBlob, listBlobs, gc, isStrayBlob } from './cache.js'
|
|
14
|
+
import { putBlob, blobPath, listBlobs, gc, isStrayBlob } from './cache.js'
|
|
14
15
|
import { validateTimeline } from './timeline.js'
|
|
15
|
-
import { evalTimeline, type EvalTimeline } from './evaltab.js'
|
|
16
|
+
import { evalTimeline, readBlobByHash, type EvalTimeline } from './evaltab.js'
|
|
16
17
|
|
|
17
18
|
function flag(args: string[], name: string): string | undefined {
|
|
18
19
|
const i = args.indexOf(`--${name}`)
|
|
@@ -206,7 +207,7 @@ async function evalCmd(args: string[]): Promise<number> {
|
|
|
206
207
|
return 2
|
|
207
208
|
}
|
|
208
209
|
const sel = positional(args)
|
|
209
|
-
const id = !sel || sel === '.' ? currentNodeId(root) : sel
|
|
210
|
+
const id = !sel || sel === '.' ? currentNodeId(root) : stripRefSigil(sel) // node args tolerate @/[[ ]] sigils ([[mentions]])
|
|
210
211
|
if (!id) { console.error('spex yatsu eval .: no current node (no .session/node-branch here) — name a node'); return 2 }
|
|
211
212
|
const node = (await gatherNodes(root)).find((n) => n.id === id)
|
|
212
213
|
if (!node) { console.error(`spex yatsu eval: no yatsu node '${id}' (a node needs a yatsu.md)`); return 1 }
|
|
@@ -273,9 +274,34 @@ async function evalCmd(args: string[]): Promise<number> {
|
|
|
273
274
|
: 'no evidence'
|
|
274
275
|
console.log(` ✓ '${id}' scenario '${scenario.name}' → ${verdictText(verdict)} @ ${reading.codeSha.slice(0, 7)} [${reading.evaluator}] (${ev})`)
|
|
275
276
|
console.log(`spex yatsu eval: 1 measurement filed`)
|
|
277
|
+
|
|
278
|
+
// @@@mis-anchor guard - a codeSha names a COMMIT, never a working tree: filed over uncommitted governed
|
|
279
|
+
// edits, this reading claims a verdict at HEAD while HEAD lacks the code actually measured — wrong from
|
|
280
|
+
// birth, and the stale flag after the next commit is freshness exposing it, not an engine bug. Warn,
|
|
281
|
+
// never block: the honest flow is measure on the tree until green → commit that tested tree → file.
|
|
282
|
+
const dirty = dirtyGoverned(root, [...(scenario.code?.length ? scenario.code : node.codeFiles), node.yatsuPath])
|
|
283
|
+
if (dirty.length) {
|
|
284
|
+
console.error(` ⚠ mis-anchored reading: uncommitted changes in governed ${dirty.join(', ')}`)
|
|
285
|
+
console.error(` this reading anchors to HEAD ${reading.codeSha.slice(0, 7)}, which does NOT contain those edits — it claims a ${verdict.status} for code that never ran, and the commit that lands them will (correctly) read it stale.`)
|
|
286
|
+
console.error(` the honest flow: measure on the tree until green → commit that just-tested tree (code+spec) → THEN file the reading against the clean HEAD (retract this one if it recorded the dirty run).`)
|
|
287
|
+
}
|
|
276
288
|
return 0
|
|
277
289
|
}
|
|
278
290
|
|
|
291
|
+
// the governed paths with uncommitted changes (staged, unstaged, or untracked) — the mis-anchor guard's
|
|
292
|
+
// probe. Paths are repo-relative pathspecs, so a governed dir prefix or `*` glob scopes the same way
|
|
293
|
+
// nodeChanged matches it; a rename line reports its new name.
|
|
294
|
+
function dirtyGoverned(root: string, paths: string[]): string[] {
|
|
295
|
+
if (!paths.length) return []
|
|
296
|
+
const out = git(['-C', root, 'status', '--porcelain', '--', ...paths])
|
|
297
|
+
const files = out.split('\n').filter(Boolean).map((l) => {
|
|
298
|
+
const p = l.slice(3)
|
|
299
|
+
const i = p.indexOf(' -> ')
|
|
300
|
+
return i >= 0 ? p.slice(i + 4) : p
|
|
301
|
+
})
|
|
302
|
+
return [...new Set(files)]
|
|
303
|
+
}
|
|
304
|
+
|
|
279
305
|
// the verdict from the flags: --pass or --fail sets the status (pass wins if both given); --note <text> is an
|
|
280
306
|
// OPTIONAL annotation attached to either. No status flag → null (a measurement must commit to pass or fail).
|
|
281
307
|
function parseVerdict(args: string[]): Verdict | null {
|
|
@@ -309,7 +335,7 @@ async function retractCmd(args: string[]): Promise<number> {
|
|
|
309
335
|
return 2
|
|
310
336
|
}
|
|
311
337
|
const sel = positional(args)
|
|
312
|
-
const id = !sel || sel === '.' ? currentNodeId(root) : sel
|
|
338
|
+
const id = !sel || sel === '.' ? currentNodeId(root) : stripRefSigil(sel)
|
|
313
339
|
if (!id) { console.error('spex yatsu retract .: no current node (no .session/node-branch here) — name a node'); return 2 }
|
|
314
340
|
const node = yatsuNodes(root).find((n) => n.id === id)
|
|
315
341
|
if (!node) { console.error(`spex yatsu retract: no yatsu node '${id}' (a node needs a yatsu.md)`); return 1 }
|
|
@@ -410,7 +436,7 @@ function checkStaged(): number {
|
|
|
410
436
|
async function show(args: string[]): Promise<number> {
|
|
411
437
|
const root = repoRoot()
|
|
412
438
|
const sel = positional(args)
|
|
413
|
-
const id = !sel || sel === '.' ? currentNodeId(root) : sel
|
|
439
|
+
const id = !sel || sel === '.' ? currentNodeId(root) : stripRefSigil(sel)
|
|
414
440
|
if (!id) { console.error('spex yatsu show .: no current node (no .session/node-branch here) — name a node'); return 2 }
|
|
415
441
|
const tl = await evalTimeline(id) // no ctx → evalTimeline derives specs + driftIndex itself for this one id
|
|
416
442
|
if (has(args, 'json')) { console.log(JSON.stringify(tl, null, 2)); return 0 }
|
|
@@ -464,16 +490,20 @@ export async function runYatsu(args: string[]): Promise<number> {
|
|
|
464
490
|
return 2
|
|
465
491
|
}
|
|
466
492
|
|
|
467
|
-
// `spex blob put <file|->` ([[blob-put]]) — the bare
|
|
468
|
-
//
|
|
469
|
-
//
|
|
493
|
+
// `spex blob put <file|->` / `spex blob get <hash> [-o <file>]` ([[blob-put]], [[blob-get]]) — the bare
|
|
494
|
+
// evidence-transport pair: put stashes bytes in the shared content-addressed cache and prints the hash,
|
|
495
|
+
// WITHOUT filing a reading (`yatsu eval --video` couples the two); get is its symmetric read — hash in,
|
|
496
|
+
// bytes out. putBlob is idempotent by content, so re-putting re-seeds a checkout whose cache lacks a blob
|
|
470
497
|
// some thread already references by hash (the clone-evidence-404 repair).
|
|
471
|
-
export function runBlob(args: string[]): number {
|
|
472
|
-
|
|
473
|
-
if (args[0]
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
498
|
+
export async function runBlob(args: string[]): Promise<number> {
|
|
499
|
+
if (args[0] === 'put' && args[1] !== undefined) return blobPut(args[1])
|
|
500
|
+
if (args[0] === 'get') return blobGet(args.slice(1))
|
|
501
|
+
console.error('spex blob: put <file|-> — stash bytes in the shared evidence cache, print the content hash')
|
|
502
|
+
console.error(' get <hash> [-o <file>] — read a blob back: local cache first, backend fallback')
|
|
503
|
+
return 2
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function blobPut(file: string): number {
|
|
477
507
|
let bytes: Buffer
|
|
478
508
|
try { bytes = readFileSync(file === '-' ? 0 : file) } catch (e) {
|
|
479
509
|
console.error(`spex blob put: cannot read ${file}: ${(e as Error).message}`)
|
|
@@ -483,3 +513,47 @@ export function runBlob(args: string[]): number {
|
|
|
483
513
|
console.log(putBlob(bytes))
|
|
484
514
|
return 0
|
|
485
515
|
}
|
|
516
|
+
|
|
517
|
+
// the read half: ① the local content-addressed cache (the evidence usually IS on this disk — no backend
|
|
518
|
+
// needed), ② on a local miss the same GET /api/yatsu/blob/:hash the dashboard streams from (the blob may
|
|
519
|
+
// have been pruned here, or put on another machine sharing the backend), ③ both missed → fail loud naming
|
|
520
|
+
// both paths. No third read mechanism — this reuses readBlobByHash and the existing endpoint verbatim.
|
|
521
|
+
async function blobGet(args: string[]): Promise<number> {
|
|
522
|
+
const oIdx = args.indexOf('-o')
|
|
523
|
+
const out = oIdx >= 0 ? args[oIdx + 1] : undefined
|
|
524
|
+
if (oIdx >= 0 && (out === undefined || out.startsWith('-'))) { console.error('spex blob get: -o needs a <file>'); return 2 }
|
|
525
|
+
const hash = args.find((a, i) => (oIdx < 0 || (i !== oIdx && i !== oIdx + 1)) && !a.startsWith('-'))
|
|
526
|
+
if (!hash) { console.error('spex blob get: usage: spex blob get <hash> [-o <file>]'); return 2 }
|
|
527
|
+
const local = readBlobByHash(hash) // validates 64-hex before touching the fs, then reads the shared cache
|
|
528
|
+
if (local.ok) return emitBlob(local.bytes, out)
|
|
529
|
+
if (local.reason === 'invalid') { console.error(`spex blob get: bad hash '${hash}' — a blob hash is 64 hex chars`); return 2 }
|
|
530
|
+
const { apiBase } = await import('../../spec-cli/src/sessions.js')
|
|
531
|
+
const url = `${await apiBase()}/api/yatsu/blob/${hash}`
|
|
532
|
+
let backendMiss: string
|
|
533
|
+
try {
|
|
534
|
+
const r = await fetch(url)
|
|
535
|
+
if (r.ok) return emitBlob(Buffer.from(await r.arrayBuffer()), out)
|
|
536
|
+
backendMiss = `HTTP ${r.status}`
|
|
537
|
+
} catch (e) {
|
|
538
|
+
backendMiss = `unreachable (${(e as Error).message})`
|
|
539
|
+
}
|
|
540
|
+
console.error(`spex blob get: ${hash} — not found on either path:`)
|
|
541
|
+
console.error(` local cache: ${blobPath(hash)} — no such blob (pruned, or put on another machine)`)
|
|
542
|
+
console.error(` backend: ${url} — ${backendMiss}`)
|
|
543
|
+
return 1
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// default stdout (pipe-friendly; the cli.ts flushExit drains a large piped dump before exit), -o writes a
|
|
547
|
+
// file. Raw bytes straight at a human's terminal get a one-line stderr warning, not a block.
|
|
548
|
+
function emitBlob(bytes: Buffer, out?: string): number {
|
|
549
|
+
if (out !== undefined) {
|
|
550
|
+
try { writeFileSync(out, bytes) } catch (e) {
|
|
551
|
+
console.error(`spex blob get: cannot write ${out}: ${(e as Error).message}`)
|
|
552
|
+
return 2
|
|
553
|
+
}
|
|
554
|
+
return 0
|
|
555
|
+
}
|
|
556
|
+
if (process.stdout.isTTY) console.error(`spex blob get: writing ${bytes.length} raw bytes to a tty — pipe it or use -o <file>`)
|
|
557
|
+
process.stdout.write(bytes)
|
|
558
|
+
return 0
|
|
559
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
1
2
|
import { gitA, headSha } from '../../spec-cli/src/git.js'
|
|
2
3
|
import { parseScenarios } from './yatsu.js'
|
|
3
4
|
|
|
@@ -25,49 +26,118 @@ function blockContent(src: string): Map<string, string> {
|
|
|
25
26
|
return m
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
29
|
+
const ZERO = '0'.repeat(40) // the null OID a delete/add carries on its absent side
|
|
30
|
+
const EMPTY: Map<string, string> = new Map()
|
|
31
|
+
|
|
32
|
+
// content-addressed: a blob OID -> its canonical per-scenario block map. Git objects are IMMUTABLE, so this
|
|
33
|
+
// memo never needs invalidation and is shared across every root — a per-worktree build reuses the blocks main
|
|
34
|
+
// already parsed, which is what lets a worktree's freshness build stay cheap instead of re-reading everything.
|
|
35
|
+
const blockByOid = new Map<string, Map<string, string>>()
|
|
36
|
+
|
|
37
|
+
// ONE `git log --raw` walk over all yatsu.md → per head-path version chain [{hash, oid}], newest-first,
|
|
38
|
+
// rename-followed via the `alias` idiom (git.ts buildIndex): the newest sighting of a path IS its head path;
|
|
39
|
+
// an `R` row remaps the older `from` path onto that head. The raw row carries the new blob OID directly, so
|
|
40
|
+
// no per-version path resolution and no `git show`. --full-history is REQUIRED: default pathspec
|
|
41
|
+
// history-simplification prunes commits off HEAD's first-parent chain, but in this repo every spec edit lands
|
|
42
|
+
// via a --no-ff merge of a node branch, so those pruned commits ARE the scenario edits — dropping them would
|
|
43
|
+
// under-report staleness (a stale reading judged fresh). Merge commits emit no raw diff row, so they add no
|
|
44
|
+
// version (exactly as the old `--follow` did).
|
|
45
|
+
async function fileChains(root: string, wanted: Set<string>): Promise<Map<string, { hash: string; oid: string }[]>> {
|
|
46
|
+
const chains = new Map<string, { hash: string; oid: string }[]>()
|
|
47
|
+
const alias = new Map<string, string>()
|
|
48
|
+
const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'log',
|
|
49
|
+
'--raw', '--no-abbrev', '--full-history', '-M', `--format=${RS}%H`, '--', '*yatsu.md'])
|
|
50
|
+
for (const rec of out.split(RS)) {
|
|
51
|
+
const nl = rec.indexOf('\n')
|
|
52
|
+
if (nl < 0) continue
|
|
53
|
+
const hash = rec.slice(0, nl)
|
|
54
|
+
if (!hash) continue
|
|
55
|
+
for (const line of rec.slice(nl + 1).split('\n')) {
|
|
56
|
+
if (line[0] !== ':') continue // `:<oldmode> <newmode> <oldoid> <newoid> <status>\t<path>[\t<path2>]`
|
|
57
|
+
const tab = line.indexOf('\t')
|
|
58
|
+
if (tab < 0) continue
|
|
59
|
+
const meta = line.slice(1, tab).split(' ')
|
|
60
|
+
const oid = meta[3], rename = meta[4][0] === 'R' || meta[4][0] === 'C'
|
|
61
|
+
const paths = line.slice(tab + 1).split('\t')
|
|
62
|
+
const to = rename ? paths[1] : paths[0] // the path on the newer side of this commit
|
|
63
|
+
let head = alias.get(to)
|
|
64
|
+
if (head === undefined) { head = to; alias.set(to, to) }
|
|
65
|
+
let arr = chains.get(head); if (!arr) { arr = []; chains.set(head, arr) }
|
|
66
|
+
arr.push({ hash, oid })
|
|
67
|
+
if (rename && paths[0] !== to) { alias.set(paths[0], head); alias.delete(to) } // older history calls it `from`
|
|
68
|
+
}
|
|
43
69
|
}
|
|
44
|
-
|
|
70
|
+
for (const k of [...chains.keys()]) if (!wanted.has(k)) chains.delete(k) // keep only the head paths asked for
|
|
71
|
+
return chains
|
|
45
72
|
}
|
|
46
73
|
|
|
47
|
-
// per-scenario change-commits for one file
|
|
48
|
-
// scenario whose block differs from the next-older version (undefined
|
|
49
|
-
|
|
50
|
-
|
|
74
|
+
// per-scenario change-commits for one file's version chain: newest->oldest, attribute a commit to every
|
|
75
|
+
// scenario whose canonical block differs from the next-older version (undefined either side = add/remove = a
|
|
76
|
+
// change). A ZERO-oid (delete) version is dropped — it carries no readable content, matching the old
|
|
77
|
+
// `git show ''` skip; a pure rename (R100, oid == the older version's oid) diffs to no change for free.
|
|
78
|
+
function scenarioCommits(chain: { hash: string; oid: string }[]): Map<string, string[]> {
|
|
51
79
|
const commits = new Map<string, string[]>()
|
|
52
80
|
const push = (name: string, hash: string) => { const a = commits.get(name); if (a) a.push(hash); else commits.set(name, [hash]) }
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
81
|
+
const real = chain.filter((v) => v.oid !== ZERO)
|
|
82
|
+
for (let i = 0; i < real.length; i++) {
|
|
83
|
+
const cur = blockByOid.get(real[i].oid) ?? EMPTY
|
|
84
|
+
const older = i + 1 < real.length ? (blockByOid.get(real[i + 1].oid) ?? EMPTY) : EMPTY
|
|
56
85
|
for (const name of new Set([...cur.keys(), ...older.keys()])) {
|
|
57
|
-
if (cur.get(name) !== older.get(name)) push(name,
|
|
86
|
+
if (cur.get(name) !== older.get(name)) push(name, real[i].hash)
|
|
58
87
|
}
|
|
59
88
|
}
|
|
60
89
|
return commits
|
|
61
90
|
}
|
|
62
91
|
|
|
92
|
+
// read MANY blobs in ONE `git cat-file --batch` process (vs one `git show` per blob). Feeds the OIDs on
|
|
93
|
+
// stdin, parses the `<oid> <type> <size>\n<payload>\n` records byte-accurately (size is bytes; blobs are
|
|
94
|
+
// UTF-8). A `<oid> missing` line yields no entry. Env-stripped like git.ts's helpers (a stray GIT_DIR would
|
|
95
|
+
// misroute repo discovery); kept here beside its only caller — a general git-seam primitive if a second
|
|
96
|
+
// caller ever wants one.
|
|
97
|
+
function catFileBatch(root: string, oids: string[]): Promise<Map<string, string>> {
|
|
98
|
+
const out = new Map<string, string>()
|
|
99
|
+
if (!oids.length) return Promise.resolve(out)
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const env = { ...process.env }
|
|
102
|
+
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
103
|
+
const p = spawn('git', ['-C', root, 'cat-file', '--batch'], { env })
|
|
104
|
+
const chunks: Buffer[] = []
|
|
105
|
+
p.stdout.on('data', (c: Buffer) => chunks.push(c))
|
|
106
|
+
p.on('error', reject)
|
|
107
|
+
p.on('close', () => {
|
|
108
|
+
const buf = Buffer.concat(chunks)
|
|
109
|
+
let i = 0
|
|
110
|
+
while (i < buf.length) {
|
|
111
|
+
const nl = buf.indexOf(0x0a, i)
|
|
112
|
+
if (nl < 0) break
|
|
113
|
+
const header = buf.toString('utf8', i, nl)
|
|
114
|
+
i = nl + 1
|
|
115
|
+
if (header.endsWith(' missing')) continue // unknown OID — no payload follows
|
|
116
|
+
const size = Number(header.slice(header.lastIndexOf(' ') + 1))
|
|
117
|
+
if (!Number.isFinite(size)) break
|
|
118
|
+
out.set(header.slice(0, header.indexOf(' ')), buf.toString('utf8', i, i + size))
|
|
119
|
+
i += size + 1 // payload + its trailing newline
|
|
120
|
+
}
|
|
121
|
+
resolve(out)
|
|
122
|
+
})
|
|
123
|
+
p.stdin.on('error', () => { /* EPIPE if git exits early; the close handler reports what arrived */ })
|
|
124
|
+
p.stdin.write(oids.join('\n') + '\n')
|
|
125
|
+
p.stdin.end()
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// TWO git subprocesses for the whole index (was F logs + V shows): one `--raw` log for the rename-followed
|
|
130
|
+
// chains, one `cat-file --batch` for every distinct blob not already memoized. Then a pure in-memory diff.
|
|
63
131
|
async function build(root: string, yatsuPaths: string[]): Promise<ScenarioIndex> {
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
for (
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
132
|
+
const chains = await fileChains(root, new Set(yatsuPaths))
|
|
133
|
+
const need = new Set<string>()
|
|
134
|
+
for (const chain of chains.values()) for (const v of chain) if (v.oid !== ZERO && !blockByOid.has(v.oid)) need.add(v.oid)
|
|
135
|
+
if (need.size) {
|
|
136
|
+
const blobs = await catFileBatch(root, [...need])
|
|
137
|
+
for (const [oid, src] of blobs) blockByOid.set(oid, blockContent(src))
|
|
70
138
|
}
|
|
139
|
+
const idx: ScenarioIndex = new Map()
|
|
140
|
+
for (const p of yatsuPaths) idx.set(p, scenarioCommits(chains.get(p) ?? [])) // every asked path gets an entry (empty if untracked)
|
|
71
141
|
return idx
|
|
72
142
|
}
|
|
73
143
|
|