spexcode 0.5.4 → 0.5.6
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/hooks/dispatch.sh +10 -0
- package/spec-cli/src/cli.ts +6 -5
- package/spec-cli/src/contract-filter.ts +73 -29
- package/spec-cli/src/git.ts +80 -40
- package/spec-cli/src/harness.ts +127 -8
- package/spec-cli/src/index.ts +9 -3
- package/spec-cli/src/materialize.ts +118 -49
- package/spec-cli/src/reviewSnapshot.ts +4 -0
- package/spec-cli/src/reviews.ts +10 -5
- package/spec-cli/src/sessions.ts +113 -9
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
- package/spec-dashboard/dist/assets/{App-C5vbTw8Q.js → App-B72LuS5I.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-C5X4Va3V.js +27 -0
- package/spec-dashboard/dist/assets/{EvalsPage-BS7ITcNo.js → EvalsPage-BTvJIW8Q.js} +2 -2
- package/spec-dashboard/dist/assets/IssuesPage-Bn94h_HQ.js +1 -0
- package/spec-dashboard/dist/assets/{MobileApp-DVLnk9hz.js → MobileApp-ClbtwZ1e.js} +2 -2
- package/spec-dashboard/dist/assets/{Modal-6mHq6fbZ.js → Modal-6l_QtCKF.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-CAY4S4g4.js → PageScroll-B2kxcqJJ.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-UQyzsTWN.js → ProjectsPage-C8IPsMKV.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-DKU4c1Z-.js → SessionInterface-B5jf7dW7.js} +11 -11
- package/spec-dashboard/dist/assets/SessionWindow-Dag_GiJB.js +1 -0
- package/spec-dashboard/dist/assets/{Settings-igR17pns.js → Settings-J3aibcXo.js} +1 -1
- package/spec-dashboard/dist/assets/{Thread-B-ZUarN1.js → Thread-Dg35J-Pu.js} +3 -3
- package/spec-dashboard/dist/assets/{TimelineChat-sc49Qj5d.js → TimelineChat-f0UF9fXq.js} +1 -1
- package/spec-dashboard/dist/assets/{data-B1ot4PF0.js → data-SNi0AmVT.js} +1 -1
- package/spec-dashboard/dist/assets/{index-BqBNCa1V.js → index-BUKLPN_4.js} +10 -10
- package/spec-dashboard/dist/index.html +1 -1
- 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/SessionWindow-zGwJaGbR.js +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spexcode",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,6 +43,16 @@ proj="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
|
43
43
|
rt="$(cd "$proj" 2>/dev/null && hp_runtime_dir)" || rt=""
|
|
44
44
|
slot="$(cd "$proj" 2>/dev/null && hp_tree_dir)" || slot=""
|
|
45
45
|
|
|
46
|
+
# A project transport can outlive the tree that installed it. The current tree's last successful materialize
|
|
47
|
+
# is the authority for whether its events are active. Before the v1 marker, an absent allowlist is the legacy
|
|
48
|
+
# shape; afterwards absence means this tree never successfully selected a harness and dispatch stays inert.
|
|
49
|
+
allowed="$slot/harnesses"
|
|
50
|
+
if [ -f "$allowed" ]; then
|
|
51
|
+
grep -Fxq "$harness" "$allowed" || exit 0
|
|
52
|
+
elif [ -f "$rt/harness-selection-v1" ]; then
|
|
53
|
+
exit 0
|
|
54
|
+
fi
|
|
55
|
+
|
|
46
56
|
# --- dispatch ---------------------------------------------------------------------------------------------
|
|
47
57
|
if [ -n "${SPEX_HOOK_MANIFEST:-}" ]; then
|
|
48
58
|
manifest="$SPEX_HOOK_MANIFEST"
|
package/spec-cli/src/cli.ts
CHANGED
|
@@ -1130,10 +1130,11 @@ if (cmd === 'serve') {
|
|
|
1130
1130
|
const ok = mark(() => s.markState(st, { proposal: flag('propose') as any, note: flag('note'), sessionId: sess }))
|
|
1131
1131
|
console.log(ok.ok ? `state -> ${st}${noteEcho(flag('note'))}` : ok.reason ?? noRecord())
|
|
1132
1132
|
} else if (sub === 'session-fail') {
|
|
1133
|
-
//
|
|
1134
|
-
|
|
1135
|
-
const
|
|
1136
|
-
|
|
1133
|
+
// StopFailure is one native source for the shared active-only turn-failure CAS. A declaration or explicit
|
|
1134
|
+
// stop that landed first is authoritative, just as it is for Codex notifications and headless exits.
|
|
1135
|
+
const { s, sess, mark } = await stateKit()
|
|
1136
|
+
const failed = mark(() => s.markTurnFailure(sess, 'claude turn failed'))
|
|
1137
|
+
console.log(failed.ok ? 'marked error' : failed.reason ?? 'noop (session is not live active)')
|
|
1137
1138
|
} else if (sub === 'session-turn-fail') {
|
|
1138
1139
|
// Headless adapters report an ephemeral turn's non-zero exit through this one shared CAS. A declaration
|
|
1139
1140
|
// that landed before teardown wins, so a late child close can never erase an agent-authored state.
|
|
@@ -1143,7 +1144,7 @@ if (cmd === 'serve') {
|
|
|
1143
1144
|
process.exit(2)
|
|
1144
1145
|
}
|
|
1145
1146
|
const { markHeadlessTurnFailure } = await import('./sessions.js')
|
|
1146
|
-
console.log(markHeadlessTurnFailure(sessionId, harness, exitCode) ? `marked error (${harness} ${exitCode})` : 'noop (
|
|
1147
|
+
console.log(markHeadlessTurnFailure(sessionId, harness, exitCode) ? `marked error (${harness} ${exitCode})` : 'noop (session is not live active)')
|
|
1147
1148
|
} else if (sub === 'session-idle') {
|
|
1148
1149
|
// the Notification(idle_prompt) hook marks its session (--session from the payload) idle when claude waits
|
|
1149
1150
|
// at its prompt. INFERRED, so guarded active-only: it no-ops unless the current status is exactly `active`,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from 'node:fs'
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync, existsSync } from 'node:fs'
|
|
2
2
|
import { join, relative } from 'node:path'
|
|
3
3
|
import { execFileSync } from 'node:child_process'
|
|
4
4
|
import { git } from './git.js'
|
|
5
5
|
import { writeManagedBlock, removeManagedBlock } from './harness.js'
|
|
6
|
+
import { encodeProject, runtimeRoot, treeSlotDir } from './layout.js'
|
|
6
7
|
|
|
7
8
|
// @@@ contract-filter ([[content-filter]]) - the answer for a MIXED-CONTENT contract file: a
|
|
8
9
|
// CLAUDE.md/AGENTS.md the HOST TRACKS — or has begun writing its OWN prose into — where "generate + ignore"
|
|
@@ -34,30 +35,54 @@ function commonDirOf(proj: string): string {
|
|
|
34
35
|
const filterDir = (common: string) => join(common, 'spexcode')
|
|
35
36
|
const shimPath = (common: string) => join(filterDir(common), 'contract-filter.sh')
|
|
36
37
|
const blockPath = (common: string) => join(filterDir(common), 'contract-block.md')
|
|
38
|
+
const rootPath = (common: string) => join(filterDir(common), 'contract-filter-root')
|
|
39
|
+
const bindingsPath = (common: string) => join(filterDir(common), 'contract-filter-bindings')
|
|
40
|
+
const treeFilterDir = (proj: string) => join(treeSlotDir(proj), 'contract-filter')
|
|
37
41
|
const attributesPath = (common: string) => join(common, 'info', 'attributes')
|
|
38
42
|
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
const registeredSlots = (proj: string) => git(['-C', proj, 'worktree', 'list', '--porcelain', '-z']).split('\0')
|
|
44
|
+
.filter((row) => row.startsWith('worktree ')).map((row) =>
|
|
45
|
+
join(runtimeRoot(proj), 'trees', encodeProject(row.slice('worktree '.length))))
|
|
46
|
+
const legacyFilterTree = (proj: string) => registeredSlots(proj)
|
|
47
|
+
.some((slot) => existsSync(join(slot, 'content-hash')) && !existsSync(join(slot, 'contract-filter-v2')))
|
|
48
|
+
|
|
49
|
+
export type ContractFilterPayload = { file: string; content: string }
|
|
50
|
+
export type ContractFilterBinding = { file: string; start: string; end: string; legacy?: boolean }
|
|
51
|
+
|
|
52
|
+
// The common shim both filter directions run through. Pure shell/awk (no node boot on git's hot path), it
|
|
53
|
+
// resolves the invoking checkout to that tree's payload before mirroring managed-block normalization:
|
|
41
54
|
// clean : drop the sentinel block (+ the blank line smudge printed before it) → the pristine host prose.
|
|
42
|
-
// smudge: clean first (
|
|
43
|
-
// blank line + START + the contract-block file's content + END.
|
|
55
|
+
// smudge: clean first (a block already in the index can never double-inject), then append the tree payload.
|
|
44
56
|
// Byte-exactness holds for text ending in exactly one newline (git's own well-formed-text shape); a pristine
|
|
45
57
|
// file ending in zero or 2+ newlines is normalized to one on the first round-trip and stable after.
|
|
46
58
|
const SHIM = `#!/usr/bin/env bash
|
|
47
|
-
# spexcode
|
|
48
|
-
#
|
|
59
|
+
# spexcode managed-text filter (generated by spex materialize; see [[content-filter]]).
|
|
60
|
+
# One common driver selects the payload belonging to the Git checkout that invoked it.
|
|
49
61
|
set -u
|
|
50
|
-
mode="\${1:?usage: contract-filter.sh smudge|clean}"
|
|
62
|
+
mode="\${1:?usage: contract-filter.sh smudge|clean <path>}"
|
|
63
|
+
path="\${2:?usage: contract-filter.sh smudge|clean <path>}"
|
|
51
64
|
here="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
52
|
-
|
|
65
|
+
binding="$(awk -F '\t' -v p="$path" '$1 == p { print $2 "\t" $3 "\t" $4; exit }' "$here/contract-filter-bindings" 2>/dev/null)"
|
|
66
|
+
[ -n "$binding" ] || { cat; exit 0; }
|
|
67
|
+
start="\${binding%%$'\t'*}"; rest="\${binding#*$'\t'}"
|
|
68
|
+
end="\${rest%%$'\t'*}"; legacy="\${rest#*$'\t'}"
|
|
69
|
+
top="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
|
70
|
+
root="$(cat "$here/contract-filter-root" 2>/dev/null || true)"
|
|
71
|
+
key="$(printf '%s' "$top" | sed 's#[/.]#-#g')"
|
|
72
|
+
manifest="$root/trees/$key/contract-filter/manifest"
|
|
73
|
+
payload="$(awk -F '\t' -v p="$path" '$1 == p { print $2; exit }' "$manifest" 2>/dev/null)"
|
|
74
|
+
# A pre-v2 tree has only the old common payload. Once that tree materializes, its marker makes a missing
|
|
75
|
+
# per-file payload mean identity (for example, AGENTS.md in a Claude-only tree), never global fallback.
|
|
76
|
+
marker="$root/trees/$key/contract-filter-v2"
|
|
77
|
+
if [ ! -r "$payload" ] && [ ! -f "$marker" ] && [ "$legacy" = 1 ] && [ -r "$here/contract-block.md" ]; then payload="$here/contract-block.md"; fi
|
|
53
78
|
strip() {
|
|
54
|
-
awk 'BEGIN { n = 0 }
|
|
79
|
+
awk -v sline="$start" -v eline="$end" 'BEGIN { n = 0 }
|
|
55
80
|
{ lines[n++] = $0 }
|
|
56
81
|
END {
|
|
57
82
|
s = -1; e = -1
|
|
58
83
|
for (i = 0; i < n; i++) {
|
|
59
|
-
if (lines[i] ==
|
|
60
|
-
if (lines[i] ==
|
|
84
|
+
if (lines[i] == sline && s < 0) s = i
|
|
85
|
+
if (lines[i] == eline && s >= 0 && e < 0) e = i
|
|
61
86
|
}
|
|
62
87
|
if (s >= 0 && e >= s) {
|
|
63
88
|
a = s; while (a > 0 && lines[a-1] == "") a--
|
|
@@ -66,24 +91,22 @@ strip() {
|
|
|
66
91
|
for (i = 0; i < n; i++) if (i < a || i > b) lines[j++] = lines[i]
|
|
67
92
|
n = j
|
|
68
93
|
}
|
|
69
|
-
# NO leading-blank strip: dropping a..b (block + its surrounding blanks) can never CREATE a leading
|
|
70
|
-
# blank, and a host file that BEGINS with blank lines must keep them — clean(smudge(x)) == x.
|
|
71
94
|
for (i = 0; i < n; i++) print lines[i]
|
|
72
95
|
}'
|
|
73
96
|
}
|
|
74
97
|
case "$mode" in
|
|
75
98
|
clean) strip ;;
|
|
76
99
|
smudge)
|
|
77
|
-
if [ ! -r "$
|
|
78
|
-
strip | awk -v b="$
|
|
100
|
+
if [ ! -r "$payload" ]; then strip; exit 0; fi
|
|
101
|
+
strip | awk -v b="$payload" -v sline="$start" -v eline="$end" 'BEGIN { n = 0 }
|
|
79
102
|
{ lines[n++] = $0 }
|
|
80
103
|
END {
|
|
81
|
-
while (n > 0 && lines[n-1] == "") n--
|
|
104
|
+
while (n > 0 && lines[n-1] == "") n--
|
|
82
105
|
for (i = 0; i < n; i++) print lines[i]
|
|
83
106
|
if (n > 0) print ""
|
|
84
|
-
print
|
|
107
|
+
print sline
|
|
85
108
|
while ((getline l < b) > 0) print l
|
|
86
|
-
print
|
|
109
|
+
print eline
|
|
87
110
|
}' ;;
|
|
88
111
|
*) echo "contract-filter.sh: unknown mode $mode" >&2; exit 1 ;;
|
|
89
112
|
esac
|
|
@@ -92,25 +115,37 @@ esac
|
|
|
92
115
|
// edge ①: the command git runs is a tolerant wrapper — the shim path is an ARGUMENT ($0), and a missing/
|
|
93
116
|
// unreadable shim degrades to `cat` (identity) instead of a per-operation fatal.
|
|
94
117
|
const filterCmd = (shim: string, mode: 'smudge' | 'clean') =>
|
|
95
|
-
`sh -c 'test -r "$0" && exec bash "$0" ${mode} || exec cat' '${shim.replace(/'/g, `'\\''`)}'`
|
|
118
|
+
`sh -c 'test -r "$0" && exec bash "$0" ${mode} "$1" || exec cat' '${shim.replace(/'/g, `'\\''`)}' %f`
|
|
96
119
|
|
|
97
120
|
// plant (or refresh) the filter for the given contract files (tracked, or untracked-with-host-content —
|
|
98
121
|
// pre-armed): the shim + the block content it smudges, the per-clone git config, and the attribute lines
|
|
99
122
|
// binding each file to the filter. Idempotent — every write is a full replace. `contract` is the assembled
|
|
100
123
|
// block body (guide + surface:system). settleIndexStat skips untracked entries (no index blob) by design.
|
|
101
|
-
export function plantContractFilter(proj: string,
|
|
124
|
+
export function plantContractFilter(proj: string, payloads: ContractFilterPayload[], bindings: ContractFilterBinding[]): void {
|
|
102
125
|
const common = commonDirOf(proj)
|
|
103
126
|
mkdirSync(filterDir(common), { recursive: true })
|
|
104
|
-
writeFileSync(blockPath(common), contract.endsWith('\n') ? contract : `${contract}\n`) // edge ②: the shim's smudge source, refreshed with the materialize
|
|
105
127
|
writeFileSync(shimPath(common), SHIM)
|
|
106
128
|
chmodSync(shimPath(common), 0o755)
|
|
107
129
|
git(['-C', proj, 'config', 'filter.spexcode.smudge', filterCmd(shimPath(common), 'smudge')])
|
|
108
130
|
git(['-C', proj, 'config', 'filter.spexcode.clean', filterCmd(shimPath(common), 'clean')])
|
|
109
|
-
|
|
110
|
-
const
|
|
131
|
+
writeFileSync(bindingsPath(common), bindings.map((b) => `${b.file}\t${b.start}\t${b.end}\t${b.legacy ? 1 : 0}`).join('\n') + '\n')
|
|
132
|
+
const dir = treeFilterDir(proj)
|
|
133
|
+
rmSync(dir, { recursive: true, force: true }); mkdirSync(dir, { recursive: true })
|
|
134
|
+
const manifest: string[] = []
|
|
135
|
+
for (const [i, payload] of payloads.entries()) {
|
|
136
|
+
const target = join(dir, String(i))
|
|
137
|
+
writeFileSync(target, payload.content.endsWith('\n') ? payload.content : `${payload.content}\n`)
|
|
138
|
+
manifest.push(`${payload.file}\t${target}`)
|
|
139
|
+
}
|
|
140
|
+
const manifestPath = join(dir, 'manifest')
|
|
141
|
+
writeFileSync(manifestPath, manifest.join('\n') + (manifest.length ? '\n' : ''))
|
|
142
|
+
writeFileSync(rootPath(common), `${runtimeRoot(proj)}\n`)
|
|
143
|
+
// Attribute patterns are checkout-relative; the stable binding set is safe in the common git dir because
|
|
144
|
+
// the driver selects a payload from the invoking checkout's tree slot.
|
|
145
|
+
const entries = bindings.map((b) => `/${b.file} filter=spexcode`).sort().join('\n')
|
|
111
146
|
mkdirSync(join(common, 'info'), { recursive: true })
|
|
112
147
|
writeManagedBlock(attributesPath(common), entries, ['# ', ''])
|
|
113
|
-
settleIndexStat(proj,
|
|
148
|
+
settleIndexStat(proj, payloads.map((p) => join(proj, p.file)))
|
|
114
149
|
}
|
|
115
150
|
|
|
116
151
|
// settle the index STAT for each file — the famous filtered-path phantom-`M`: git cannot verify a
|
|
@@ -139,15 +174,24 @@ export function settleIndexStat(proj: string, files: string[]): void {
|
|
|
139
174
|
// the full inverse (edge ③ — call AFTER the managed blocks left the working files): attribute lines out,
|
|
140
175
|
// config keys unset, shim + block content removed. `<common>/spexcode/` may host other spexcode data
|
|
141
176
|
// (evidence blobs), so only OUR two files go, never the dir.
|
|
142
|
-
export function removeContractFilter(proj: string): void {
|
|
177
|
+
export function removeContractFilter(proj: string, files: string[] = [], final = false): void {
|
|
143
178
|
let common: string
|
|
144
179
|
try { common = commonDirOf(proj) } catch { return } // not a git repo → nothing was ever planted
|
|
180
|
+
try { rmSync(treeFilterDir(proj), { recursive: true, force: true }) } catch { /* inaccessible tree */ }
|
|
181
|
+
settleIndexStat(proj, files)
|
|
182
|
+
const anotherPayload = registeredSlots(proj).some((slot) => existsSync(join(slot, 'contract-filter', 'manifest')))
|
|
183
|
+
const legacyTree = legacyFilterTree(proj)
|
|
184
|
+
if (!legacyTree) rmSync(blockPath(common), { force: true })
|
|
185
|
+
if (!final && (anotherPayload || legacyTree)) return
|
|
145
186
|
removeManagedBlock(attributesPath(common), ['# ', ''], true)
|
|
146
187
|
for (const key of ['filter.spexcode.smudge', 'filter.spexcode.clean']) {
|
|
147
|
-
try { git(['-C', proj, 'config', '--unset', key]) } catch { /* not set — already clean */ }
|
|
188
|
+
try { git(['-C', proj, 'config', '--unset-all', key]) } catch { /* not set — already clean */ }
|
|
148
189
|
}
|
|
149
|
-
|
|
150
|
-
|
|
190
|
+
for (const path of [shimPath(common), blockPath(common), rootPath(common), bindingsPath(common)]) rmSync(path, { force: true })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function retireLegacyContractBlock(proj: string): void {
|
|
194
|
+
if (!legacyFilterTree(proj)) rmSync(blockPath(commonDirOf(proj)), { force: true })
|
|
151
195
|
}
|
|
152
196
|
|
|
153
197
|
// is the filter currently planted? (the assert-side probe tests use; cheap: one config read)
|
package/spec-cli/src/git.ts
CHANGED
|
@@ -360,9 +360,36 @@ type EventStreamRequest = {
|
|
|
360
360
|
}
|
|
361
361
|
|
|
362
362
|
type EventPathMemo = EventCacheLocation & {
|
|
363
|
-
common: string; shallowPath: string; grafts: string; shallow: string
|
|
363
|
+
common: string; shallowPath: string; grafts: string; shallow: string
|
|
364
|
+
replacementStorage: string; replacements: string
|
|
364
365
|
}
|
|
365
366
|
const eventPathMemo = new Map<string, EventPathMemo>()
|
|
367
|
+
function replacementStorageIdentity(common: string): string {
|
|
368
|
+
const hash = createHash('sha256')
|
|
369
|
+
const addTree = (root: string, rel: string) => {
|
|
370
|
+
if (!existsSync(root)) return
|
|
371
|
+
const stack = [{ dir: root, rel }]
|
|
372
|
+
while (stack.length) {
|
|
373
|
+
const current = stack.pop()!
|
|
374
|
+
const entries = readdirSync(current.dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))
|
|
375
|
+
for (const entry of entries) {
|
|
376
|
+
const path = join(current.dir, entry.name)
|
|
377
|
+
const name = `${current.rel}/${entry.name}`
|
|
378
|
+
if (entry.isDirectory()) stack.push({ dir: path, rel: name })
|
|
379
|
+
else hash.update(`\0${name}\0`).update(readFileSync(path))
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
addTree(join(common, 'refs', 'replace'), 'refs/replace')
|
|
384
|
+
for (const name of ['packed-refs']) {
|
|
385
|
+
const path = join(common, name)
|
|
386
|
+
if (existsSync(path)) hash.update(`\0${name}\0`).update(readFileSync(path))
|
|
387
|
+
}
|
|
388
|
+
// Reftable is opaque here by design: its bytes are only an invalidation signal. Git remains the one
|
|
389
|
+
// parser and supplies the canonical refs/replace targets when those bytes change.
|
|
390
|
+
addTree(join(common, 'reftable'), 'reftable')
|
|
391
|
+
return hash.digest('hex')
|
|
392
|
+
}
|
|
366
393
|
function eventCacheLocation(root: string): EventCacheLocation {
|
|
367
394
|
const rootId = rootKey(root), old = eventPathMemo.get(rootId)
|
|
368
395
|
const common = old?.common ?? git(['-C', root, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
|
|
@@ -370,7 +397,10 @@ function eventCacheLocation(root: string): EventCacheLocation {
|
|
|
370
397
|
const shallow = existsSync(shallowPath) ? readFileSync(shallowPath, 'utf8') : 'unshallow'
|
|
371
398
|
const graftsPath = join(common, 'info', 'grafts')
|
|
372
399
|
const grafts = existsSync(graftsPath) ? readFileSync(graftsPath, 'utf8') : ''
|
|
373
|
-
const
|
|
400
|
+
const replacementStorage = replacementStorageIdentity(common)
|
|
401
|
+
const replacements = old?.replacementStorage === replacementStorage
|
|
402
|
+
? old.replacements
|
|
403
|
+
: git(['-C', root, 'for-each-ref', 'refs/replace', '--format=%(refname) %(objectname)'])
|
|
374
404
|
const objectFormat = gitObjectFormat(root)
|
|
375
405
|
if (old && old.shallow === shallow && old.grafts === grafts && old.replacements === replacements && old.objectFormat === objectFormat)
|
|
376
406
|
return { path: old.path, identity: old.identity, objectFormat }
|
|
@@ -382,7 +412,7 @@ function eventCacheLocation(root: string): EventCacheLocation {
|
|
|
382
412
|
const gitDir = gitDirOf(root)
|
|
383
413
|
const storeIdentity = gitDir === root && common === root ? join(common, '.git') : common
|
|
384
414
|
const path = join(projectRuntimeRoot(storeIdentity), `${EVENT_CACHE_SCHEMA}-${identity}.ndjson`)
|
|
385
|
-
eventPathMemo.set(rootId, { common, shallowPath, grafts, shallow, replacements, path, identity, objectFormat })
|
|
415
|
+
eventPathMemo.set(rootId, { common, shallowPath, grafts, shallow, replacementStorage, replacements, path, identity, objectFormat })
|
|
386
416
|
return { path, identity, objectFormat }
|
|
387
417
|
}
|
|
388
418
|
function emptyEventCache(): EventCache {
|
|
@@ -1002,38 +1032,45 @@ function canonicalPathProjector(
|
|
|
1002
1032
|
}
|
|
1003
1033
|
|
|
1004
1034
|
type SharedIndexInputs = {
|
|
1005
|
-
|
|
1006
|
-
|
|
1035
|
+
allPaths: Set<string>
|
|
1036
|
+
specPaths: Set<string>
|
|
1037
|
+
topology: TopologyProjection
|
|
1007
1038
|
historyOut: string
|
|
1008
1039
|
driftOut: string
|
|
1009
1040
|
mergeIndex: MergeHistoryEvents
|
|
1010
1041
|
}
|
|
1011
1042
|
|
|
1043
|
+
type TopologyProjection = {
|
|
1044
|
+
order: Map<string, number>
|
|
1045
|
+
parents: Map<string, string[]>
|
|
1046
|
+
reachable: Set<string>
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1012
1049
|
async function buildIndex(root: string, tip: string, transient: boolean, useCache = true, shared?: SharedIndexInputs): Promise<HistoryIndex> {
|
|
1013
1050
|
const versions = new Map<string, Version[]>()
|
|
1014
1051
|
const stats = new Map<string, Map<string, DiffStat>>()
|
|
1015
1052
|
const mergeVersions = new Set<string>()
|
|
1016
1053
|
const commitVersions = new Map<string, Version>()
|
|
1017
1054
|
const commitOrder = new Map<string, number>()
|
|
1018
|
-
const
|
|
1019
|
-
|
|
1020
|
-
|
|
1055
|
+
const rawVersions = new Map<string, Version[]>()
|
|
1056
|
+
const rawStats = new Map<string, Map<string, DiffStat>>()
|
|
1057
|
+
let currentPaths: Set<string>
|
|
1058
|
+
let topology: TopologyProjection
|
|
1059
|
+
if (shared) {
|
|
1060
|
+
currentPaths = shared.specPaths
|
|
1061
|
+
topology = shared.topology
|
|
1062
|
+
} else {
|
|
1063
|
+
const [tipPathsOut, topologyOut] = await Promise.all([
|
|
1021
1064
|
strictEventGit(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--name-only', tip, '--', '.spec']),
|
|
1022
1065
|
strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
|
|
1023
1066
|
])
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
const currentPaths = new Set(tipPathsOut.split('\0').filter((path) => path.startsWith('.spec/')))
|
|
1027
|
-
const renamesByFrom = new Map<string, { hash: string; to: string }[]>()
|
|
1028
|
-
const topologyOrd = new Map<string, number>(), topologyParents = new Map<string, string[]>()
|
|
1029
|
-
let topologyPosition = 0
|
|
1030
|
-
for (const line of topologyOut.trim().split('\n')) {
|
|
1031
|
-
if (!line) continue
|
|
1032
|
-
const [hash, ...parents] = line.split(' ')
|
|
1033
|
-
topologyOrd.set(hash, topologyPosition++)
|
|
1034
|
-
topologyParents.set(hash, parents)
|
|
1067
|
+
currentPaths = new Set(tipPathsOut.split('\0').filter((path) => path.startsWith('.spec/')))
|
|
1068
|
+
topology = topologyProjection(topologyOut)
|
|
1035
1069
|
}
|
|
1036
|
-
const
|
|
1070
|
+
const renamesByFrom = new Map<string, { hash: string; to: string }[]>()
|
|
1071
|
+
const topologyOrd = topology.order
|
|
1072
|
+
const topologyParents = topology.parents
|
|
1073
|
+
const topologyReachable = topology.reachable
|
|
1037
1074
|
const out = shared?.historyOut ?? await eventStream(root, tip,
|
|
1038
1075
|
indexEventRequests(root, tip, topologyOrd, topologyReachable).numstat, !transient, useCache)
|
|
1039
1076
|
if (!out) return { versions, stats, mergeVersions }
|
|
@@ -1363,23 +1400,22 @@ async function buildDriftIndex(root: string, tip: string, transient: boolean, us
|
|
|
1363
1400
|
const acks = new Map<string, Set<string>>(), selfAcks = new Map<string, Set<string>>(), specNodes = new Map<string, Set<string>>()
|
|
1364
1401
|
const ackCandidates = new Map<string, Set<string>>(), trees = new Map<string, string>()
|
|
1365
1402
|
const idx: DriftIndex = { tip, ord, parents, fileEvents, lineageEvents, lineageKeys: (path) => [path], resolutionEvents: new Map(), acks, selfAcks, specNodes, anc: new Map() }
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1403
|
+
let currentPaths: Set<string>
|
|
1404
|
+
let topology: TopologyProjection
|
|
1405
|
+
if (shared) {
|
|
1406
|
+
currentPaths = shared.allPaths
|
|
1407
|
+
topology = shared.topology
|
|
1408
|
+
} else {
|
|
1409
|
+
const [tipPathsOut, topologyOut] = await Promise.all([
|
|
1410
|
+
strictEventGit(['-C', root, 'ls-tree', '-r', '-z', '--name-only', tip]),
|
|
1370
1411
|
strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
|
|
1371
1412
|
])
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
let topologyPosition = 0
|
|
1375
|
-
for (const line of topology.trim().split('\n')) {
|
|
1376
|
-
const [hash, ...parentList] = line.split(' ')
|
|
1377
|
-
if (hash) {
|
|
1378
|
-
topologyOrder.set(hash, topologyPosition++)
|
|
1379
|
-
topologyParents.set(hash, parentList)
|
|
1380
|
-
topologyReachable.add(hash)
|
|
1381
|
-
}
|
|
1413
|
+
currentPaths = new Set(tipPathsOut.split('\0').filter(Boolean))
|
|
1414
|
+
topology = topologyProjection(topologyOut)
|
|
1382
1415
|
}
|
|
1416
|
+
const topologyOrder = topology.order
|
|
1417
|
+
const topologyParents = topology.parents
|
|
1418
|
+
const topologyReachable = topology.reachable
|
|
1383
1419
|
// Numstat is the immutable identity event: unlike a name-only stream it records both sides of a rename,
|
|
1384
1420
|
// allowing the same event-scoped projection used by spec history to follow forks and reject path reuse.
|
|
1385
1421
|
const out = shared?.driftOut ?? await eventStream(root, tip,
|
|
@@ -1507,16 +1543,17 @@ export function driftIndex(root: string, tip = 'HEAD'): Promise<DriftIndex> {
|
|
|
1507
1543
|
return p
|
|
1508
1544
|
}
|
|
1509
1545
|
|
|
1510
|
-
function topologyProjection(out: string):
|
|
1511
|
-
const order = new Map<string, number>(), reachable = new Set<string>()
|
|
1546
|
+
function topologyProjection(out: string): TopologyProjection {
|
|
1547
|
+
const order = new Map<string, number>(), parents = new Map<string, string[]>(), reachable = new Set<string>()
|
|
1512
1548
|
let position = 0
|
|
1513
1549
|
for (const line of out.trim().split('\n')) {
|
|
1514
|
-
const hash = line.split(' '
|
|
1550
|
+
const [hash, ...parentList] = line.split(' ')
|
|
1515
1551
|
if (!hash) continue
|
|
1516
1552
|
order.set(hash, position++)
|
|
1553
|
+
parents.set(hash, parentList)
|
|
1517
1554
|
reachable.add(hash)
|
|
1518
1555
|
}
|
|
1519
|
-
return { order, reachable }
|
|
1556
|
+
return { order, parents, reachable }
|
|
1520
1557
|
}
|
|
1521
1558
|
|
|
1522
1559
|
async function buildIndexPair(root: string, tip: string, transient: boolean, useCache = true): Promise<[HistoryIndex, DriftIndex]> {
|
|
@@ -1525,11 +1562,14 @@ async function buildIndexPair(root: string, tip: string, transient: boolean, use
|
|
|
1525
1562
|
strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
|
|
1526
1563
|
])
|
|
1527
1564
|
const topology = topologyProjection(topologyOut)
|
|
1565
|
+
const allPaths = new Set(allPathsOut.split('\0').filter(Boolean))
|
|
1566
|
+
const specPaths = new Set([...allPaths].filter((path) => path.startsWith('.spec/')))
|
|
1528
1567
|
const requests = indexEventRequests(root, tip, topology.order, topology.reachable)
|
|
1529
1568
|
const streams = await deriveEventStreams(root, tip, EVENT_STREAM_KINDS.map((kind) => requests[kind]), !transient, useCache)
|
|
1530
1569
|
const shared: SharedIndexInputs = {
|
|
1531
|
-
|
|
1532
|
-
|
|
1570
|
+
allPaths,
|
|
1571
|
+
specPaths,
|
|
1572
|
+
topology,
|
|
1533
1573
|
historyOut: streams.get('numstat') ?? '',
|
|
1534
1574
|
driftOut: streams.get('drift-numstat') ?? '',
|
|
1535
1575
|
mergeIndex: parseMergeHistoryEvents(streams.get('merge') ?? ''),
|