@spexcode/spec-core 0.6.2 → 0.6.3
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/dist/anchors.d.ts +94 -0
- package/dist/anchors.js +730 -0
- package/dist/git.d.ts +166 -0
- package/dist/git.js +2736 -0
- package/dist/graph.d.ts +34 -0
- package/dist/graph.js +237 -0
- package/dist/graphDelta.d.ts +45 -0
- package/dist/graphDelta.js +84 -0
- package/dist/harness-identity.d.ts +31 -0
- package/dist/harness-identity.js +20 -0
- package/dist/identity-presets.d.ts +152 -0
- package/dist/identity-presets.js +132 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +19 -0
- package/dist/layout.d.ts +183 -0
- package/dist/layout.js +548 -0
- package/dist/process-identity.d.ts +37 -0
- package/dist/process-identity.js +214 -0
- package/dist/project-identity.d.ts +12 -0
- package/dist/project-identity.js +71 -0
- package/dist/project-store.d.ts +3 -0
- package/dist/project-store.js +14 -0
- package/dist/resilience.d.ts +2 -0
- package/dist/resilience.js +40 -0
- package/dist/review/index.d.ts +3 -0
- package/{src → dist}/review/index.js +3 -3
- package/dist/review/reviewFilters.d.ts +77 -0
- package/dist/review/reviewFilters.js +308 -0
- package/dist/review/reviewQuery.d.ts +66 -0
- package/dist/review/reviewQuery.js +180 -0
- package/dist/review/session.d.ts +4 -0
- package/dist/review/session.js +8 -0
- package/dist/reviewSnapshot.d.ts +15 -0
- package/dist/reviewSnapshot.js +12 -0
- package/dist/root-lru.d.ts +4 -0
- package/{src/root-lru.ts → dist/root-lru.js} +26 -30
- package/dist/specs.d.ts +117 -0
- package/dist/specs.js +489 -0
- package/package.json +19 -8
- package/src/anchors.ts +0 -728
- package/src/git.ts +0 -2556
- package/src/graph.ts +0 -251
- package/src/harness-identity.ts +0 -26
- package/src/identity-presets.d.ts +0 -13
- package/src/identity-presets.js +0 -138
- package/src/index.ts +0 -20
- package/src/layout.ts +0 -637
- package/src/process-identity.ts +0 -207
- package/src/project-identity.ts +0 -73
- package/src/project-store.ts +0 -17
- package/src/resilience.ts +0 -41
- package/src/review/reviewFilters.js +0 -324
- package/src/review/reviewQuery.js +0 -174
- package/src/review/session.js +0 -13
- package/src/reviewSnapshot.ts +0 -28
- package/src/specs.ts +0 -498
package/dist/git.js
ADDED
|
@@ -0,0 +1,2736 @@
|
|
|
1
|
+
import { execFileSync, execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdirSync, rmSync, renameSync, openSync, closeSync, accessSync, constants } from 'node:fs';
|
|
4
|
+
import { join, isAbsolute, resolve, delimiter } from 'node:path';
|
|
5
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
6
|
+
import { projectRuntimeRoot } from './project-store.js';
|
|
7
|
+
import { rootSlots, touchRoot as touchRootLru } from './root-lru.js';
|
|
8
|
+
import { processStartToken } from './process-identity.js';
|
|
9
|
+
const US = '\x1f', RS = '\x1e';
|
|
10
|
+
// @@@ bounded graph git children - a git child that never exits (wedged fs, a hijacked PATH git, a dead
|
|
11
|
+
// network mount) must not pin its awaiter forever: [[graph-cache]]'s settle guarantee starts at this seam.
|
|
12
|
+
// Every shared helper passes a generous timeout with SIGKILL. A graph build additionally carries one fixed
|
|
13
|
+
// permit pool through AsyncLocalStorage, so corpus-wide Promise.all fanout queues here before spawn rather
|
|
14
|
+
// than materializing one process per worktree/eval. Calls outside that build context remain unconstrained.
|
|
15
|
+
const GIT_TIMEOUT_MS = Number(process.env.SPEXCODE_GIT_TIMEOUT_MS || 120000);
|
|
16
|
+
const GIT_SYNC_MAX_BUFFER = 1 << 27;
|
|
17
|
+
export const BOARD_GIT_CONCURRENCY = 4;
|
|
18
|
+
const gitByPath = new Map();
|
|
19
|
+
export function gitBinary(env = process.env) {
|
|
20
|
+
const path = env.PATH || '';
|
|
21
|
+
const known = gitByPath.get(path);
|
|
22
|
+
if (known) {
|
|
23
|
+
try {
|
|
24
|
+
accessSync(known, constants.X_OK);
|
|
25
|
+
return known;
|
|
26
|
+
}
|
|
27
|
+
catch { }
|
|
28
|
+
}
|
|
29
|
+
for (const dir of path.split(delimiter)) {
|
|
30
|
+
const candidate = resolve(dir || '.', 'git');
|
|
31
|
+
try {
|
|
32
|
+
accessSync(candidate, constants.X_OK);
|
|
33
|
+
gitByPath.set(path, candidate);
|
|
34
|
+
return candidate;
|
|
35
|
+
}
|
|
36
|
+
catch { }
|
|
37
|
+
}
|
|
38
|
+
throw new Error('git executable not found on PATH');
|
|
39
|
+
}
|
|
40
|
+
const gitBuild = new AsyncLocalStorage();
|
|
41
|
+
// @@@ a build's git children run on a bounded pack footprint - git sizes its mmap window, its mmap ceiling
|
|
42
|
+
// and its delta-base cache for a process that owns the machine. A graph build's heaviest walks each mapped
|
|
43
|
+
// well over a hundred megabytes of pack for output measured in kilobytes, and those children land inside the
|
|
44
|
+
// build's own memory platform. Capping the three makes the same walks run in a fraction of the resident set
|
|
45
|
+
// for a fraction of a second more. This is a RESOURCE boundary, never a semantic one — output, exit status
|
|
46
|
+
// and stderr are byte-identical under every setting — and it is scoped to the build context, so ordinary
|
|
47
|
+
// CLI/API git keeps git's defaults. It is also content-blind: the transport knows pack sizing, never which
|
|
48
|
+
// walk a caller is doing.
|
|
49
|
+
const BUILD_GIT_LIMITS = [
|
|
50
|
+
'-c', 'core.packedGitWindowSize=1m',
|
|
51
|
+
'-c', 'core.packedGitLimit=32m',
|
|
52
|
+
'-c', 'core.deltaBaseCacheLimit=1m',
|
|
53
|
+
];
|
|
54
|
+
const withBuildLimits = (args) => (gitBuild.getStore() ? [...BUILD_GIT_LIMITS, ...args] : args);
|
|
55
|
+
export function gitAbortError() {
|
|
56
|
+
return Object.assign(new Error('The operation was aborted'), { name: 'AbortError', code: 'ABORT_ERR' });
|
|
57
|
+
}
|
|
58
|
+
function gitPermitPool(limit) {
|
|
59
|
+
let active = 0;
|
|
60
|
+
const waiting = [];
|
|
61
|
+
const releasePermit = () => {
|
|
62
|
+
let released = false;
|
|
63
|
+
return () => {
|
|
64
|
+
if (released)
|
|
65
|
+
return;
|
|
66
|
+
released = true;
|
|
67
|
+
active--;
|
|
68
|
+
drain();
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
const drain = () => {
|
|
72
|
+
while (active < limit && waiting.length) {
|
|
73
|
+
const waiter = waiting.shift();
|
|
74
|
+
waiter.signal.removeEventListener('abort', waiter.onAbort);
|
|
75
|
+
if (waiter.signal.aborted) {
|
|
76
|
+
waiter.reject(gitAbortError());
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
active++;
|
|
80
|
+
waiter.resolve(releasePermit());
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
acquire(signal) {
|
|
85
|
+
if (signal.aborted)
|
|
86
|
+
return Promise.reject(gitAbortError());
|
|
87
|
+
if (active < limit) {
|
|
88
|
+
active++;
|
|
89
|
+
return Promise.resolve(releasePermit());
|
|
90
|
+
}
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const waiter = {
|
|
93
|
+
signal,
|
|
94
|
+
resolve,
|
|
95
|
+
reject,
|
|
96
|
+
onAbort: () => {
|
|
97
|
+
const index = waiting.indexOf(waiter);
|
|
98
|
+
if (index >= 0)
|
|
99
|
+
waiting.splice(index, 1);
|
|
100
|
+
reject(gitAbortError());
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
waiting.push(waiter);
|
|
104
|
+
signal.addEventListener('abort', waiter.onAbort, { once: true });
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
// A board build owns one abort signal. Async git calls inherit it without every graph layer growing a
|
|
110
|
+
// cancellation parameter; aborting the build therefore reaches every active child and queued permit below
|
|
111
|
+
// the graph seam. The pool is created here, so ordinary CLI/API git calls never share or wait on it.
|
|
112
|
+
export function withGitAbortSignal(signal, run) {
|
|
113
|
+
return gitBuild.run({ signal, permits: gitPermitPool(BOARD_GIT_CONCURRENCY) }, run);
|
|
114
|
+
}
|
|
115
|
+
const inheritedContext = () => gitBuild.getStore();
|
|
116
|
+
export function currentGitBuildAbortSignal() {
|
|
117
|
+
return inheritedContext()?.signal;
|
|
118
|
+
}
|
|
119
|
+
function warnIfTimedOut(e, args) {
|
|
120
|
+
if (e?.code === 'ETIMEDOUT' || e?.spexcodeGitTimeout === true)
|
|
121
|
+
console.warn(`spec-cli: git ${args.slice(0, 6).join(' ')}… killed after ${GIT_TIMEOUT_MS}ms — child never exited`);
|
|
122
|
+
}
|
|
123
|
+
// strip git's hook-exported env (GIT_DIR etc.) so every call discovers the repo from the filesystem.
|
|
124
|
+
export function git(args) {
|
|
125
|
+
const env = { ...process.env };
|
|
126
|
+
delete env.GIT_DIR;
|
|
127
|
+
delete env.GIT_WORK_TREE;
|
|
128
|
+
delete env.GIT_INDEX_FILE;
|
|
129
|
+
delete env.GIT_OBJECT_DIRECTORY;
|
|
130
|
+
try {
|
|
131
|
+
return execFileSync(gitBinary(env), withBuildLimits(args), {
|
|
132
|
+
encoding: 'utf8',
|
|
133
|
+
env,
|
|
134
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
135
|
+
timeout: GIT_TIMEOUT_MS,
|
|
136
|
+
killSignal: 'SIGKILL',
|
|
137
|
+
maxBuffer: GIT_SYNC_MAX_BUFFER,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
warnIfTimedOut(e, args);
|
|
142
|
+
throw e;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function gitBuffer(args, input) {
|
|
146
|
+
const env = { ...process.env };
|
|
147
|
+
delete env.GIT_DIR;
|
|
148
|
+
delete env.GIT_WORK_TREE;
|
|
149
|
+
delete env.GIT_INDEX_FILE;
|
|
150
|
+
delete env.GIT_OBJECT_DIRECTORY;
|
|
151
|
+
try {
|
|
152
|
+
return execFileSync(gitBinary(env), withBuildLimits(args), {
|
|
153
|
+
input,
|
|
154
|
+
env,
|
|
155
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
156
|
+
timeout: GIT_TIMEOUT_MS,
|
|
157
|
+
killSignal: 'SIGKILL',
|
|
158
|
+
maxBuffer: GIT_SYNC_MAX_BUFFER,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
warnIfTimedOut(e, args);
|
|
163
|
+
throw e;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const gitObjectFormatMemo = new Map();
|
|
167
|
+
export function gitObjectFormat(root) {
|
|
168
|
+
const key = rootKey(root);
|
|
169
|
+
const hit = gitObjectFormatMemo.get(key);
|
|
170
|
+
if (hit)
|
|
171
|
+
return hit;
|
|
172
|
+
const format = git(['-C', root, 'rev-parse', '--show-object-format=storage']).trim();
|
|
173
|
+
if (format !== 'sha1' && format !== 'sha256')
|
|
174
|
+
throw new Error(`unsupported Git object format '${format || 'empty'}' at ${root}`);
|
|
175
|
+
gitObjectFormatMemo.set(key, format);
|
|
176
|
+
return format;
|
|
177
|
+
}
|
|
178
|
+
export function isGitObjectId(root, value) {
|
|
179
|
+
const length = gitObjectFormat(root) === 'sha256' ? 64 : 40;
|
|
180
|
+
return value.length === length && /^[0-9a-f]+$/.test(value);
|
|
181
|
+
}
|
|
182
|
+
// Batch immutable object lookups used by the shared anchor/index path. Git accepts revision:path queries on
|
|
183
|
+
// batch-check, so dozens of rev-parse + cat-file children collapse into two bounded processes without
|
|
184
|
+
// changing the returned bytes or object ids.
|
|
185
|
+
//
|
|
186
|
+
// These are async for two reasons a synchronous execFileSync cannot serve once the caller batches its WHOLE
|
|
187
|
+
// invocation rather than one reading at a time. A sync child is invisible to the build's permit pool and its
|
|
188
|
+
// abort signal, so a watchdog abort could not kill it; and one build-wide `cat-file --batch` reading tens of
|
|
189
|
+
// megabytes would be a single uninterruptible stretch — exactly the /health-blocking shape [[graph-cache]]
|
|
190
|
+
// closed for the fs walks.
|
|
191
|
+
//
|
|
192
|
+
// Chunking is a real output bound, not ceremony: the async transport caps a child's stdout at GIT_MAX_BUFFER
|
|
193
|
+
// and overflow is a loud error. Revision rows are 41 bytes each, so only the blob read (payload-sized) needs
|
|
194
|
+
// a chunk; the cap is on COUNT because sizes are unknown until git answers, and it is set so an ordinary
|
|
195
|
+
// source corpus never approaches the byte ceiling.
|
|
196
|
+
const BATCH_BLOB_CHUNK = 256;
|
|
197
|
+
const BATCH_BLOB_MAX_BUFFER = 1 << 26;
|
|
198
|
+
async function batchBuffer(args, input, maxBuffer, extraEnv = {}) {
|
|
199
|
+
const env = { ...process.env };
|
|
200
|
+
delete env.GIT_DIR;
|
|
201
|
+
delete env.GIT_WORK_TREE;
|
|
202
|
+
delete env.GIT_INDEX_FILE;
|
|
203
|
+
delete env.GIT_OBJECT_DIRECTORY;
|
|
204
|
+
Object.assign(env, extraEnv);
|
|
205
|
+
try {
|
|
206
|
+
return (await execGitForCaller(args, env, maxBuffer, input)).stdout;
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
if (error?.name === 'AbortError')
|
|
210
|
+
throw error;
|
|
211
|
+
warnIfTimedOut(error, args);
|
|
212
|
+
throw new Error(`git ${args.slice(2, 5).join(' ')} failed: ${String(error?.stderr || error?.message || 'unknown git error').trim()}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
export async function batchRevisionOids(root, revisions, options = {}) {
|
|
216
|
+
if (!revisions.length)
|
|
217
|
+
return [];
|
|
218
|
+
const extraEnv = options.replaceObjects === false ? { GIT_NO_REPLACE_OBJECTS: '1' } : {};
|
|
219
|
+
const out = (await batchBuffer(['-C', root, 'cat-file', '--batch-check=%(objectname)'], revisions.join('\n') + '\n', undefined, extraEnv)).toString('utf8');
|
|
220
|
+
const lines = out.split('\n');
|
|
221
|
+
if (lines.length - 1 !== revisions.length)
|
|
222
|
+
throw new Error(`git cat-file --batch-check returned ${lines.length - 1} rows for ${revisions.length} revisions`);
|
|
223
|
+
return revisions.map((revision, index) => {
|
|
224
|
+
const value = lines[index].trim();
|
|
225
|
+
if (isGitObjectId(root, value))
|
|
226
|
+
return value;
|
|
227
|
+
if (value === `${revision} missing`)
|
|
228
|
+
return null;
|
|
229
|
+
throw new Error(`git cat-file --batch-check returned '${value}' for ${revision}`);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
export async function batchBlobTexts(root, oids) {
|
|
233
|
+
const unique = [...new Set(oids.filter(Boolean))];
|
|
234
|
+
const files = new Map();
|
|
235
|
+
if (!unique.length)
|
|
236
|
+
return files;
|
|
237
|
+
for (const oid of unique)
|
|
238
|
+
if (!isGitObjectId(root, oid))
|
|
239
|
+
throw new Error(`invalid object id '${oid}'`);
|
|
240
|
+
for (let cursor = 0; cursor < unique.length; cursor += BATCH_BLOB_CHUNK) {
|
|
241
|
+
const chunk = unique.slice(cursor, cursor + BATCH_BLOB_CHUNK);
|
|
242
|
+
const out = await batchBuffer(['-C', root, 'cat-file', '--batch'], chunk.join('\n') + '\n', BATCH_BLOB_MAX_BUFFER);
|
|
243
|
+
let offset = 0;
|
|
244
|
+
for (const oid of chunk) {
|
|
245
|
+
const newline = out.indexOf(10, offset);
|
|
246
|
+
if (newline < 0)
|
|
247
|
+
throw new Error(`git cat-file --batch ended before ${oid}`);
|
|
248
|
+
const header = out.subarray(offset, newline).toString('utf8');
|
|
249
|
+
const size = Number(header.match(/ blob (\d+)$/)?.[1]);
|
|
250
|
+
if (!header.startsWith(`${oid} blob `) || !Number.isFinite(size))
|
|
251
|
+
throw new Error(`git cat-file --batch returned '${header}' for ${oid}`); // dead-words-ok: Git object protocol type
|
|
252
|
+
const start = newline + 1, end = start + size;
|
|
253
|
+
if (end >= out.length || out[end] !== 0x0a)
|
|
254
|
+
throw new Error(`git cat-file --batch truncated object ${oid}`);
|
|
255
|
+
files.set(oid, out.subarray(start, end).toString('utf8'));
|
|
256
|
+
offset = end + 1;
|
|
257
|
+
}
|
|
258
|
+
if (offset !== out.length)
|
|
259
|
+
throw new Error(`git cat-file --batch returned ${out.length - offset} unexpected trailing bytes`);
|
|
260
|
+
}
|
|
261
|
+
return files;
|
|
262
|
+
}
|
|
263
|
+
function treeBlobs(root, tip, pathspec = '.') {
|
|
264
|
+
const out = git(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', tip, '--', pathspec]);
|
|
265
|
+
const blobs = [];
|
|
266
|
+
for (const record of out.split('\0')) {
|
|
267
|
+
if (!record)
|
|
268
|
+
continue;
|
|
269
|
+
const m = record.match(/^\d+ blob ([0-9a-f]+)\t([\s\S]+)$/);
|
|
270
|
+
if (m)
|
|
271
|
+
blobs.push({ oid: m[1], path: m[2] });
|
|
272
|
+
}
|
|
273
|
+
return blobs;
|
|
274
|
+
}
|
|
275
|
+
// Read a tree slice in two Git calls regardless of file count: ls-tree names immutable blob oids, then
|
|
276
|
+
// cat-file --batch returns their exact bytes. Pending lint uses this for .spec so commit --only/partial
|
|
277
|
+
// staging can never be judged through unrelated working-tree prose.
|
|
278
|
+
export function treeTextFiles(root, tip, pathspec = '.') {
|
|
279
|
+
const blobs = treeBlobs(root, tip, pathspec);
|
|
280
|
+
const files = new Map();
|
|
281
|
+
if (!blobs.length)
|
|
282
|
+
return files;
|
|
283
|
+
const out = gitBuffer(['-C', root, 'cat-file', '--batch'], blobs.map((b) => b.oid).join('\n') + '\n');
|
|
284
|
+
let offset = 0;
|
|
285
|
+
for (const blob of blobs) {
|
|
286
|
+
const newline = out.indexOf(10, offset);
|
|
287
|
+
if (newline < 0)
|
|
288
|
+
throw new Error(`git cat-file --batch ended before ${blob.path}`);
|
|
289
|
+
const header = out.subarray(offset, newline).toString('utf8');
|
|
290
|
+
const size = Number(header.match(/ blob (\d+)$/)?.[1]);
|
|
291
|
+
if (!Number.isFinite(size))
|
|
292
|
+
throw new Error(`git cat-file --batch returned '${header}' for ${blob.path}`);
|
|
293
|
+
const start = newline + 1, end = start + size;
|
|
294
|
+
files.set(blob.path, out.subarray(start, end).toString('utf8'));
|
|
295
|
+
offset = end + 1; // one LF follows each payload
|
|
296
|
+
}
|
|
297
|
+
return files;
|
|
298
|
+
}
|
|
299
|
+
export function treeFilePaths(root, tip) {
|
|
300
|
+
return new Set(treeBlobs(root, tip).map((b) => b.path));
|
|
301
|
+
}
|
|
302
|
+
export function treeFileText(root, tip, path) {
|
|
303
|
+
try {
|
|
304
|
+
return gitBuffer(['-C', root, 'cat-file', 'blob', `${tip}:${path}`]).toString('utf8');
|
|
305
|
+
} // dead-words-ok: git plumbing
|
|
306
|
+
catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// execFile's AbortSignal kills only its direct child. A wedged adapter may have descendants (the
|
|
311
|
+
// deterministic tests use a shell + sleep), so async git runs in their own process group and abort/timeout
|
|
312
|
+
// kills the whole group. The callback still carries the same stdout/stderr/error shape to gitA/gitTry.
|
|
313
|
+
const GIT_MAX_BUFFER = 1 << 24;
|
|
314
|
+
function execGit(args, env, signal, maxBuffer = GIT_MAX_BUFFER, input) {
|
|
315
|
+
return new Promise((resolve, reject) => {
|
|
316
|
+
if (signal?.aborted) {
|
|
317
|
+
reject(gitAbortError());
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const child = spawn(gitBinary(env), args, { env, detached: true, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] });
|
|
321
|
+
const stdout = [], stderr = [];
|
|
322
|
+
let stdoutBytes = 0, stderrBytes = 0, aborted = false, timedOut = false, overflow = false;
|
|
323
|
+
let spawnError = null;
|
|
324
|
+
const killTree = () => {
|
|
325
|
+
if (!child.pid)
|
|
326
|
+
return;
|
|
327
|
+
try {
|
|
328
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
329
|
+
}
|
|
330
|
+
catch { /* group may already be gone */ }
|
|
331
|
+
try {
|
|
332
|
+
child.kill('SIGKILL');
|
|
333
|
+
}
|
|
334
|
+
catch { /* already exited */ }
|
|
335
|
+
};
|
|
336
|
+
const onAbort = () => { aborted = true; killTree(); };
|
|
337
|
+
const append = (chunks, chunk, stream) => {
|
|
338
|
+
const total = stream === 'stdout' ? (stdoutBytes += chunk.length) : (stderrBytes += chunk.length);
|
|
339
|
+
if (total > maxBuffer) {
|
|
340
|
+
overflow = true;
|
|
341
|
+
killTree();
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
chunks.push(chunk);
|
|
345
|
+
};
|
|
346
|
+
child.stdout.on('data', (chunk) => append(stdout, chunk, 'stdout'));
|
|
347
|
+
child.stderr.on('data', (chunk) => append(stderr, chunk, 'stderr'));
|
|
348
|
+
child.once('error', (error) => { spawnError = error; });
|
|
349
|
+
if (input !== undefined) {
|
|
350
|
+
// A command can reject its input before the pipe drains. Keep that write failure on the same
|
|
351
|
+
// close/reject path as spawn and exit failures instead of letting Node raise an unhandled EPIPE.
|
|
352
|
+
child.stdin.once('error', (error) => { if (!spawnError)
|
|
353
|
+
spawnError = error; });
|
|
354
|
+
child.stdin.end(input);
|
|
355
|
+
}
|
|
356
|
+
child.once('close', (code, childSignal) => {
|
|
357
|
+
clearTimeout(timer);
|
|
358
|
+
signal?.removeEventListener('abort', onAbort);
|
|
359
|
+
const result = { stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr).toString('utf8') };
|
|
360
|
+
if (code === 0 && !aborted && !timedOut && !overflow && !spawnError) {
|
|
361
|
+
resolve(result);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const error = spawnError ?? new Error(overflow
|
|
365
|
+
? `git output exceeded ${maxBuffer} bytes`
|
|
366
|
+
: `git exited with ${code ?? childSignal ?? 'unknown status'}`);
|
|
367
|
+
// @@@ a spawn failure keeps its OWN cause - a child that never started still emits `close`, with the
|
|
368
|
+
// negated errno as its code (EACCES arrives as -13). Overwriting the spawn error's `'EACCES'`/`'ENOENT'`
|
|
369
|
+
// with that number turns "git could not RUN" into "git ran and exited", and every caller that separates
|
|
370
|
+
// the two reads the second as a real git answer: the freshness batch concluded "the anchor object is
|
|
371
|
+
// unreadable", session create concluded "the branch does not exist". A machine where git is missing or
|
|
372
|
+
// unexecutable would have been told, silently, that the thing it asked about is absent.
|
|
373
|
+
if (overflow)
|
|
374
|
+
error.code = 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER';
|
|
375
|
+
else if (!spawnError)
|
|
376
|
+
error.code = code;
|
|
377
|
+
error.signal = childSignal;
|
|
378
|
+
error.stdout = result.stdout.toString('utf8');
|
|
379
|
+
error.stderr = result.stderr;
|
|
380
|
+
if (aborted)
|
|
381
|
+
error.name = 'AbortError';
|
|
382
|
+
if (timedOut)
|
|
383
|
+
error.spexcodeGitTimeout = true;
|
|
384
|
+
reject(error);
|
|
385
|
+
});
|
|
386
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
387
|
+
if (signal?.aborted)
|
|
388
|
+
onAbort();
|
|
389
|
+
const timer = setTimeout(() => { timedOut = true; killTree(); }, GIT_TIMEOUT_MS);
|
|
390
|
+
timer.unref?.();
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
async function execGitForCaller(args, env, maxBuffer, input) {
|
|
394
|
+
const context = inheritedContext();
|
|
395
|
+
if (!context)
|
|
396
|
+
return execGit(args, env, undefined, maxBuffer, input);
|
|
397
|
+
const release = await context.permits.acquire(context.signal);
|
|
398
|
+
try {
|
|
399
|
+
return await execGit(withBuildLimits(args), env, context.signal, maxBuffer, input);
|
|
400
|
+
}
|
|
401
|
+
finally {
|
|
402
|
+
release();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
// Event streams are the index input itself and may legitimately exceed execFile's fixed maxBuffer. Read
|
|
406
|
+
// them through spawn so the only bound is the index the caller is intentionally constructing; timeout,
|
|
407
|
+
// cancellation, process-group cleanup and build permits remain identical to the ordinary async transport.
|
|
408
|
+
function execGitStream(args, env, signal, input) {
|
|
409
|
+
return new Promise((resolve, reject) => {
|
|
410
|
+
if (signal?.aborted) {
|
|
411
|
+
reject(gitAbortError());
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const child = spawn(gitBinary(env), args, { env, detached: true, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] });
|
|
415
|
+
const stdout = [], stderr = [];
|
|
416
|
+
let settled = false, aborted = false, timedOut = false;
|
|
417
|
+
let stdinError = null;
|
|
418
|
+
const killTree = () => {
|
|
419
|
+
if (!child.pid)
|
|
420
|
+
return;
|
|
421
|
+
try {
|
|
422
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
423
|
+
}
|
|
424
|
+
catch { /* group may already be gone */ }
|
|
425
|
+
try {
|
|
426
|
+
child.kill('SIGKILL');
|
|
427
|
+
}
|
|
428
|
+
catch { /* already exited */ }
|
|
429
|
+
};
|
|
430
|
+
const onAbort = () => { aborted = true; killTree(); };
|
|
431
|
+
const timer = setTimeout(() => { timedOut = true; killTree(); }, GIT_TIMEOUT_MS);
|
|
432
|
+
timer.unref?.();
|
|
433
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
434
|
+
child.stdout.on('data', (chunk) => stdout.push(chunk));
|
|
435
|
+
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
|
436
|
+
if (input !== undefined) {
|
|
437
|
+
// A command can reject its input before the pipe drains. Keep that write failure on the same close
|
|
438
|
+
// path as exit and abort failures instead of letting Node raise an unhandled EPIPE.
|
|
439
|
+
child.stdin.once('error', (error) => { stdinError ??= error; });
|
|
440
|
+
child.stdin.end(input);
|
|
441
|
+
}
|
|
442
|
+
child.on('error', (error) => {
|
|
443
|
+
if (settled)
|
|
444
|
+
return;
|
|
445
|
+
settled = true;
|
|
446
|
+
clearTimeout(timer);
|
|
447
|
+
signal?.removeEventListener('abort', onAbort);
|
|
448
|
+
if (aborted)
|
|
449
|
+
error.name = 'AbortError';
|
|
450
|
+
if (timedOut)
|
|
451
|
+
error.spexcodeGitTimeout = true;
|
|
452
|
+
reject(error);
|
|
453
|
+
});
|
|
454
|
+
child.on('close', (code, childSignal) => {
|
|
455
|
+
if (settled)
|
|
456
|
+
return;
|
|
457
|
+
settled = true;
|
|
458
|
+
clearTimeout(timer);
|
|
459
|
+
signal?.removeEventListener('abort', onAbort);
|
|
460
|
+
const result = { stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr).toString('utf8') };
|
|
461
|
+
if (code === 0 && !aborted && !timedOut && !stdinError) {
|
|
462
|
+
resolve(result);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const error = stdinError ?? new Error(`git exited with ${code ?? childSignal ?? 'unknown status'}`);
|
|
466
|
+
error.code = stdinError ? error.code : code;
|
|
467
|
+
error.signal = childSignal;
|
|
468
|
+
error.stdout = result.stdout.toString('utf8');
|
|
469
|
+
error.stderr = result.stderr;
|
|
470
|
+
if (aborted)
|
|
471
|
+
error.name = 'AbortError';
|
|
472
|
+
if (timedOut)
|
|
473
|
+
error.spexcodeGitTimeout = true;
|
|
474
|
+
reject(error);
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
async function execGitStreamForCaller(args, env, input) {
|
|
479
|
+
const context = inheritedContext();
|
|
480
|
+
if (!context)
|
|
481
|
+
return execGitStream(args, env, undefined, input);
|
|
482
|
+
const release = await context.permits.acquire(context.signal);
|
|
483
|
+
try {
|
|
484
|
+
return await execGitStream(withBuildLimits(args), env, context.signal, input);
|
|
485
|
+
}
|
|
486
|
+
finally {
|
|
487
|
+
release();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
export async function gitA(args, input) {
|
|
491
|
+
const env = { ...process.env };
|
|
492
|
+
delete env.GIT_DIR;
|
|
493
|
+
delete env.GIT_WORK_TREE;
|
|
494
|
+
delete env.GIT_INDEX_FILE;
|
|
495
|
+
delete env.GIT_OBJECT_DIRECTORY;
|
|
496
|
+
const context = inheritedContext();
|
|
497
|
+
try {
|
|
498
|
+
const { stdout } = await execGitForCaller(args, env, undefined, input);
|
|
499
|
+
return stdout.toString('utf8');
|
|
500
|
+
}
|
|
501
|
+
catch (e) {
|
|
502
|
+
if (context?.signal.aborted || e?.name === 'AbortError')
|
|
503
|
+
throw e;
|
|
504
|
+
warnIfTimedOut(e, args);
|
|
505
|
+
return '';
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
async function gitARequired(args, input) {
|
|
509
|
+
const env = { ...process.env };
|
|
510
|
+
delete env.GIT_DIR;
|
|
511
|
+
delete env.GIT_WORK_TREE;
|
|
512
|
+
delete env.GIT_INDEX_FILE;
|
|
513
|
+
delete env.GIT_OBJECT_DIRECTORY;
|
|
514
|
+
try {
|
|
515
|
+
return (await execGitForCaller(args, env, undefined, input)).stdout.toString('utf8');
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
if (error?.name === 'AbortError')
|
|
519
|
+
throw error;
|
|
520
|
+
warnIfTimedOut(error, args);
|
|
521
|
+
throw new Error(`git ${args.slice(2, 6).join(' ')} failed: ${String(error?.stderr || error?.message || 'unknown git error').trim()}`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
// The schema names both the ledger grammar and its on-disk namespace. A reader that predates a row type
|
|
525
|
+
// must never share a ledger with its writer: it seeds the next namespace from Git instead.
|
|
526
|
+
const EVENT_CACHE_SCHEMA = 'history-events-v16';
|
|
527
|
+
const IMMUTABLE_HUNK_FACT = 'immutable-hunk-v1';
|
|
528
|
+
const EVENT_STREAM_KINDS = ['merge', 'identity-raw'];
|
|
529
|
+
const eventLedgerBuild = new AsyncLocalStorage();
|
|
530
|
+
const eventLedgerDemandPolicy = new AsyncLocalStorage();
|
|
531
|
+
const eventLedgerDiagnostics = { reads: 0, locks: 0, replaces: 0 };
|
|
532
|
+
const eventPathMemo = new Map();
|
|
533
|
+
function replacementStorageIdentity(common) {
|
|
534
|
+
const hash = createHash('sha256');
|
|
535
|
+
const addTree = (root, rel) => {
|
|
536
|
+
if (!existsSync(root))
|
|
537
|
+
return;
|
|
538
|
+
const stack = [{ dir: root, rel }];
|
|
539
|
+
while (stack.length) {
|
|
540
|
+
const current = stack.pop();
|
|
541
|
+
const entries = readdirSync(current.dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
542
|
+
for (const entry of entries) {
|
|
543
|
+
const path = join(current.dir, entry.name);
|
|
544
|
+
const name = `${current.rel}/${entry.name}`;
|
|
545
|
+
if (entry.isDirectory())
|
|
546
|
+
stack.push({ dir: path, rel: name });
|
|
547
|
+
else
|
|
548
|
+
hash.update(`\0${name}\0`).update(readFileSync(path));
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
addTree(join(common, 'refs', 'replace'), 'refs/replace');
|
|
553
|
+
for (const name of ['packed-refs']) {
|
|
554
|
+
const path = join(common, name);
|
|
555
|
+
if (existsSync(path))
|
|
556
|
+
hash.update(`\0${name}\0`).update(readFileSync(path));
|
|
557
|
+
}
|
|
558
|
+
// Reftable is opaque here by design: its bytes are only an invalidation signal. Git remains the one
|
|
559
|
+
// parser and supplies the canonical refs/replace targets when those bytes change.
|
|
560
|
+
addTree(join(common, 'reftable'), 'reftable');
|
|
561
|
+
return hash.digest('hex');
|
|
562
|
+
}
|
|
563
|
+
function eventCacheLocation(root) {
|
|
564
|
+
const rootId = rootKey(root), old = eventPathMemo.get(rootId);
|
|
565
|
+
const common = old?.common ?? git(['-C', root, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim();
|
|
566
|
+
const shallowPath = old?.shallowPath ?? git(['-C', root, 'rev-parse', '--path-format=absolute', '--git-path', 'shallow']).trim();
|
|
567
|
+
const shallow = existsSync(shallowPath) ? readFileSync(shallowPath, 'utf8') : 'unshallow';
|
|
568
|
+
const graftsPath = join(common, 'info', 'grafts');
|
|
569
|
+
const grafts = existsSync(graftsPath) ? readFileSync(graftsPath, 'utf8') : '';
|
|
570
|
+
const replacementStorage = replacementStorageIdentity(common);
|
|
571
|
+
const replacements = old?.replacementStorage === replacementStorage
|
|
572
|
+
? old.replacements
|
|
573
|
+
: git(['-C', root, 'for-each-ref', 'refs/replace', '--format=%(refname) %(objectname)']);
|
|
574
|
+
const objectFormat = gitObjectFormat(root);
|
|
575
|
+
if (old && old.shallow === shallow && old.grafts === grafts && old.replacements === replacements && old.objectFormat === objectFormat)
|
|
576
|
+
return { path: old.path, identity: old.identity, interpretation: old.interpretation, objectFormat };
|
|
577
|
+
const interpretation = createHash('sha256')
|
|
578
|
+
.update(`${EVENT_CACHE_SCHEMA}\0${objectFormat}\0${shallow}\0${grafts}\0${replacements}`)
|
|
579
|
+
.digest('hex');
|
|
580
|
+
const identity = interpretation.slice(0, 16);
|
|
581
|
+
// projectRuntimeRoot derives checkout identity from dirname(common). A bare repository is its own common
|
|
582
|
+
// dir, so a synthetic `.git` suffix preserves the repository path instead of collapsing sibling bares.
|
|
583
|
+
const gitDir = gitDirOf(root);
|
|
584
|
+
const storeIdentity = gitDir === root && common === root ? join(common, '.git') : common;
|
|
585
|
+
const path = join(projectRuntimeRoot(storeIdentity), `${EVENT_CACHE_SCHEMA}-${identity}.ndjson`);
|
|
586
|
+
eventPathMemo.set(rootId, { common, shallowPath, grafts, shallow, replacementStorage, replacements, path, identity, interpretation, objectFormat });
|
|
587
|
+
return { path, identity, interpretation, objectFormat };
|
|
588
|
+
}
|
|
589
|
+
// One existing source-of-truth identity governs every Git reader that interprets commit images. Consumers
|
|
590
|
+
// may retain the full digest in memory while the ledger keeps its established short directory name.
|
|
591
|
+
export function gitObjectInterpretation(root) {
|
|
592
|
+
const location = eventCacheLocation(root);
|
|
593
|
+
const raw = eventPathMemo.get(rootKey(root))?.replacements ?? '';
|
|
594
|
+
const replacements = new Map();
|
|
595
|
+
for (const line of raw.split('\n').filter(Boolean)) {
|
|
596
|
+
const match = line.match(/^refs\/replace\/([0-9a-f]+) ([0-9a-f]+)$/);
|
|
597
|
+
if (!match || !isGitObjectIdForFormat(location.objectFormat, match[1]) || !isGitObjectIdForFormat(location.objectFormat, match[2]))
|
|
598
|
+
throw new Error(`malformed refs/replace projection '${line}' at ${root}`);
|
|
599
|
+
replacements.set(match[1], match[2]);
|
|
600
|
+
}
|
|
601
|
+
return { identity: location.interpretation, objectFormat: location.objectFormat, replacements };
|
|
602
|
+
}
|
|
603
|
+
export function gitInterpretationIdentity(root) { return eventCacheLocation(root).identity; }
|
|
604
|
+
function emptyEventCache() {
|
|
605
|
+
return { streams: new Map(), streamTips: new Map(), hunks: new Map() };
|
|
606
|
+
}
|
|
607
|
+
function exactKeys(value, expected) {
|
|
608
|
+
const actual = Object.keys(value).sort();
|
|
609
|
+
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
|
610
|
+
}
|
|
611
|
+
function isGitObjectIdForFormat(format, value) {
|
|
612
|
+
return value.length === (format === 'sha256' ? 64 : 40) && /^[0-9a-f]+$/.test(value);
|
|
613
|
+
}
|
|
614
|
+
function eventPayloadDigest(payload) {
|
|
615
|
+
return createHash('sha256').update(payload).digest('hex');
|
|
616
|
+
}
|
|
617
|
+
function eventIntegrityFooter(payload, addition) {
|
|
618
|
+
const footer = {
|
|
619
|
+
k: 'integrity',
|
|
620
|
+
bytes: payload.length + addition.length,
|
|
621
|
+
sha256: createHash('sha256').update(payload).update(addition).digest('hex'),
|
|
622
|
+
};
|
|
623
|
+
return Buffer.from(`${JSON.stringify(footer)}\n`);
|
|
624
|
+
}
|
|
625
|
+
function eventStreamKind(value) {
|
|
626
|
+
return typeof value === 'string' && EVENT_STREAM_KINDS.includes(value)
|
|
627
|
+
? value
|
|
628
|
+
: null;
|
|
629
|
+
}
|
|
630
|
+
function decodeHunkRanges(value) {
|
|
631
|
+
if (!Array.isArray(value))
|
|
632
|
+
return null;
|
|
633
|
+
const ranges = [];
|
|
634
|
+
for (const row of value) {
|
|
635
|
+
if (!Array.isArray(row) || row.length !== 2 || !Number.isSafeInteger(row[0]) || !Number.isSafeInteger(row[1])
|
|
636
|
+
|| row[0] <= 0 || row[1] < row[0])
|
|
637
|
+
return null;
|
|
638
|
+
ranges.push([row[0], row[1]]);
|
|
639
|
+
}
|
|
640
|
+
return ranges;
|
|
641
|
+
}
|
|
642
|
+
function decodeImmutableHunkFact(row) {
|
|
643
|
+
if (!exactKeys(row, ['a', 'b', 'i', 'k']) || row.k !== IMMUTABLE_HUNK_FACT || typeof row.i !== 'string' || !row.i)
|
|
644
|
+
return null;
|
|
645
|
+
const after = decodeHunkRanges(row.a);
|
|
646
|
+
if (!after || !Array.isArray(row.b))
|
|
647
|
+
return null;
|
|
648
|
+
const before = [];
|
|
649
|
+
for (const parent of row.b) {
|
|
650
|
+
const ranges = decodeHunkRanges(parent);
|
|
651
|
+
if (!ranges)
|
|
652
|
+
return null;
|
|
653
|
+
before.push(ranges);
|
|
654
|
+
}
|
|
655
|
+
return { key: row.i, ranges: { after, before } };
|
|
656
|
+
}
|
|
657
|
+
function decodeEventPayload(payload, location) {
|
|
658
|
+
const state = emptyEventCache();
|
|
659
|
+
const text = payload.toString('utf8');
|
|
660
|
+
for (let start = 0; start < text.length;) {
|
|
661
|
+
const newline = text.indexOf('\n', start);
|
|
662
|
+
const end = newline < 0 ? text.length : newline;
|
|
663
|
+
const line = text.slice(start, end);
|
|
664
|
+
start = end + 1;
|
|
665
|
+
if (!line)
|
|
666
|
+
continue;
|
|
667
|
+
let value;
|
|
668
|
+
try {
|
|
669
|
+
value = JSON.parse(line);
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
675
|
+
return null;
|
|
676
|
+
const row = value;
|
|
677
|
+
if (row.k === IMMUTABLE_HUNK_FACT) {
|
|
678
|
+
const fact = decodeImmutableHunkFact(row);
|
|
679
|
+
if (!fact || state.hunks.has(fact.key))
|
|
680
|
+
return null;
|
|
681
|
+
state.hunks.set(fact.key, fact.ranges);
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
if (exactKeys(row, ['k', 'tip'])) {
|
|
685
|
+
const kind = typeof row.k === 'string' && row.k.startsWith('tip:') ? eventStreamKind(row.k.slice(4)) : null;
|
|
686
|
+
if (!kind || typeof row.tip !== 'string' || !isGitObjectIdForFormat(location.objectFormat, row.tip))
|
|
687
|
+
return null;
|
|
688
|
+
const tips = state.streamTips.get(kind) ?? [];
|
|
689
|
+
if (tips.includes(row.tip))
|
|
690
|
+
return null;
|
|
691
|
+
tips.push(row.tip);
|
|
692
|
+
state.streamTips.set(kind, tips);
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
const kind = eventStreamKind(row.k);
|
|
696
|
+
if (!kind || typeof row.h !== 'string' || !isGitObjectIdForFormat(location.objectFormat, row.h))
|
|
697
|
+
return null;
|
|
698
|
+
const record = kind === 'identity-raw'
|
|
699
|
+
? (() => {
|
|
700
|
+
const payload = exactKeys(row, ['a', 'c', 'd', 'h', 'k', 'r', 's'])
|
|
701
|
+
? { a: row.a, c: row.c, d: row.d, r: row.r, s: row.s }
|
|
702
|
+
: null;
|
|
703
|
+
const identity = payload ? decodeIdentityRawRecord(payload, row.h, location) : null;
|
|
704
|
+
return identity ? { hash: row.h, identity } : null;
|
|
705
|
+
})()
|
|
706
|
+
: (!exactKeys(row, ['h', 'k', 'r']) || typeof row.r !== 'string' || !row.r
|
|
707
|
+
? null
|
|
708
|
+
: (() => {
|
|
709
|
+
const rawHash = row.r.split(US, 1)[0].split('\n', 1)[0].trim();
|
|
710
|
+
return rawHash === row.h ? { hash: row.h, raw: row.r } : null;
|
|
711
|
+
})());
|
|
712
|
+
if (!record)
|
|
713
|
+
return null;
|
|
714
|
+
let stream = state.streams.get(kind);
|
|
715
|
+
if (!stream) {
|
|
716
|
+
stream = new Map();
|
|
717
|
+
state.streams.set(kind, stream);
|
|
718
|
+
}
|
|
719
|
+
if (stream.has(row.h))
|
|
720
|
+
return null;
|
|
721
|
+
stream.set(row.h, record);
|
|
722
|
+
}
|
|
723
|
+
return state;
|
|
724
|
+
}
|
|
725
|
+
function loadEventLedger(location) {
|
|
726
|
+
eventLedgerDiagnostics.reads++;
|
|
727
|
+
let file;
|
|
728
|
+
try {
|
|
729
|
+
file = readFileSync(location.path);
|
|
730
|
+
}
|
|
731
|
+
catch (error) {
|
|
732
|
+
if (error?.code !== 'ENOENT')
|
|
733
|
+
throw error;
|
|
734
|
+
return { payload: Buffer.alloc(0), state: emptyEventCache() };
|
|
735
|
+
}
|
|
736
|
+
if (!file.length || file[file.length - 1] !== 0x0a)
|
|
737
|
+
return { payload: Buffer.alloc(0), state: emptyEventCache() };
|
|
738
|
+
const body = file.subarray(0, file.length - 1);
|
|
739
|
+
const footerBreak = body.lastIndexOf(0x0a);
|
|
740
|
+
const footerStart = footerBreak + 1;
|
|
741
|
+
const payload = file.subarray(0, footerStart);
|
|
742
|
+
try {
|
|
743
|
+
const value = JSON.parse(body.subarray(footerStart).toString('utf8'));
|
|
744
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
745
|
+
throw new Error('invalid integrity row');
|
|
746
|
+
const row = value;
|
|
747
|
+
if (!exactKeys(row, ['bytes', 'k', 'sha256']) || row.k !== 'integrity' || row.bytes !== payload.length
|
|
748
|
+
|| typeof row.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(row.sha256)
|
|
749
|
+
|| row.sha256 !== eventPayloadDigest(payload))
|
|
750
|
+
throw new Error('invalid integrity row');
|
|
751
|
+
const state = decodeEventPayload(payload, location);
|
|
752
|
+
if (!state)
|
|
753
|
+
throw new Error('invalid event row');
|
|
754
|
+
return { payload, state };
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
return { payload: Buffer.alloc(0), state: emptyEventCache() };
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
function readEventLockOwner(lock) {
|
|
761
|
+
try {
|
|
762
|
+
const value = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8'));
|
|
763
|
+
return Number.isInteger(value.pid) && value.pid > 0
|
|
764
|
+
&& typeof value.startToken === 'string' && !!value.startToken
|
|
765
|
+
&& typeof value.nonce === 'string' && !!value.nonce
|
|
766
|
+
? { pid: value.pid, startToken: value.startToken, nonce: value.nonce }
|
|
767
|
+
: null;
|
|
768
|
+
}
|
|
769
|
+
catch {
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
const sameEventLockOwner = (left, right) => !!left && !!right && left.pid === right.pid && left.startToken === right.startToken && left.nonce === right.nonce;
|
|
774
|
+
function eventLockOwnerState(owner) {
|
|
775
|
+
try {
|
|
776
|
+
process.kill(owner.pid, 0);
|
|
777
|
+
}
|
|
778
|
+
catch (error) {
|
|
779
|
+
if (error?.code === 'ESRCH')
|
|
780
|
+
return 'dead';
|
|
781
|
+
if (error?.code !== 'EPERM')
|
|
782
|
+
return 'unknown';
|
|
783
|
+
}
|
|
784
|
+
const observed = processStartToken(owner.pid);
|
|
785
|
+
if (!observed)
|
|
786
|
+
return 'unknown';
|
|
787
|
+
return observed === owner.startToken ? 'live' : 'dead';
|
|
788
|
+
}
|
|
789
|
+
function retireLockPath(active) {
|
|
790
|
+
const inert = `${active}.inert.${process.pid}.${randomBytes(8).toString('hex')}`;
|
|
791
|
+
try {
|
|
792
|
+
renameSync(active, inert);
|
|
793
|
+
}
|
|
794
|
+
catch (error) {
|
|
795
|
+
if (error?.code === 'ENOENT')
|
|
796
|
+
return false;
|
|
797
|
+
throw error;
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
rmSync(inert, { recursive: true, force: true });
|
|
801
|
+
}
|
|
802
|
+
catch { /* no longer arbitrates ownership */ }
|
|
803
|
+
return true;
|
|
804
|
+
}
|
|
805
|
+
function reclaimDeadEventLock(lock, held, claimant) {
|
|
806
|
+
if (eventLockOwnerState(held) !== 'dead')
|
|
807
|
+
return false;
|
|
808
|
+
const reclaim = join(lock, 'reclaim');
|
|
809
|
+
if (existsSync(reclaim)) {
|
|
810
|
+
const reclaimer = readEventLockOwner(reclaim);
|
|
811
|
+
if (!reclaimer)
|
|
812
|
+
throw new Error(`history event cache lock has an unprovable reclaimer: ${lock}`);
|
|
813
|
+
const state = eventLockOwnerState(reclaimer);
|
|
814
|
+
if (state === 'unknown')
|
|
815
|
+
throw new Error(`history event cache lock reclaimer identity is unreadable: ${lock}`);
|
|
816
|
+
if (state === 'live')
|
|
817
|
+
return false;
|
|
818
|
+
retireLockPath(reclaim);
|
|
819
|
+
}
|
|
820
|
+
const prepared = join(lock, `reclaim-${claimant.pid}-${claimant.nonce}`);
|
|
821
|
+
try {
|
|
822
|
+
mkdirSync(prepared);
|
|
823
|
+
writeFileSync(join(prepared, 'owner.json'), JSON.stringify(claimant));
|
|
824
|
+
renameSync(prepared, reclaim);
|
|
825
|
+
}
|
|
826
|
+
catch (error) {
|
|
827
|
+
rmSync(prepared, { recursive: true, force: true });
|
|
828
|
+
if (error?.code === 'EEXIST' || error?.code === 'ENOTEMPTY' || error?.code === 'ENOENT')
|
|
829
|
+
return false;
|
|
830
|
+
throw error;
|
|
831
|
+
}
|
|
832
|
+
const current = readEventLockOwner(lock);
|
|
833
|
+
if (!sameEventLockOwner(current, held) || (current && eventLockOwnerState(current) !== 'dead')) {
|
|
834
|
+
retireLockPath(reclaim);
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
return retireLockPath(lock);
|
|
838
|
+
}
|
|
839
|
+
const EVENT_LEDGER_BUSY = Symbol('event ledger held by a live writer');
|
|
840
|
+
async function withEventCacheLock(path, run, waitForWriter = true) {
|
|
841
|
+
const lock = `${path}.lock`;
|
|
842
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
843
|
+
const startToken = processStartToken(process.pid);
|
|
844
|
+
if (!startToken)
|
|
845
|
+
throw new Error(`cannot prove history event cache lock claimant identity: ${path}`);
|
|
846
|
+
const owner = { pid: process.pid, startToken, nonce: randomBytes(16).toString('hex') };
|
|
847
|
+
const attempts = Math.max(1, Math.ceil(GIT_TIMEOUT_MS / 5));
|
|
848
|
+
const signal = inheritedContext()?.signal;
|
|
849
|
+
for (let attempt = 0;; attempt++) {
|
|
850
|
+
if (signal?.aborted)
|
|
851
|
+
throw gitAbortError();
|
|
852
|
+
const claimant = `${lock}.claim.${owner.pid}.${owner.nonce}`;
|
|
853
|
+
try {
|
|
854
|
+
mkdirSync(claimant);
|
|
855
|
+
writeFileSync(join(claimant, 'owner.json'), JSON.stringify(owner));
|
|
856
|
+
renameSync(claimant, lock);
|
|
857
|
+
break;
|
|
858
|
+
}
|
|
859
|
+
catch (error) {
|
|
860
|
+
rmSync(claimant, { recursive: true, force: true });
|
|
861
|
+
if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY')
|
|
862
|
+
throw error;
|
|
863
|
+
const held = readEventLockOwner(lock);
|
|
864
|
+
if (!held) {
|
|
865
|
+
if (!existsSync(lock))
|
|
866
|
+
continue;
|
|
867
|
+
throw new Error(`history event cache lock has no provable exact owner: ${path}`);
|
|
868
|
+
}
|
|
869
|
+
const heldState = eventLockOwnerState(held);
|
|
870
|
+
if (heldState === 'unknown')
|
|
871
|
+
throw new Error(`history event cache lock owner identity is unreadable: ${path}`);
|
|
872
|
+
if (heldState === 'dead') {
|
|
873
|
+
if (reclaimDeadEventLock(lock, held, owner))
|
|
874
|
+
continue;
|
|
875
|
+
if (!existsSync(lock))
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
if (!waitForWriter) {
|
|
879
|
+
const current = readEventLockOwner(lock);
|
|
880
|
+
if (!current) {
|
|
881
|
+
if (!existsSync(lock))
|
|
882
|
+
continue;
|
|
883
|
+
throw new Error(`history event cache lock has no provable exact owner: ${path}`);
|
|
884
|
+
}
|
|
885
|
+
const state = eventLockOwnerState(current);
|
|
886
|
+
if (state === 'live')
|
|
887
|
+
return EVENT_LEDGER_BUSY;
|
|
888
|
+
// A live reclaimer may be arbitrating this exact dead owner. Foreground demand can safely derive
|
|
889
|
+
// from the atomic snapshot, but must not spin synchronously until that separate lease finishes.
|
|
890
|
+
if (state === 'dead')
|
|
891
|
+
return EVENT_LEDGER_BUSY;
|
|
892
|
+
throw new Error(`history event cache lock owner identity is unreadable: ${path}`);
|
|
893
|
+
}
|
|
894
|
+
if (attempt >= attempts) {
|
|
895
|
+
const heldBy = readEventLockOwner(lock)?.pid;
|
|
896
|
+
throw new Error(`timed out waiting for history event cache lock held by ${heldBy ? `pid ${heldBy}` : 'unknown owner'}: ${path}`);
|
|
897
|
+
}
|
|
898
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
eventLedgerDiagnostics.locks++;
|
|
902
|
+
try {
|
|
903
|
+
return await run();
|
|
904
|
+
}
|
|
905
|
+
finally {
|
|
906
|
+
if (sameEventLockOwner(readEventLockOwner(lock), owner))
|
|
907
|
+
retireLockPath(lock);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
function removeEventTemps(path) {
|
|
911
|
+
const dir = join(path, '..'), base = path.split('/').pop() ?? '';
|
|
912
|
+
try {
|
|
913
|
+
for (const name of readdirSync(dir))
|
|
914
|
+
if (name.startsWith(`${base}.`) && name.endsWith('.tmp'))
|
|
915
|
+
rmSync(join(dir, name), { force: true });
|
|
916
|
+
}
|
|
917
|
+
catch { /* the writer creates the directory immediately below */ }
|
|
918
|
+
}
|
|
919
|
+
function replaceEventLedger(path, payload, additions) {
|
|
920
|
+
const addition = Buffer.from(additions.join(''));
|
|
921
|
+
const tmp = `${path}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
|
|
922
|
+
let fd = null;
|
|
923
|
+
try {
|
|
924
|
+
fd = openSync(tmp, 'wx', 0o600);
|
|
925
|
+
writeFileSync(fd, payload);
|
|
926
|
+
writeFileSync(fd, addition);
|
|
927
|
+
writeFileSync(fd, eventIntegrityFooter(payload, addition));
|
|
928
|
+
const written = fd;
|
|
929
|
+
fd = null;
|
|
930
|
+
closeSync(written);
|
|
931
|
+
renameSync(tmp, path);
|
|
932
|
+
eventLedgerDiagnostics.replaces++;
|
|
933
|
+
}
|
|
934
|
+
catch (error) {
|
|
935
|
+
if (fd !== null)
|
|
936
|
+
closeSync(fd);
|
|
937
|
+
rmSync(tmp, { force: true });
|
|
938
|
+
throw error;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
const EVENT_LEDGER_RETRY = Symbol('event ledger identity moved');
|
|
942
|
+
function activeEventLedger(root) {
|
|
943
|
+
const build = eventLedgerBuild.getStore();
|
|
944
|
+
if (!build)
|
|
945
|
+
return null;
|
|
946
|
+
return eventCacheLocation(root).path === build.location.path ? build : null;
|
|
947
|
+
}
|
|
948
|
+
async function runEventLedgerAttempt(root, location, run, persist) {
|
|
949
|
+
const build = { location, snapshot: loadEventLedger(location), additions: [] };
|
|
950
|
+
const result = await eventLedgerBuild.run(build, run);
|
|
951
|
+
if (eventCacheLocation(root).identity !== location.identity)
|
|
952
|
+
return EVENT_LEDGER_RETRY;
|
|
953
|
+
if (persist && build.additions.length) {
|
|
954
|
+
removeEventTemps(location.path);
|
|
955
|
+
mkdirSync(join(location.path, '..'), { recursive: true });
|
|
956
|
+
replaceEventLedger(location.path, build.snapshot.payload, build.additions);
|
|
957
|
+
}
|
|
958
|
+
return result;
|
|
959
|
+
}
|
|
960
|
+
async function eventLedgerTransaction(root, run, demand) {
|
|
961
|
+
if (activeEventLedger(root))
|
|
962
|
+
return run();
|
|
963
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
964
|
+
const location = eventCacheLocation(root);
|
|
965
|
+
const locked = await withEventCacheLock(location.path, () => runEventLedgerAttempt(root, location, run, true), !demand);
|
|
966
|
+
const value = locked === EVENT_LEDGER_BUSY
|
|
967
|
+
? await runEventLedgerAttempt(root, location, run, false)
|
|
968
|
+
: locked;
|
|
969
|
+
if (value !== EVENT_LEDGER_RETRY)
|
|
970
|
+
return value;
|
|
971
|
+
}
|
|
972
|
+
throw new Error('history event cache identity changed repeatedly during one ledger build');
|
|
973
|
+
}
|
|
974
|
+
// The event ledger is one build transaction, not one transaction per consumer: stream extraction and
|
|
975
|
+
// immutable hunk derivation share the snapshot, integrity verdict, lock, and final replacement.
|
|
976
|
+
export function withEventLedgerBuild(root, run) {
|
|
977
|
+
const demand = eventLedgerDemandPolicy.getStore();
|
|
978
|
+
return eventLedgerTransaction(root, run, !!demand && demand.path === eventCacheLocation(root).path);
|
|
979
|
+
}
|
|
980
|
+
// Demand is an ambient acquisition POLICY, not an eager lock. Nested ledger consumers therefore use the
|
|
981
|
+
// read-only path under a live writer, while observer waits, revision reads, and stable-cut replay take no lock.
|
|
982
|
+
export function withEventLedgerDemand(root, run) {
|
|
983
|
+
const path = eventCacheLocation(root).path;
|
|
984
|
+
if (eventLedgerDemandPolicy.getStore()?.path === path)
|
|
985
|
+
return run();
|
|
986
|
+
return eventLedgerDemandPolicy.run({ path }, run);
|
|
987
|
+
}
|
|
988
|
+
export function eventLedgerDiagnosticsForTests() { return { ...eventLedgerDiagnostics }; }
|
|
989
|
+
export function resetEventLedgerDiagnosticsForTests() {
|
|
990
|
+
eventLedgerDiagnostics.reads = 0;
|
|
991
|
+
eventLedgerDiagnostics.locks = 0;
|
|
992
|
+
eventLedgerDiagnostics.replaces = 0;
|
|
993
|
+
}
|
|
994
|
+
function sortedEventRecords(records, request) {
|
|
995
|
+
return [...records].filter((record) => request.reachable.has(record.hash))
|
|
996
|
+
.sort((a, b) => (request.order.get(a.hash) ?? Number.MAX_SAFE_INTEGER) - (request.order.get(b.hash) ?? Number.MAX_SAFE_INTEGER));
|
|
997
|
+
}
|
|
998
|
+
function identityRecords(records, request) {
|
|
999
|
+
return sortedEventRecords(records, request).map((record) => {
|
|
1000
|
+
if (!('identity' in record))
|
|
1001
|
+
throw new Error('identity-raw ledger contained a text event');
|
|
1002
|
+
return record.identity;
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
function renderEventStream(state, request) {
|
|
1006
|
+
const records = sortedEventRecords(state.streams.get(request.kind)?.values() ?? [], request);
|
|
1007
|
+
if (request.kind === 'identity-raw')
|
|
1008
|
+
return records.map((record) => {
|
|
1009
|
+
if (!('identity' in record))
|
|
1010
|
+
throw new Error('identity-raw ledger contained a text event');
|
|
1011
|
+
return record.identity;
|
|
1012
|
+
});
|
|
1013
|
+
return records.map((record) => {
|
|
1014
|
+
if (!('raw' in record))
|
|
1015
|
+
throw new Error(`history event stream '${request.kind}' contained a structured event`);
|
|
1016
|
+
return RS + record.raw;
|
|
1017
|
+
}).join('');
|
|
1018
|
+
}
|
|
1019
|
+
function appendEventRecord(state, kind, record, additions) {
|
|
1020
|
+
let stream = state.streams.get(kind);
|
|
1021
|
+
if (!stream) {
|
|
1022
|
+
stream = new Map();
|
|
1023
|
+
state.streams.set(kind, stream);
|
|
1024
|
+
}
|
|
1025
|
+
if (stream.has(record.hash))
|
|
1026
|
+
return;
|
|
1027
|
+
stream.set(record.hash, record);
|
|
1028
|
+
additions.push('identity' in record
|
|
1029
|
+
? 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'
|
|
1030
|
+
: JSON.stringify({ k: kind, h: record.hash, r: record.raw }) + '\n');
|
|
1031
|
+
}
|
|
1032
|
+
function appendEventTip(state, kind, tip, additions) {
|
|
1033
|
+
const tips = state.streamTips.get(kind) ?? [];
|
|
1034
|
+
if (tips.includes(tip))
|
|
1035
|
+
return;
|
|
1036
|
+
tips.push(tip);
|
|
1037
|
+
state.streamTips.set(kind, tips);
|
|
1038
|
+
additions.push(JSON.stringify({ k: `tip:${kind}`, tip }) + '\n');
|
|
1039
|
+
}
|
|
1040
|
+
function appendImmutableHunkFact(state, key, ranges, additions) {
|
|
1041
|
+
const existing = state.hunks.get(key);
|
|
1042
|
+
if (existing) {
|
|
1043
|
+
if (JSON.stringify(existing) !== JSON.stringify(ranges))
|
|
1044
|
+
throw new Error(`immutable hunk ledger fact disagrees for image identity ${JSON.stringify(key)}`);
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
state.hunks.set(key, ranges);
|
|
1048
|
+
additions.push(JSON.stringify({ k: IMMUTABLE_HUNK_FACT, i: key, a: ranges.after, b: ranges.before }) + '\n');
|
|
1049
|
+
}
|
|
1050
|
+
function parseEventRecords(out, kind, location) {
|
|
1051
|
+
if (kind === 'identity-raw')
|
|
1052
|
+
return parseIdentityRawEventRecords(out, location);
|
|
1053
|
+
const records = [];
|
|
1054
|
+
for (const rec of out.split(RS)) {
|
|
1055
|
+
const raw = rec.replace(/^\n/, '');
|
|
1056
|
+
if (!raw)
|
|
1057
|
+
continue;
|
|
1058
|
+
const hash = raw.split(US, 1)[0].split('\n', 1)[0].trim();
|
|
1059
|
+
if (!isGitObjectIdForFormat(location.objectFormat, hash))
|
|
1060
|
+
throw new Error(`history event stream '${kind}' returned malformed object id '${hash || 'empty'}'`);
|
|
1061
|
+
records.push({ hash, raw });
|
|
1062
|
+
}
|
|
1063
|
+
return records;
|
|
1064
|
+
}
|
|
1065
|
+
function indexEventRequests(root, tip, order, reachable) {
|
|
1066
|
+
return {
|
|
1067
|
+
'identity-raw': {
|
|
1068
|
+
kind: 'identity-raw', order, reachable,
|
|
1069
|
+
argsFor: (base) => ['-C', root, '-c', 'core.quotePath=false',
|
|
1070
|
+
'log', '--root', '--full-history', '--date-order', '--no-diff-merges', '-M', '-l0', '--raw', '-z', '--no-abbrev', '--no-ext-diff', '--no-textconv',
|
|
1071
|
+
`--format=${RS}%H%x00%aI%x00%s%x00%b%x00%(trailers:key=Spec-OK,valueonly,separator=%x2C)%x00`,
|
|
1072
|
+
...(base ? [`^${base}`] : []), tip],
|
|
1073
|
+
},
|
|
1074
|
+
merge: {
|
|
1075
|
+
kind: 'merge', order, reachable,
|
|
1076
|
+
argsFor: (base) => ['-C', root, '-c', 'core.quotePath=false',
|
|
1077
|
+
'log', '--merges', '--raw', '--patch', '--cc', '--combined-all-paths', '--unified=0', '--no-color', '--no-ext-diff', '-M',
|
|
1078
|
+
`--format=${RS}%H`, ...(base ? [`^${base}`] : []), tip],
|
|
1079
|
+
},
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
async function strictEventGit(args) {
|
|
1083
|
+
return gitRequiredA(args, 'cannot derive history events');
|
|
1084
|
+
}
|
|
1085
|
+
async function deriveEventStreams(root, tip, requests, persist = true, cache = true) {
|
|
1086
|
+
if (!cache) {
|
|
1087
|
+
const location = eventCacheLocation(root);
|
|
1088
|
+
const outputs = await Promise.all(requests.map((request) => strictEventGit(request.argsFor(''))));
|
|
1089
|
+
const rendered = new Map();
|
|
1090
|
+
for (let index = 0; index < requests.length; index++) {
|
|
1091
|
+
const request = requests[index];
|
|
1092
|
+
rendered.set(request.kind, request.kind === 'identity-raw'
|
|
1093
|
+
? identityRecords(parseIdentityRawEventRecords(outputs[index], location), request)
|
|
1094
|
+
: outputs[index]);
|
|
1095
|
+
}
|
|
1096
|
+
return rendered;
|
|
1097
|
+
}
|
|
1098
|
+
if (new Set(requests.map((request) => request.kind)).size !== requests.length)
|
|
1099
|
+
throw new Error('one event-ledger transaction cannot request the same stream twice');
|
|
1100
|
+
const build = activeEventLedger(root);
|
|
1101
|
+
if (!build)
|
|
1102
|
+
return withEventLedgerBuild(root, () => deriveEventStreams(root, tip, requests, persist, cache));
|
|
1103
|
+
const { location, snapshot, additions } = build;
|
|
1104
|
+
const missing = requests.filter((request) => !(snapshot.state.streamTips.get(request.kind) ?? []).includes(tip));
|
|
1105
|
+
const outputs = await Promise.all(missing.map((request) => {
|
|
1106
|
+
const base = [...(snapshot.state.streamTips.get(request.kind) ?? [])].reverse()
|
|
1107
|
+
.find((candidate) => request.reachable.has(candidate)) ?? '';
|
|
1108
|
+
return strictEventGit(request.argsFor(base));
|
|
1109
|
+
}));
|
|
1110
|
+
for (let index = 0; index < missing.length; index++) {
|
|
1111
|
+
const request = missing[index];
|
|
1112
|
+
for (const record of parseEventRecords(outputs[index], request.kind, location))
|
|
1113
|
+
appendEventRecord(snapshot.state, request.kind, record, additions);
|
|
1114
|
+
if (persist)
|
|
1115
|
+
appendEventTip(snapshot.state, request.kind, tip, additions);
|
|
1116
|
+
}
|
|
1117
|
+
return new Map(requests.map((request) => [request.kind, renderEventStream(snapshot.state, request)]));
|
|
1118
|
+
}
|
|
1119
|
+
// Immutable hunk ranges are a ledger fact, not a second anchor cache: callers name their whole image-key
|
|
1120
|
+
// demand, receive only facts the shared ledger already certified, and derive/persist misses in their own read.
|
|
1121
|
+
export function readImmutableHunkFacts(root, keys) {
|
|
1122
|
+
const wanted = new Set(keys);
|
|
1123
|
+
if (!wanted.size)
|
|
1124
|
+
return new Map();
|
|
1125
|
+
const build = activeEventLedger(root);
|
|
1126
|
+
if (!build)
|
|
1127
|
+
return new Map();
|
|
1128
|
+
const found = new Map();
|
|
1129
|
+
for (const key of wanted) {
|
|
1130
|
+
const ranges = build.snapshot.state.hunks.get(key);
|
|
1131
|
+
if (ranges)
|
|
1132
|
+
found.set(key, ranges);
|
|
1133
|
+
}
|
|
1134
|
+
return found;
|
|
1135
|
+
}
|
|
1136
|
+
export async function persistImmutableHunkFacts(root, facts) {
|
|
1137
|
+
if (!facts.size)
|
|
1138
|
+
return;
|
|
1139
|
+
const build = activeEventLedger(root);
|
|
1140
|
+
if (!build)
|
|
1141
|
+
return withEventLedgerBuild(root, () => persistImmutableHunkFacts(root, facts));
|
|
1142
|
+
for (const [key, ranges] of facts)
|
|
1143
|
+
appendImmutableHunkFact(build.snapshot.state, key, ranges, build.additions);
|
|
1144
|
+
}
|
|
1145
|
+
async function eventStream(root, tip, request, persist = true, cache = true) {
|
|
1146
|
+
const value = (await deriveEventStreams(root, tip, [request], persist, cache)).get(request.kind);
|
|
1147
|
+
if (value === undefined)
|
|
1148
|
+
throw new Error(`history event stream '${request.kind}' was not rendered`);
|
|
1149
|
+
return value;
|
|
1150
|
+
}
|
|
1151
|
+
async function textEventStream(root, tip, request, persist = true, cache = true) {
|
|
1152
|
+
const value = await eventStream(root, tip, request, persist, cache);
|
|
1153
|
+
if (typeof value !== 'string')
|
|
1154
|
+
throw new Error(`history event stream '${request.kind}' rendered structured data`);
|
|
1155
|
+
return value;
|
|
1156
|
+
}
|
|
1157
|
+
async function identityRawEventStream(root, tip, request, persist = true, cache = true) {
|
|
1158
|
+
const value = await eventStream(root, tip, request, persist, cache);
|
|
1159
|
+
if (!Array.isArray(value) || value.some((record) => !('a' in record)))
|
|
1160
|
+
throw new Error(`history event stream '${request.kind}' rendered text`);
|
|
1161
|
+
return value;
|
|
1162
|
+
}
|
|
1163
|
+
export async function gitTry(args, options = {}) {
|
|
1164
|
+
const env = { ...process.env };
|
|
1165
|
+
for (const [key, value] of Object.entries(options.extraEnv ?? {})) {
|
|
1166
|
+
if (value === undefined)
|
|
1167
|
+
delete env[key];
|
|
1168
|
+
else
|
|
1169
|
+
env[key] = value;
|
|
1170
|
+
}
|
|
1171
|
+
delete env.GIT_DIR;
|
|
1172
|
+
delete env.GIT_WORK_TREE;
|
|
1173
|
+
delete env.GIT_INDEX_FILE;
|
|
1174
|
+
delete env.GIT_OBJECT_DIRECTORY;
|
|
1175
|
+
if (options.indexFile)
|
|
1176
|
+
env.GIT_INDEX_FILE = options.indexFile;
|
|
1177
|
+
const context = inheritedContext();
|
|
1178
|
+
try {
|
|
1179
|
+
const { stdout, stderr } = await execGitForCaller(args, env, undefined, options.input);
|
|
1180
|
+
return { ok: true, stdout: stdout.toString('utf8'), stderr };
|
|
1181
|
+
}
|
|
1182
|
+
catch (e) {
|
|
1183
|
+
if (context?.signal.aborted || e?.name === 'AbortError')
|
|
1184
|
+
throw e;
|
|
1185
|
+
warnIfTimedOut(e, args);
|
|
1186
|
+
const failure = e?.spexcodeGitTimeout ? 'timeout' : typeof e?.code === 'number' ? 'exit' : 'spawn';
|
|
1187
|
+
return { ok: false, stdout: e?.stdout ?? '', stderr: e?.stderr ?? String(e?.message ?? e), failure };
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
// A walk whose OUTPUT is a projection the caller is intentionally building reads through the streamed
|
|
1191
|
+
// transport (no fixed stdout bound); `input` lets its revision roster ride stdin, so argv cannot grow with
|
|
1192
|
+
// the roster and needs no chunking.
|
|
1193
|
+
export async function gitRequiredA(args, purpose, options = {}) {
|
|
1194
|
+
const env = { ...process.env };
|
|
1195
|
+
for (const [key, value] of Object.entries(options.extraEnv ?? {})) {
|
|
1196
|
+
if (value === undefined)
|
|
1197
|
+
delete env[key];
|
|
1198
|
+
else
|
|
1199
|
+
env[key] = value;
|
|
1200
|
+
}
|
|
1201
|
+
delete env.GIT_DIR;
|
|
1202
|
+
delete env.GIT_WORK_TREE;
|
|
1203
|
+
delete env.GIT_INDEX_FILE;
|
|
1204
|
+
delete env.GIT_OBJECT_DIRECTORY;
|
|
1205
|
+
try {
|
|
1206
|
+
return (await execGitStreamForCaller(args, env, options.input)).stdout.toString('utf8');
|
|
1207
|
+
}
|
|
1208
|
+
catch (error) {
|
|
1209
|
+
if (error?.name === 'AbortError')
|
|
1210
|
+
throw error;
|
|
1211
|
+
warnIfTimedOut(error, args);
|
|
1212
|
+
throw new Error(`${purpose}: git ${args.slice(2).join(' ')} failed: ${String(error?.stderr || error?.message || 'unknown git error').trim()}`);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
// memoized: repoRoot is constant per process, but resolveLayout() calls it per request — avoid a git fork each time.
|
|
1216
|
+
let repoRootCache = null;
|
|
1217
|
+
export function repoRoot() {
|
|
1218
|
+
if (repoRootCache !== null)
|
|
1219
|
+
return repoRootCache;
|
|
1220
|
+
try {
|
|
1221
|
+
repoRootCache = git(['rev-parse', '--show-toplevel']).trim();
|
|
1222
|
+
}
|
|
1223
|
+
catch {
|
|
1224
|
+
repoRootCache = process.cwd();
|
|
1225
|
+
}
|
|
1226
|
+
return repoRootCache;
|
|
1227
|
+
}
|
|
1228
|
+
function gitDirOf(root) {
|
|
1229
|
+
// a normal checkout has a `.git` DIRECTORY; a linked worktree has a `.git` FILE: `gitdir: <path>`.
|
|
1230
|
+
const dotgit = join(root, '.git');
|
|
1231
|
+
if (!existsSync(dotgit)) {
|
|
1232
|
+
try {
|
|
1233
|
+
if (git(['-C', root, 'rev-parse', '--is-bare-repository']).trim() === 'true')
|
|
1234
|
+
return root;
|
|
1235
|
+
}
|
|
1236
|
+
catch { /* the caller will receive the same loud path error as a malformed checkout */ }
|
|
1237
|
+
throw new Error(`headSha: no .git directory at ${dotgit}`);
|
|
1238
|
+
}
|
|
1239
|
+
if (statSync(dotgit).isDirectory())
|
|
1240
|
+
return dotgit;
|
|
1241
|
+
const m = readFileSync(dotgit, 'utf8').match(/^gitdir:\s*(.+)$/m);
|
|
1242
|
+
if (!m)
|
|
1243
|
+
throw new Error(`headSha: unparseable .git file at ${dotgit}`);
|
|
1244
|
+
const dir = m[1].trim();
|
|
1245
|
+
return isAbsolute(dir) ? dir : resolve(root, dir);
|
|
1246
|
+
}
|
|
1247
|
+
function commonDirOf(gitDir) {
|
|
1248
|
+
// a worktree's gitdir holds per-worktree state (HEAD); SHARED refs (refs/heads/*, packed-refs) live
|
|
1249
|
+
// in the common dir, named by the `commondir` pointer. A plain checkout IS its own common dir.
|
|
1250
|
+
const p = join(gitDir, 'commondir');
|
|
1251
|
+
if (!existsSync(p))
|
|
1252
|
+
return gitDir;
|
|
1253
|
+
const c = readFileSync(p, 'utf8').trim();
|
|
1254
|
+
return isAbsolute(c) ? c : resolve(gitDir, c);
|
|
1255
|
+
}
|
|
1256
|
+
export function headSha(root) {
|
|
1257
|
+
const gitDir = gitDirOf(root);
|
|
1258
|
+
const head = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim();
|
|
1259
|
+
const ref = head.match(/^ref:\s*(.+)$/);
|
|
1260
|
+
if (!ref)
|
|
1261
|
+
return head; // detached HEAD: the file already holds the sha
|
|
1262
|
+
const name = ref[1].trim();
|
|
1263
|
+
// a loose ref wins over packed; per-worktree HEAD points at a branch whose ref lives in the common dir.
|
|
1264
|
+
const looseWt = join(gitDir, name);
|
|
1265
|
+
if (existsSync(looseWt))
|
|
1266
|
+
return readFileSync(looseWt, 'utf8').trim();
|
|
1267
|
+
const common = commonDirOf(gitDir);
|
|
1268
|
+
const loose = join(common, name);
|
|
1269
|
+
if (existsSync(loose))
|
|
1270
|
+
return readFileSync(loose, 'utf8').trim();
|
|
1271
|
+
const packed = join(common, 'packed-refs');
|
|
1272
|
+
if (existsSync(packed)) {
|
|
1273
|
+
for (const line of readFileSync(packed, 'utf8').split('\n')) {
|
|
1274
|
+
if (!line || line[0] === '#' || line[0] === '^')
|
|
1275
|
+
continue;
|
|
1276
|
+
const sp = line.indexOf(' ');
|
|
1277
|
+
if (sp > 0 && line.slice(sp + 1).trim() === name)
|
|
1278
|
+
return line.slice(0, sp).trim();
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
// an UNBORN HEAD — a fresh `git init` with no commits — points at a branch ref that doesn't exist yet.
|
|
1282
|
+
// That is a valid EMPTY-HISTORY state, not a failure: the board renders fine from the working tree. Return
|
|
1283
|
+
// a stable, truthy sentinel so historyIndex/driftIndex/safeHead MEMOIZE it (the head value is only ever a
|
|
1284
|
+
// cache key, never a git ref) instead of re-forking git on every read; headOrEmpty's warning is then
|
|
1285
|
+
// reserved for a genuinely unreadable HEAD and never fires for this routine first-run state.
|
|
1286
|
+
return `unborn:${name}`;
|
|
1287
|
+
}
|
|
1288
|
+
function isUnbornHistoryTip(tip) { return tip.startsWith('unborn:'); }
|
|
1289
|
+
// fingerprint of a worktree's `.spec` working tree by path + mtimeMs + size (no git); the overlay-cache
|
|
1290
|
+
// key for its working-tree state. '' when `.spec` is absent.
|
|
1291
|
+
export function worktreeSpecSig(wtPath) {
|
|
1292
|
+
const root = join(wtPath, '.spec');
|
|
1293
|
+
if (!existsSync(root))
|
|
1294
|
+
return '';
|
|
1295
|
+
const parts = [];
|
|
1296
|
+
const stack = [root];
|
|
1297
|
+
while (stack.length) {
|
|
1298
|
+
const dir = stack.pop();
|
|
1299
|
+
let ents;
|
|
1300
|
+
try {
|
|
1301
|
+
ents = readdirSync(dir, { withFileTypes: true });
|
|
1302
|
+
}
|
|
1303
|
+
catch {
|
|
1304
|
+
continue;
|
|
1305
|
+
}
|
|
1306
|
+
for (const e of ents) {
|
|
1307
|
+
const p = join(dir, e.name);
|
|
1308
|
+
if (e.isDirectory()) {
|
|
1309
|
+
stack.push(p);
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
try {
|
|
1313
|
+
const st = statSync(p);
|
|
1314
|
+
parts.push(`${p}:${st.mtimeMs}:${st.size}`);
|
|
1315
|
+
}
|
|
1316
|
+
catch { /* vanished mid-walk */ }
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
return parts.sort().join('\n');
|
|
1320
|
+
}
|
|
1321
|
+
function emptyHistoryIndex() {
|
|
1322
|
+
return { versions: new Map(), contentVersions: new Set(), versionPaths: new Map(), mergeVersions: new Set() };
|
|
1323
|
+
}
|
|
1324
|
+
function isRawObjectId(value, format) {
|
|
1325
|
+
const length = format === 'sha256' ? 64 : 40;
|
|
1326
|
+
return isGitObjectIdForFormat(format, value) || (value.length === length && /^0+$/.test(value));
|
|
1327
|
+
}
|
|
1328
|
+
function parseIdentityRawEventRecords(out, location) {
|
|
1329
|
+
const parsed = [];
|
|
1330
|
+
let current = null;
|
|
1331
|
+
const tokens = out.split('\0');
|
|
1332
|
+
let index = 0;
|
|
1333
|
+
const metadata = (field) => {
|
|
1334
|
+
const token = tokens[index++];
|
|
1335
|
+
if (token === undefined)
|
|
1336
|
+
throw new Error(`raw identity stream ended before commit ${field}`);
|
|
1337
|
+
return token;
|
|
1338
|
+
};
|
|
1339
|
+
const begin = (token) => {
|
|
1340
|
+
const header = token.startsWith('\n') ? token.slice(1) : token;
|
|
1341
|
+
if (!header.startsWith(RS))
|
|
1342
|
+
throw new Error(`raw identity stream expected a commit header, got '${header}'`);
|
|
1343
|
+
const hash = header.slice(RS.length);
|
|
1344
|
+
if (!isGitObjectIdForFormat(location.objectFormat, hash))
|
|
1345
|
+
throw new Error(`history event stream 'identity-raw' returned malformed object id '${hash || 'empty'}'`);
|
|
1346
|
+
if (current)
|
|
1347
|
+
parsed.push(current);
|
|
1348
|
+
const date = metadata('date'), reason = metadata('subject'), body = metadata('body'), ack = metadata('trailers');
|
|
1349
|
+
const session = body.match(/Session:\s*(\S+)/);
|
|
1350
|
+
return { h: hash, d: date, r: reason, s: session ? session[1] : null, a: ack, c: [] };
|
|
1351
|
+
};
|
|
1352
|
+
while (index < tokens.length) {
|
|
1353
|
+
const token = tokens[index++];
|
|
1354
|
+
if (!current) {
|
|
1355
|
+
if (!token)
|
|
1356
|
+
continue;
|
|
1357
|
+
current = begin(token);
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1360
|
+
const value = token.startsWith('\n') ? token.slice(1) : token;
|
|
1361
|
+
if (!value)
|
|
1362
|
+
continue;
|
|
1363
|
+
if (value.startsWith(RS)) {
|
|
1364
|
+
current = begin(value);
|
|
1365
|
+
continue;
|
|
1366
|
+
}
|
|
1367
|
+
const raw = value.match(/^:([0-7]{6}) ([0-7]{6}) ([0-9a-f]+) ([0-9a-f]+) ([A-Z])(?:\d+)?$/);
|
|
1368
|
+
if (!raw || !isRawObjectId(raw[3], location.objectFormat) || !isRawObjectId(raw[4], location.objectFormat))
|
|
1369
|
+
throw new Error(`raw identity event ${current.h} has malformed raw record '${value}'`);
|
|
1370
|
+
const from = tokens[index++];
|
|
1371
|
+
if (from === undefined)
|
|
1372
|
+
throw new Error(`raw identity event ${current.h} ended before its path`);
|
|
1373
|
+
const to = raw[5] === 'R' ? tokens[index++] : from;
|
|
1374
|
+
if (to === undefined)
|
|
1375
|
+
throw new Error(`raw identity event ${current.h} ended before its rename destination`);
|
|
1376
|
+
current.c.push([raw[5], from, to, raw[3], raw[4]]);
|
|
1377
|
+
}
|
|
1378
|
+
if (current)
|
|
1379
|
+
parsed.push(current);
|
|
1380
|
+
return parsed.map((identity) => ({ hash: identity.h, identity }));
|
|
1381
|
+
}
|
|
1382
|
+
function decodeIdentityRawRecord(value, hash, location) {
|
|
1383
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
1384
|
+
return null;
|
|
1385
|
+
const record = value;
|
|
1386
|
+
if (!exactKeys(record, ['a', 'c', 'd', 'r', 's']) || typeof record.a !== 'string' || typeof record.d !== 'string'
|
|
1387
|
+
|| typeof record.r !== 'string' || (typeof record.s !== 'string' && record.s !== null) || !Array.isArray(record.c) || record.c.length % 5)
|
|
1388
|
+
return null;
|
|
1389
|
+
const changes = [];
|
|
1390
|
+
for (let index = 0; index < record.c.length; index += 5) {
|
|
1391
|
+
if (typeof record.c[index] !== 'string' || typeof record.c[index + 1] !== 'string' || typeof record.c[index + 2] !== 'string'
|
|
1392
|
+
|| typeof record.c[index + 3] !== 'string' || typeof record.c[index + 4] !== 'string'
|
|
1393
|
+
|| !/^[A-Z](?:\d+)?$/.test(record.c[index]) || !isRawObjectId(record.c[index + 3], location.objectFormat)
|
|
1394
|
+
|| !isRawObjectId(record.c[index + 4], location.objectFormat))
|
|
1395
|
+
return null;
|
|
1396
|
+
changes.push([record.c[index], record.c[index + 1], record.c[index + 2], record.c[index + 3], record.c[index + 4]]);
|
|
1397
|
+
}
|
|
1398
|
+
return { h: hash, d: record.d, r: record.r, s: record.s, a: record.a, c: changes };
|
|
1399
|
+
}
|
|
1400
|
+
// Both bulk indices are pure functions of a checkout's HEAD, and they are read for SEVERAL roots at
|
|
1401
|
+
// once — the backend checkout (board, loadSpecs) plus every session worktree ([[session-eval]]'s eval
|
|
1402
|
+
// tab roots its readings at the session's branch). A single-slot cache thrashes between those roots:
|
|
1403
|
+
// each eval-tab request evicts the board's entry and vice versa, so every request re-runs a full-history
|
|
1404
|
+
// `git log` and re-parses it on the event loop — which is what starves every other request (the board,
|
|
1405
|
+
// remark posts) under load. So the cache is a small LRU keyed by interpretation identity + HEAD, holding
|
|
1406
|
+
// the in-flight PROMISE so concurrent requests for one immutable view share a single build.
|
|
1407
|
+
const indexCache = new Map();
|
|
1408
|
+
const indexRoots = new Map();
|
|
1409
|
+
const driftRoots = new Map();
|
|
1410
|
+
const driftIdxCache = new Map();
|
|
1411
|
+
const INDEX_ROOT_SLOTS = rootSlots(process.env.SPEXCODE_INDEX_CACHE_ROOTS, 32);
|
|
1412
|
+
function rootKey(root) { return resolve(root); }
|
|
1413
|
+
function indexCacheKey(root, head) { return `${eventCacheLocation(root).path}\0${head}`; }
|
|
1414
|
+
// A project-namespaced ledger path plus HEAD identifies immutable index contents. Its path is scoped to the
|
|
1415
|
+
// common Git store and interpretation state, so linked worktrees share while independent same-HEAD clones do not.
|
|
1416
|
+
// The checkout root owns which live view is useful; the root bound keeps closed worktrees from leaking.
|
|
1417
|
+
// The bookkeeping itself is [[root-lru]]'s — this only names the store and the bound.
|
|
1418
|
+
function touchRoot(roots, cache, root, cacheKey) {
|
|
1419
|
+
touchRootLru(roots, cache, rootKey(root), cacheKey, INDEX_ROOT_SLOTS);
|
|
1420
|
+
}
|
|
1421
|
+
function dropFailed(cache, head, promise) {
|
|
1422
|
+
if (cache.get(head) !== promise)
|
|
1423
|
+
return;
|
|
1424
|
+
// A rejected index is never reusable, even when its root still points at that HEAD. The next read must
|
|
1425
|
+
// start a fresh walk after a watchdog abort or transient git failure.
|
|
1426
|
+
cache.delete(head);
|
|
1427
|
+
}
|
|
1428
|
+
function pendingOnlyIssues(root, tip) {
|
|
1429
|
+
try {
|
|
1430
|
+
const parents = git(['-C', root, 'rev-list', '--parents', '-n1', tip]).trim().split(/\s+/).filter(Boolean);
|
|
1431
|
+
// A first-parent issue diff can hide side-branch code debt in an ours merge. Only a single-parent
|
|
1432
|
+
// candidate may reuse its parent's indexes; every merge must build the candidate's full reachable view.
|
|
1433
|
+
if (parents.length !== 2)
|
|
1434
|
+
return false;
|
|
1435
|
+
const parent = git(['-C', root, 'rev-parse', `${tip}^`]).trim();
|
|
1436
|
+
const paths = git(['-C', root, 'diff-tree', '--no-commit-id', '--name-only', '-r', parent, tip])
|
|
1437
|
+
.split('\n').map((p) => p.trim()).filter(Boolean);
|
|
1438
|
+
return paths.length > 0 && paths.every((p) => p.startsWith('.spec/.issues/'));
|
|
1439
|
+
}
|
|
1440
|
+
catch {
|
|
1441
|
+
return false;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
function pendingParent(root, tip) {
|
|
1445
|
+
try {
|
|
1446
|
+
const parent = git(['-C', root, 'rev-parse', `${tip}^`]).trim();
|
|
1447
|
+
return isGitObjectId(root, parent) ? parent : null;
|
|
1448
|
+
}
|
|
1449
|
+
catch {
|
|
1450
|
+
return null;
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
export function historyIndex(root, tip = 'HEAD') {
|
|
1454
|
+
if (tip !== 'HEAD') {
|
|
1455
|
+
const resolved = git(['-C', root, 'rev-parse', `${tip}^{commit}`]).trim();
|
|
1456
|
+
// An issue-only candidate changes no governed content, but its lint result still includes the
|
|
1457
|
+
// parent's existing drift/related-drift warnings. Reuse that immutable parent index instead of
|
|
1458
|
+
// manufacturing an empty one (which silently dropped warnings at golden depths).
|
|
1459
|
+
if (pendingOnlyIssues(root, resolved)) {
|
|
1460
|
+
const parent = pendingParent(root, resolved);
|
|
1461
|
+
if (parent) {
|
|
1462
|
+
const head = headOrEmpty(root);
|
|
1463
|
+
if (head === parent)
|
|
1464
|
+
return historyIndex(root);
|
|
1465
|
+
return buildIndex(root, parent, true, true);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
return buildIndex(root, resolved, true, true);
|
|
1469
|
+
}
|
|
1470
|
+
const head = headOrEmpty(root);
|
|
1471
|
+
if (!head)
|
|
1472
|
+
return buildIndex(root, 'HEAD', false, true);
|
|
1473
|
+
const cacheKey = indexCacheKey(root, head);
|
|
1474
|
+
touchRoot(indexRoots, indexCache, root, cacheKey);
|
|
1475
|
+
const hit = indexCache.get(cacheKey);
|
|
1476
|
+
if (hit)
|
|
1477
|
+
return hit;
|
|
1478
|
+
const p = buildIndex(root, head, false, true);
|
|
1479
|
+
p.catch(() => { dropFailed(indexCache, cacheKey, p); }); // don't pin a failed build
|
|
1480
|
+
indexCache.set(cacheKey, p);
|
|
1481
|
+
return p;
|
|
1482
|
+
}
|
|
1483
|
+
// resolve HEAD for cache-keying, '' if unreadable (fails the cache test → recompute); warns once.
|
|
1484
|
+
let headWarned = false;
|
|
1485
|
+
function headOrEmpty(root) {
|
|
1486
|
+
try {
|
|
1487
|
+
return headSha(root);
|
|
1488
|
+
}
|
|
1489
|
+
catch (e) {
|
|
1490
|
+
if (!headWarned) {
|
|
1491
|
+
headWarned = true;
|
|
1492
|
+
console.warn(`spec-cli: headSha failed, recomputing every read: ${e.message}`);
|
|
1493
|
+
}
|
|
1494
|
+
return '';
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
// @@@ reachability memoized on the rename side, never the event side - every comparison the projector makes
|
|
1498
|
+
// has a RENAME commit on one end, and a history holds far fewer renames than file events. Keying the memo on
|
|
1499
|
+
// the OTHER end rebuilds a history-wide ancestor set per distinct event commit: 2.3M parent-edge visits and
|
|
1500
|
+
// 1,219 retained bitsets served 9k one-bit questions on this repository. That end builds one closure per
|
|
1501
|
+
// distinct event commit actually compared against a rename — C of them, O(C(H+G)) construction and Θ(CH) bits
|
|
1502
|
+
// — which a linear history whose events all sit on one renamed path drives to Θ(H²). Asking the rename end
|
|
1503
|
+
// instead — its descendants when it is the older commit, its ancestors when it is the newer one — moves ONLY
|
|
1504
|
+
// the closure term onto the rename count K: at most 2K full-size closures, O(K(H+G)) construction over H
|
|
1505
|
+
// reachable commits and G parent edges, O(KH) bits. That 2K ceiling bounds closure buffers, count and bytes,
|
|
1506
|
+
// against C; it does NOT bound runtime or edge visits, since a rename with many unrelated descendants
|
|
1507
|
+
// traverses ground the event-side ancestor walk never touched. The projector's own work is unchanged and is
|
|
1508
|
+
// NOT covered by that term: one scan of the N events, plus a lineage walk whose frontier compares each step's
|
|
1509
|
+
// applicable renames pairwise — Σ d(candidate)² O(1) queries, worst case Θ(NK²) when one path carries K
|
|
1510
|
+
// mutually incomparable renames. So: no linear-in-history promise — at K≈H the closure term is O(H(H+G)),
|
|
1511
|
+
// quadratic only where the DAG is sparse enough that G=O(H), and the untouched frontier term is cubic when
|
|
1512
|
+
// N, K and H grow together. It is also why a
|
|
1513
|
+
// reachability matrix over the rename commits is not worth it: same O(KH) bits, but eagerly.
|
|
1514
|
+
function renameSideReachability(renameCommits, topology) {
|
|
1515
|
+
const { order, parents } = topology;
|
|
1516
|
+
const size = (order.size + 7) >> 3;
|
|
1517
|
+
let childEdges = null;
|
|
1518
|
+
const children = () => {
|
|
1519
|
+
if (childEdges)
|
|
1520
|
+
return childEdges;
|
|
1521
|
+
childEdges = new Map();
|
|
1522
|
+
for (const [hash] of order)
|
|
1523
|
+
for (const parent of parents.get(hash) ?? []) {
|
|
1524
|
+
if (!order.has(parent))
|
|
1525
|
+
continue; // shallow boundary: an unwalked parent ends the chain
|
|
1526
|
+
const kids = childEdges.get(parent);
|
|
1527
|
+
if (kids)
|
|
1528
|
+
kids.push(hash);
|
|
1529
|
+
else
|
|
1530
|
+
childEdges.set(parent, [hash]);
|
|
1531
|
+
}
|
|
1532
|
+
return childEdges;
|
|
1533
|
+
};
|
|
1534
|
+
const closure = (start, edges, memo) => {
|
|
1535
|
+
const hit = memo.get(start);
|
|
1536
|
+
if (hit)
|
|
1537
|
+
return hit;
|
|
1538
|
+
const bits = new Uint8Array(size);
|
|
1539
|
+
const at = order.get(start);
|
|
1540
|
+
bits[at >> 3] |= 1 << (at & 7);
|
|
1541
|
+
const stack = [start];
|
|
1542
|
+
while (stack.length)
|
|
1543
|
+
for (const next of edges.get(stack.pop()) ?? []) {
|
|
1544
|
+
const position = order.get(next);
|
|
1545
|
+
if (position === undefined)
|
|
1546
|
+
continue;
|
|
1547
|
+
const mask = 1 << (position & 7);
|
|
1548
|
+
if (bits[position >> 3] & mask)
|
|
1549
|
+
continue;
|
|
1550
|
+
bits[position >> 3] |= mask;
|
|
1551
|
+
stack.push(next);
|
|
1552
|
+
}
|
|
1553
|
+
memo.set(start, bits);
|
|
1554
|
+
return bits;
|
|
1555
|
+
};
|
|
1556
|
+
const ancestors = new Map(), descendants = new Map();
|
|
1557
|
+
return (older, newer) => {
|
|
1558
|
+
const from = order.get(older), to = order.get(newer);
|
|
1559
|
+
if (from === undefined || to === undefined)
|
|
1560
|
+
throw new Error(`rename projection cannot place ${older} against ${newer} in the current topology`);
|
|
1561
|
+
// Whichever end is the rename owns the closure; the projector always puts one there.
|
|
1562
|
+
return renameCommits.has(older)
|
|
1563
|
+
? (closure(older, children(), descendants)[to >> 3] & (1 << (to & 7))) !== 0
|
|
1564
|
+
: (closure(newer, parents, ancestors)[from >> 3] & (1 << (from & 7))) !== 0;
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
function canonicalPathProjector(renamesByFrom, topology) {
|
|
1568
|
+
const renameCommits = new Set();
|
|
1569
|
+
for (const renames of renamesByFrom.values())
|
|
1570
|
+
for (const rename of renames)
|
|
1571
|
+
renameCommits.add(rename.hash);
|
|
1572
|
+
const precedes = renameSideReachability(renameCommits, topology);
|
|
1573
|
+
return (path, event) => {
|
|
1574
|
+
// A path no rename ever left keeps its own identity at every event; there is no lineage to walk.
|
|
1575
|
+
if (!renamesByFrom.has(path))
|
|
1576
|
+
return [path];
|
|
1577
|
+
const pending = [path], resolved = new Set(), seen = new Set();
|
|
1578
|
+
while (pending.length) {
|
|
1579
|
+
const candidate = pending.pop();
|
|
1580
|
+
if (seen.has(candidate)) {
|
|
1581
|
+
resolved.add(candidate);
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
1584
|
+
seen.add(candidate);
|
|
1585
|
+
const unique = new Map();
|
|
1586
|
+
for (const rename of renamesByFrom.get(candidate) ?? []) {
|
|
1587
|
+
if (!precedes(rename.hash, event))
|
|
1588
|
+
unique.set(`${rename.hash}\0${rename.to}`, rename);
|
|
1589
|
+
}
|
|
1590
|
+
const applicable = [...unique.values()];
|
|
1591
|
+
if (!applicable.length) {
|
|
1592
|
+
resolved.add(candidate);
|
|
1593
|
+
continue;
|
|
1594
|
+
}
|
|
1595
|
+
// The earliest applicable rename starts this path's next lineage epoch. Later renames from the same
|
|
1596
|
+
// path belong to a recreated path, not to an event that predates the first boundary. Incomparable
|
|
1597
|
+
// rename branches are both frontier members, so their identities still fork at the merge. A merge can
|
|
1598
|
+
// expose the same rename once per parent; equal-commit rows are peers, never ancestors of one another.
|
|
1599
|
+
const frontier = applicable.filter((rename, index) => !applicable.some((other, otherIndex) => otherIndex !== index
|
|
1600
|
+
&& other.hash !== rename.hash && precedes(other.hash, rename.hash)));
|
|
1601
|
+
if (frontier.some((rename) => !precedes(event, rename.hash)))
|
|
1602
|
+
resolved.add(candidate);
|
|
1603
|
+
for (const rename of frontier)
|
|
1604
|
+
pending.push(rename.to);
|
|
1605
|
+
}
|
|
1606
|
+
return [...resolved];
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
async function buildIndex(root, tip, transient, useCache = true, shared) {
|
|
1610
|
+
if (isUnbornHistoryTip(tip))
|
|
1611
|
+
return emptyHistoryIndex();
|
|
1612
|
+
const versions = new Map();
|
|
1613
|
+
const contentVersions = new Set();
|
|
1614
|
+
const versionPaths = new Map();
|
|
1615
|
+
const mergeVersions = new Set();
|
|
1616
|
+
const commitVersions = new Map();
|
|
1617
|
+
const commitOrder = new Map();
|
|
1618
|
+
const rawVersions = new Map();
|
|
1619
|
+
let currentPaths;
|
|
1620
|
+
let topology;
|
|
1621
|
+
if (shared) {
|
|
1622
|
+
currentPaths = shared.specPaths;
|
|
1623
|
+
topology = shared.topology;
|
|
1624
|
+
}
|
|
1625
|
+
else {
|
|
1626
|
+
const [tipPathsOut, topologyOut] = await Promise.all([
|
|
1627
|
+
strictEventGit(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--name-only', tip, '--', '.spec']),
|
|
1628
|
+
strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
|
|
1629
|
+
]);
|
|
1630
|
+
currentPaths = new Set(tipPathsOut.split('\0').filter((path) => path.startsWith('.spec/')));
|
|
1631
|
+
topology = topologyProjection(topologyOut);
|
|
1632
|
+
}
|
|
1633
|
+
const renamesByFrom = new Map();
|
|
1634
|
+
const topologyOrd = topology.order;
|
|
1635
|
+
const topologyParents = topology.parents;
|
|
1636
|
+
const topologyReachable = topology.reachable;
|
|
1637
|
+
const identityRecords = shared?.identityRecords ?? await identityRawEventStream(root, tip, indexEventRequests(root, tip, topologyOrd, topologyReachable)['identity-raw'], !transient, useCache);
|
|
1638
|
+
if (!identityRecords.length)
|
|
1639
|
+
return { versions, contentVersions, versionPaths, mergeVersions };
|
|
1640
|
+
let commitPosition = 0;
|
|
1641
|
+
const rawContent = new Set();
|
|
1642
|
+
for (const record of identityRecords) {
|
|
1643
|
+
const version = { hash: record.h, date: record.d, reason: record.r, session: record.s };
|
|
1644
|
+
commitVersions.set(record.h, version);
|
|
1645
|
+
if (!commitOrder.has(record.h))
|
|
1646
|
+
commitOrder.set(record.h, commitPosition++);
|
|
1647
|
+
for (const [, from, to, oldOid, newOid] of record.c) {
|
|
1648
|
+
if (!from.startsWith('.spec/') && !to.startsWith('.spec/'))
|
|
1649
|
+
continue;
|
|
1650
|
+
if (!rawVersions.has(to))
|
|
1651
|
+
rawVersions.set(to, []);
|
|
1652
|
+
rawVersions.get(to).push(version);
|
|
1653
|
+
if (oldOid !== newOid)
|
|
1654
|
+
rawContent.add(`${to}\0${record.h}`);
|
|
1655
|
+
if (from !== to) {
|
|
1656
|
+
const renames = renamesByFrom.get(from) ?? [];
|
|
1657
|
+
renames.push({ hash: record.h, to });
|
|
1658
|
+
renamesByFrom.set(from, renames);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
// Re-establish Git's date-order contract after records are parsed from a stream. An independent commit
|
|
1663
|
+
// can appear before its child in a date-ordered result; repair that partial-order constraint with a stable
|
|
1664
|
+
// topological pass. A comparator that
|
|
1665
|
+
// materializes an ancestor Set/bitset for every sort comparison turns a 4k-commit history into an
|
|
1666
|
+
// accidental quadratic walk; this queue visits each commit and parent edge once.
|
|
1667
|
+
const originalOrder = new Map(commitOrder);
|
|
1668
|
+
const childrenLeft = new Map();
|
|
1669
|
+
for (const hash of commitVersions.keys())
|
|
1670
|
+
childrenLeft.set(hash, 0);
|
|
1671
|
+
for (const hash of commitVersions.keys())
|
|
1672
|
+
for (const parent of topologyParents.get(hash) ?? [])
|
|
1673
|
+
if (childrenLeft.has(parent))
|
|
1674
|
+
childrenLeft.set(parent, childrenLeft.get(parent) + 1);
|
|
1675
|
+
const heap = [];
|
|
1676
|
+
const before = (a, b) => (originalOrder.get(a) ?? Number.MAX_SAFE_INTEGER) - (originalOrder.get(b) ?? Number.MAX_SAFE_INTEGER);
|
|
1677
|
+
const push = (hash) => {
|
|
1678
|
+
let i = heap.push(hash) - 1;
|
|
1679
|
+
while (i > 0) {
|
|
1680
|
+
const parent = (i - 1) >> 1;
|
|
1681
|
+
if (before(heap[parent], hash) <= 0)
|
|
1682
|
+
break;
|
|
1683
|
+
heap[i] = heap[parent];
|
|
1684
|
+
i = parent;
|
|
1685
|
+
}
|
|
1686
|
+
heap[i] = hash;
|
|
1687
|
+
};
|
|
1688
|
+
const pop = () => {
|
|
1689
|
+
const first = heap[0], last = heap.pop();
|
|
1690
|
+
if (heap.length) {
|
|
1691
|
+
let i = 0;
|
|
1692
|
+
while (true) {
|
|
1693
|
+
const left = i * 2 + 1;
|
|
1694
|
+
if (left >= heap.length)
|
|
1695
|
+
break;
|
|
1696
|
+
let child = left;
|
|
1697
|
+
const right = left + 1;
|
|
1698
|
+
if (right < heap.length && before(heap[right], heap[left]) < 0)
|
|
1699
|
+
child = right;
|
|
1700
|
+
if (before(heap[child], last) >= 0)
|
|
1701
|
+
break;
|
|
1702
|
+
heap[i] = heap[child];
|
|
1703
|
+
i = child;
|
|
1704
|
+
}
|
|
1705
|
+
heap[i] = last;
|
|
1706
|
+
}
|
|
1707
|
+
return first;
|
|
1708
|
+
};
|
|
1709
|
+
for (const [hash, left] of childrenLeft)
|
|
1710
|
+
if (left === 0)
|
|
1711
|
+
push(hash);
|
|
1712
|
+
const ordered = [];
|
|
1713
|
+
while (heap.length) {
|
|
1714
|
+
const hash = pop();
|
|
1715
|
+
ordered.push(hash);
|
|
1716
|
+
for (const parent of topologyParents.get(hash) ?? []) {
|
|
1717
|
+
if (!childrenLeft.has(parent))
|
|
1718
|
+
continue;
|
|
1719
|
+
const left = childrenLeft.get(parent) - 1;
|
|
1720
|
+
childrenLeft.set(parent, left);
|
|
1721
|
+
if (left === 0)
|
|
1722
|
+
push(parent);
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
// A shallow or malformed topology should fail closed rather than silently dropping a version row.
|
|
1726
|
+
if (ordered.length !== commitVersions.size)
|
|
1727
|
+
for (const hash of commitVersions.keys())
|
|
1728
|
+
if (!ordered.includes(hash))
|
|
1729
|
+
ordered.push(hash);
|
|
1730
|
+
commitOrder.clear();
|
|
1731
|
+
ordered.forEach((hash, i) => commitOrder.set(hash, i));
|
|
1732
|
+
const mergeIndex = shared?.mergeIndex
|
|
1733
|
+
?? await mergeHistoryEvents(root, tip, transient, topologyOrd, topologyReachable, useCache);
|
|
1734
|
+
for (const rename of mergeIndex.renames) {
|
|
1735
|
+
const renames = renamesByFrom.get(rename.from) ?? [];
|
|
1736
|
+
renames.push({ hash: rename.hash, to: rename.to });
|
|
1737
|
+
renamesByFrom.set(rename.from, renames);
|
|
1738
|
+
}
|
|
1739
|
+
const canonical = canonicalPathProjector(renamesByFrom, topology);
|
|
1740
|
+
for (const [path, pathRows] of rawVersions) {
|
|
1741
|
+
for (const row of pathRows)
|
|
1742
|
+
for (const head of canonical(path, row.hash).filter((candidate) => currentPaths.has(candidate))) {
|
|
1743
|
+
const rows = versions.get(head) ?? [];
|
|
1744
|
+
if (!rows.some((existing) => existing.hash === row.hash))
|
|
1745
|
+
rows.push(row);
|
|
1746
|
+
versions.set(head, rows);
|
|
1747
|
+
const key = `${head}\0${row.hash}`;
|
|
1748
|
+
if (rawContent.has(`${path}\0${row.hash}`))
|
|
1749
|
+
contentVersions.add(key);
|
|
1750
|
+
if (!versionPaths.has(key))
|
|
1751
|
+
versionPaths.set(key, path);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
for (const [path, mergeEvents] of mergeIndex.resolutions) {
|
|
1755
|
+
if (!isSpecMd(path))
|
|
1756
|
+
continue;
|
|
1757
|
+
for (const { hash } of mergeEvents)
|
|
1758
|
+
for (const head of canonical(path, hash)) {
|
|
1759
|
+
const rows = versions.get(head) ?? [];
|
|
1760
|
+
const version = commitVersions.get(hash);
|
|
1761
|
+
if (!version || rows.some((row) => row.hash === hash))
|
|
1762
|
+
continue;
|
|
1763
|
+
rows.push(version);
|
|
1764
|
+
const key = `${head}\0${hash}`;
|
|
1765
|
+
mergeVersions.add(key);
|
|
1766
|
+
if (!versionPaths.has(key))
|
|
1767
|
+
versionPaths.set(key, path);
|
|
1768
|
+
versions.set(head, rows);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
for (const rows of versions.values()) {
|
|
1772
|
+
rows.sort((a, b) => (commitOrder.get(a.hash) ?? Number.MAX_SAFE_INTEGER) - (commitOrder.get(b.hash) ?? Number.MAX_SAFE_INTEGER));
|
|
1773
|
+
}
|
|
1774
|
+
return { versions, contentVersions, versionPaths, mergeVersions };
|
|
1775
|
+
}
|
|
1776
|
+
// Pure lookups over a prebuilt index. Blob identity decides whether a one-parent row is a content version;
|
|
1777
|
+
// numstat remains display data and must not let attributes erase a version window.
|
|
1778
|
+
export function rowsFor(idx, relPath) {
|
|
1779
|
+
const rows = idx.versions.get(relPath) ?? [];
|
|
1780
|
+
return rows.filter((v) => idx.contentVersions.has(`${relPath}\0${v.hash}`) || idx.mergeVersions?.has(`${relPath}\0${v.hash}`));
|
|
1781
|
+
}
|
|
1782
|
+
// per-commit numstat summed over a SET of paths in one `git log` walk. No `--follow` (it takes a single
|
|
1783
|
+
// path), so no rename-tracking — same as the old `git show -- paths`; spec.md gets renames via the bulk index.
|
|
1784
|
+
export async function pathsStats(root, paths) {
|
|
1785
|
+
const m = new Map();
|
|
1786
|
+
if (!paths.length)
|
|
1787
|
+
return m;
|
|
1788
|
+
const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'log', '--format=%H', '--numstat', '--', ...paths]);
|
|
1789
|
+
if (!out)
|
|
1790
|
+
return m;
|
|
1791
|
+
let cur = '';
|
|
1792
|
+
for (const line of out.split('\n')) {
|
|
1793
|
+
const t = line.trim();
|
|
1794
|
+
if (isGitObjectId(root, t)) {
|
|
1795
|
+
cur = t;
|
|
1796
|
+
continue;
|
|
1797
|
+
}
|
|
1798
|
+
const n = line.match(/^(\d+|-)\t(\d+|-)\t/);
|
|
1799
|
+
if (n && cur) {
|
|
1800
|
+
const s = m.get(cur) ?? { additions: 0, deletions: 0, files: 0 };
|
|
1801
|
+
s.files++;
|
|
1802
|
+
s.additions += n[1] === '-' ? 0 : +n[1];
|
|
1803
|
+
s.deletions += n[2] === '-' ? 0 : +n[2];
|
|
1804
|
+
m.set(cur, s);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
return m;
|
|
1808
|
+
}
|
|
1809
|
+
// History display stats are intentionally read only for the selected node: their text interpretation may
|
|
1810
|
+
// depend on working-tree attributes, so they cannot live in the shared immutable event ledger. One diff-tree
|
|
1811
|
+
// batch preserves every selected commit's historical path without turning a history page into N processes.
|
|
1812
|
+
export async function historyStats(root, idx, relPath) {
|
|
1813
|
+
const rows = rowsFor(idx, relPath);
|
|
1814
|
+
if (!rows.length)
|
|
1815
|
+
return new Map();
|
|
1816
|
+
const paths = new Map(rows.map((row) => [row.hash, idx.versionPaths.get(`${relPath}\0${row.hash}`) ?? relPath]));
|
|
1817
|
+
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`);
|
|
1818
|
+
const stats = new Map();
|
|
1819
|
+
for (const record of out.split(RS)) {
|
|
1820
|
+
const lines = record.replace(/^\n/, '').split('\n');
|
|
1821
|
+
const hash = lines.shift()?.trim() ?? '';
|
|
1822
|
+
const path = paths.get(hash);
|
|
1823
|
+
if (!path)
|
|
1824
|
+
continue;
|
|
1825
|
+
const stat = { additions: 0, deletions: 0, files: 0 };
|
|
1826
|
+
for (const line of lines) {
|
|
1827
|
+
const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
|
|
1828
|
+
if (!match || parseStatPath(match[3]).to !== path)
|
|
1829
|
+
continue;
|
|
1830
|
+
stat.files++;
|
|
1831
|
+
stat.additions += match[1] === '-' ? 0 : Number(match[1]);
|
|
1832
|
+
stat.deletions += match[2] === '-' ? 0 : Number(match[2]);
|
|
1833
|
+
}
|
|
1834
|
+
stats.set(hash, stat);
|
|
1835
|
+
}
|
|
1836
|
+
return stats;
|
|
1837
|
+
}
|
|
1838
|
+
// the patch a spec.md got in one commit (vs parent); resolve its path AT that commit (reparents move it)
|
|
1839
|
+
// via the stable leaf dir `…/<id>/spec.md`, then `git show` that path. `-M` keeps a rename+edit's body. '' on error.
|
|
1840
|
+
export async function fileDiffAt(root, relPath, hash) {
|
|
1841
|
+
if (!hash || !relPath.endsWith('/spec.md'))
|
|
1842
|
+
return '';
|
|
1843
|
+
const leaf = relPath.slice(relPath.lastIndexOf('/', relPath.length - '/spec.md'.length - 1) + 1); // `<id>/spec.md`
|
|
1844
|
+
const names = await gitA(['-C', root, '-c', 'core.quotePath=false', 'show', '--name-only', '--format=', '-M', hash]);
|
|
1845
|
+
const at = names.split('\n').map((s) => s.trim()).find((p) => p.endsWith('/' + leaf) || p === leaf) ?? relPath;
|
|
1846
|
+
return gitA(['-C', root, '-c', 'core.quotePath=false', 'show', '-M', '--format=', hash, '--', at]);
|
|
1847
|
+
}
|
|
1848
|
+
function emptyDriftIndex() {
|
|
1849
|
+
return {
|
|
1850
|
+
ord: new Map(), parents: new Map(), fileEvents: new Map(), lineageEvents: new Map(),
|
|
1851
|
+
lineageKeys: (path) => [path], resolutionEvents: new Map(), acks: new Map(), selfAcks: new Map(),
|
|
1852
|
+
specNodes: new Map(), anc: new Map(),
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
function decodeGitCPath(value) {
|
|
1856
|
+
if (!value.startsWith('"'))
|
|
1857
|
+
return value;
|
|
1858
|
+
if (value.length < 2 || !value.endsWith('"'))
|
|
1859
|
+
throw new Error(`malformed Git C-quoted path '${value}'`);
|
|
1860
|
+
const bytes = [];
|
|
1861
|
+
const plain = (part) => bytes.push(...Buffer.from(part, 'utf8'));
|
|
1862
|
+
for (let index = 1; index < value.length - 1;) {
|
|
1863
|
+
const point = value.codePointAt(index);
|
|
1864
|
+
const char = String.fromCodePoint(point);
|
|
1865
|
+
index += char.length;
|
|
1866
|
+
if (char !== '\\') {
|
|
1867
|
+
plain(char);
|
|
1868
|
+
continue;
|
|
1869
|
+
}
|
|
1870
|
+
const escaped = value[index++];
|
|
1871
|
+
if (escaped === undefined)
|
|
1872
|
+
throw new Error(`malformed Git C-quoted path '${value}'`);
|
|
1873
|
+
const simple = { a: 7, b: 8, f: 12, n: 10, r: 13, t: 9, v: 11, '"': 34, '\\': 92 };
|
|
1874
|
+
if (escaped in simple) {
|
|
1875
|
+
bytes.push(simple[escaped]);
|
|
1876
|
+
continue;
|
|
1877
|
+
}
|
|
1878
|
+
const octal = `${escaped}${value.slice(index, index + 2)}`;
|
|
1879
|
+
if (!/^[0-7]{3}$/.test(octal))
|
|
1880
|
+
throw new Error(`malformed Git C-quoted path '${value}'`);
|
|
1881
|
+
bytes.push(Number.parseInt(octal, 8));
|
|
1882
|
+
index += 2;
|
|
1883
|
+
}
|
|
1884
|
+
return Buffer.from(bytes).toString('utf8');
|
|
1885
|
+
}
|
|
1886
|
+
// Dense combined diff prefixes have one column per parent. Ownership is a LINE fact, not a hunk fact:
|
|
1887
|
+
// all `+` means the result authored a line absent from every parent; all `-` means it deleted a line present
|
|
1888
|
+
// in every parent. Mixed columns inherit from at least one parent. Track every cursor through all displayed
|
|
1889
|
+
// lines so adjacent mixed/owned rows never widen one another.
|
|
1890
|
+
export function combinedDiffOwnedChanges(patch) {
|
|
1891
|
+
const byPath = new Map();
|
|
1892
|
+
let path = null;
|
|
1893
|
+
let parents = 0;
|
|
1894
|
+
let parentLines = [];
|
|
1895
|
+
let parentPaths = [];
|
|
1896
|
+
let resultLine = 0;
|
|
1897
|
+
let inHunk = false;
|
|
1898
|
+
for (const line of patch.split('\n')) {
|
|
1899
|
+
if (line.startsWith('diff --cc ')) {
|
|
1900
|
+
path = decodeGitCPath(line.slice('diff --cc '.length));
|
|
1901
|
+
parents = 0;
|
|
1902
|
+
parentPaths = [];
|
|
1903
|
+
inHunk = false;
|
|
1904
|
+
continue;
|
|
1905
|
+
}
|
|
1906
|
+
if (!inHunk && path !== null && line.startsWith('--- ')) {
|
|
1907
|
+
const raw = decodeGitCPath(line.slice(4));
|
|
1908
|
+
parentPaths.push(raw === '/dev/null' ? path : raw.replace(/^a\//, ''));
|
|
1909
|
+
continue;
|
|
1910
|
+
}
|
|
1911
|
+
const header = line.match(/^(@{3,}) ((?:-\d+(?:,\d+)? )+)\+(\d+)(?:,\d+)? @+/);
|
|
1912
|
+
if (header) {
|
|
1913
|
+
parents = header[1].length - 1;
|
|
1914
|
+
parentLines = [...header[2].matchAll(/-(\d+)/g)].map((match) => Number(match[1]));
|
|
1915
|
+
resultLine = Number(header[3]);
|
|
1916
|
+
inHunk = path !== null && parents >= 2 && parentLines.length === parents;
|
|
1917
|
+
continue;
|
|
1918
|
+
}
|
|
1919
|
+
if (!inHunk || path === null || line.startsWith('\'))
|
|
1920
|
+
continue;
|
|
1921
|
+
const prefix = line.slice(0, parents);
|
|
1922
|
+
if (prefix.length !== parents || !/^[ +\-]+$/.test(prefix)) {
|
|
1923
|
+
inHunk = false;
|
|
1924
|
+
continue;
|
|
1925
|
+
}
|
|
1926
|
+
const authoredAfter = [...prefix].every((c) => c === '+');
|
|
1927
|
+
const authoredBefore = [...prefix].every((c) => c === '-');
|
|
1928
|
+
if ((authoredAfter || authoredBefore) && parentPaths.length !== parents)
|
|
1929
|
+
throw new Error(`combined diff for '${path}' declared ${parents} parents but exposed ${parentPaths.length} parent paths`);
|
|
1930
|
+
const changes = byPath.get(path) ?? {
|
|
1931
|
+
after: [],
|
|
1932
|
+
before: Array.from({ length: parents }, () => []),
|
|
1933
|
+
parentPaths: [...parentPaths],
|
|
1934
|
+
};
|
|
1935
|
+
if (authoredAfter)
|
|
1936
|
+
changes.after.push([Math.max(1, resultLine), Math.max(1, resultLine)]);
|
|
1937
|
+
if (authoredBefore) {
|
|
1938
|
+
for (let parent = 0; parent < parents; parent++) {
|
|
1939
|
+
const point = Math.max(1, parentLines[parent]);
|
|
1940
|
+
changes.before[parent].push([point, point]);
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
if (authoredAfter || authoredBefore)
|
|
1944
|
+
byPath.set(path, changes);
|
|
1945
|
+
const resultExists = prefix.includes('+') || [...prefix].every((c) => c === ' ');
|
|
1946
|
+
for (let parent = 0; parent < parents; parent++)
|
|
1947
|
+
if (prefix[parent] === '-' || (prefix[parent] === ' ' && resultExists))
|
|
1948
|
+
parentLines[parent]++;
|
|
1949
|
+
if (resultExists)
|
|
1950
|
+
resultLine++;
|
|
1951
|
+
}
|
|
1952
|
+
return byPath;
|
|
1953
|
+
}
|
|
1954
|
+
function parseMergeHistoryEvents(out) {
|
|
1955
|
+
const resolutions = new Map();
|
|
1956
|
+
const renames = [];
|
|
1957
|
+
for (const rec of out.split(RS)) {
|
|
1958
|
+
const normalized = rec.replace(/^\n/, '');
|
|
1959
|
+
const newline = normalized.indexOf('\n');
|
|
1960
|
+
const hash = (newline < 0 ? normalized : normalized.slice(0, newline)).trim();
|
|
1961
|
+
if (!hash)
|
|
1962
|
+
continue;
|
|
1963
|
+
const patch = newline < 0 ? '' : normalized.slice(newline + 1);
|
|
1964
|
+
for (const line of patch.split('\n')) {
|
|
1965
|
+
const raw = line.match(/^(:{2,})[0-7]{6}(?: [0-7]{6})+ (?:[0-9a-f]+ )+[A-Z]+\t/);
|
|
1966
|
+
if (!raw)
|
|
1967
|
+
continue;
|
|
1968
|
+
const parentCount = raw[1].length;
|
|
1969
|
+
const fields = line.split('\t');
|
|
1970
|
+
const status = fields[0].trim().split(/\s+/).at(-1) ?? '';
|
|
1971
|
+
const paths = fields.slice(1).map(decodeGitCPath);
|
|
1972
|
+
if (status.length !== parentCount || paths.length !== parentCount + 1)
|
|
1973
|
+
throw new Error(`combined raw diff for ${hash} exposed ${status.length} statuses and ${paths.length} paths for ${parentCount} parents`);
|
|
1974
|
+
const resultPath = paths[parentCount];
|
|
1975
|
+
for (let parent = 0; parent < parentCount; parent++) {
|
|
1976
|
+
if (status[parent] !== 'R' || paths[parent] === resultPath)
|
|
1977
|
+
continue;
|
|
1978
|
+
renames.push({ hash, from: paths[parent], to: resultPath });
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
for (const [path, changes] of combinedDiffOwnedChanges(patch)) {
|
|
1982
|
+
const events = resolutions.get(path) ?? [];
|
|
1983
|
+
events.push({ hash, parentPaths: changes.parentPaths });
|
|
1984
|
+
resolutions.set(path, events);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
return { resolutions, renames };
|
|
1988
|
+
}
|
|
1989
|
+
async function mergeHistoryEvents(root, tip, transient, order, reachable, useCache = true) {
|
|
1990
|
+
if (!useCache)
|
|
1991
|
+
return parseMergeHistoryEvents(await strictEventGit(['-C', root, '-c', 'core.quotePath=false',
|
|
1992
|
+
'log', '--merges', '--raw', '--patch', '--cc', '--combined-all-paths', '--unified=0', '--no-color', '--no-ext-diff', '-M', `--format=${RS}%H`, tip]));
|
|
1993
|
+
return parseMergeHistoryEvents(await textEventStream(root, tip, indexEventRequests(root, tip, order, reachable).merge, !transient, useCache));
|
|
1994
|
+
}
|
|
1995
|
+
async function buildDriftIndex(root, tip, transient, useCache = true, shared) {
|
|
1996
|
+
if (isUnbornHistoryTip(tip))
|
|
1997
|
+
return emptyDriftIndex();
|
|
1998
|
+
const ord = new Map(), parents = new Map();
|
|
1999
|
+
const fileEvents = new Map();
|
|
2000
|
+
const lineageEvents = new Map();
|
|
2001
|
+
const acks = new Map(), selfAcks = new Map(), specNodes = new Map();
|
|
2002
|
+
const ackCandidates = new Map(), ackCheckpoints = new Map();
|
|
2003
|
+
const idx = { tip, ord, parents, fileEvents, lineageEvents, lineageKeys: (path) => [path], resolutionEvents: new Map(), acks, selfAcks, specNodes, anc: new Map() };
|
|
2004
|
+
let currentPaths;
|
|
2005
|
+
let topology;
|
|
2006
|
+
if (shared) {
|
|
2007
|
+
currentPaths = shared.allPaths;
|
|
2008
|
+
topology = shared.topology;
|
|
2009
|
+
}
|
|
2010
|
+
else {
|
|
2011
|
+
const [tipPathsOut, topologyOut] = await Promise.all([
|
|
2012
|
+
strictEventGit(['-C', root, 'ls-tree', '-r', '-z', '--name-only', tip]),
|
|
2013
|
+
strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
|
|
2014
|
+
]);
|
|
2015
|
+
currentPaths = new Set(tipPathsOut.split('\0').filter(Boolean));
|
|
2016
|
+
topology = topologyProjection(topologyOut);
|
|
2017
|
+
}
|
|
2018
|
+
const topologyOrder = topology.order;
|
|
2019
|
+
const topologyParents = topology.parents;
|
|
2020
|
+
const topologyReachable = topology.reachable;
|
|
2021
|
+
for (const [hash, position] of topologyOrder) {
|
|
2022
|
+
ord.set(hash, position);
|
|
2023
|
+
parents.set(hash, topologyParents.get(hash) ?? []);
|
|
2024
|
+
}
|
|
2025
|
+
// Raw identity records path pairs and immutable object ids once; projection owns forks and reuse.
|
|
2026
|
+
const records = shared?.identityRecords ?? await identityRawEventStream(root, tip, indexEventRequests(root, tip, topologyOrder, topologyReachable)['identity-raw'], !transient, useCache);
|
|
2027
|
+
if (!records.length)
|
|
2028
|
+
return idx;
|
|
2029
|
+
const rawFileEvents = new Map();
|
|
2030
|
+
const renamesByFrom = new Map();
|
|
2031
|
+
for (const record of records) {
|
|
2032
|
+
const { h: hash, a: ackStr, c: changes } = record;
|
|
2033
|
+
const ackSet = new Set(ackStr.split(',').map((s) => s.trim()).filter(Boolean));
|
|
2034
|
+
if (ackSet.size) {
|
|
2035
|
+
ackCandidates.set(hash, ackSet);
|
|
2036
|
+
ackCheckpoints.set(hash, changes.length === 0);
|
|
2037
|
+
}
|
|
2038
|
+
const merge = (parents.get(hash) ?? []).length > 1;
|
|
2039
|
+
for (const [, from, to] of changes) {
|
|
2040
|
+
if (!merge) {
|
|
2041
|
+
const events = rawFileEvents.get(to) ?? [];
|
|
2042
|
+
const event = {
|
|
2043
|
+
commit: hash,
|
|
2044
|
+
historicalPath: to,
|
|
2045
|
+
parents: (parents.get(hash) ?? []).slice(0, 1).map((commit) => ({ commit, historicalPath: from })),
|
|
2046
|
+
};
|
|
2047
|
+
events.push(event);
|
|
2048
|
+
rawFileEvents.set(to, events);
|
|
2049
|
+
}
|
|
2050
|
+
if (from !== to) {
|
|
2051
|
+
const renames = renamesByFrom.get(from) ?? [];
|
|
2052
|
+
renames.push({ hash, to });
|
|
2053
|
+
renamesByFrom.set(from, renames);
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
const mergeIndex = shared?.mergeIndex
|
|
2058
|
+
?? await mergeHistoryEvents(root, tip, transient, topologyOrder, topologyReachable, useCache);
|
|
2059
|
+
for (const rename of mergeIndex.renames) {
|
|
2060
|
+
const renames = renamesByFrom.get(rename.from) ?? [];
|
|
2061
|
+
renames.push({ hash: rename.hash, to: rename.to });
|
|
2062
|
+
renamesByFrom.set(rename.from, renames);
|
|
2063
|
+
}
|
|
2064
|
+
const canonical = canonicalPathProjector(renamesByFrom, topology);
|
|
2065
|
+
idx.lineageKeys = canonical;
|
|
2066
|
+
// One projection per event serves both the lineage index and the current-path index; asking the
|
|
2067
|
+
// projector the same question twice per event only pays for it twice.
|
|
2068
|
+
const addEvent = (keys, event, target) => {
|
|
2069
|
+
for (const key of keys) {
|
|
2070
|
+
const events = target.get(key) ?? [];
|
|
2071
|
+
if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath))
|
|
2072
|
+
events.push(event);
|
|
2073
|
+
target.set(key, events);
|
|
2074
|
+
}
|
|
2075
|
+
};
|
|
2076
|
+
for (const [path, rawEvents] of rawFileEvents)
|
|
2077
|
+
for (const event of rawEvents) {
|
|
2078
|
+
const keys = canonical(path, event.commit);
|
|
2079
|
+
addEvent(keys, event, lineageEvents);
|
|
2080
|
+
for (const head of keys.filter((candidate) => currentPaths.has(candidate))) {
|
|
2081
|
+
const events = fileEvents.get(head) ?? [];
|
|
2082
|
+
if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath))
|
|
2083
|
+
events.push(event);
|
|
2084
|
+
fileEvents.set(head, events);
|
|
2085
|
+
if (isSpecMd(head)) {
|
|
2086
|
+
const nodes = specNodes.get(event.commit) ?? new Set();
|
|
2087
|
+
nodes.add(nodeIdOf(head));
|
|
2088
|
+
specNodes.set(event.commit, nodes);
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
for (const [hash, nodes] of ackCandidates) {
|
|
2093
|
+
const parentList = parents.get(hash) ?? [];
|
|
2094
|
+
const firstParent = parentList[0];
|
|
2095
|
+
// A non-merge commit with no raw identity entries has the same tree as its sole parent.
|
|
2096
|
+
const checkpoint = parentList.length === 1 && !!firstParent && ackCheckpoints.get(hash) === true;
|
|
2097
|
+
(checkpoint ? acks : selfAcks).set(hash, nodes);
|
|
2098
|
+
}
|
|
2099
|
+
for (const [path, mergeEvents] of mergeIndex.resolutions)
|
|
2100
|
+
for (const mergeEvent of mergeEvents) {
|
|
2101
|
+
const mergeParents = topologyParents.get(mergeEvent.hash) ?? [];
|
|
2102
|
+
if (mergeParents.length !== mergeEvent.parentPaths.length)
|
|
2103
|
+
throw new Error(`merge ${mergeEvent.hash} has ${mergeParents.length} topology parents but ${mergeEvent.parentPaths.length} combined parent paths`);
|
|
2104
|
+
const event = {
|
|
2105
|
+
commit: mergeEvent.hash,
|
|
2106
|
+
historicalPath: path,
|
|
2107
|
+
parents: mergeParents.map((commit, index) => ({ commit, historicalPath: mergeEvent.parentPaths[index] })),
|
|
2108
|
+
};
|
|
2109
|
+
const keys = canonical(path, mergeEvent.hash);
|
|
2110
|
+
addEvent(keys, event, lineageEvents);
|
|
2111
|
+
for (const head of keys.filter((candidate) => currentPaths.has(candidate))) {
|
|
2112
|
+
const events = idx.resolutionEvents.get(head) ?? [];
|
|
2113
|
+
if (!events.some((existing) => existing.commit === event.commit && existing.historicalPath === event.historicalPath))
|
|
2114
|
+
events.push(event);
|
|
2115
|
+
idx.resolutionEvents.set(head, events);
|
|
2116
|
+
if (isSpecMd(head)) {
|
|
2117
|
+
const nodes = specNodes.get(mergeEvent.hash) ?? new Set();
|
|
2118
|
+
nodes.add(nodeIdOf(head));
|
|
2119
|
+
specNodes.set(mergeEvent.hash, nodes);
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
return idx;
|
|
2124
|
+
}
|
|
2125
|
+
export function driftIndex(root, tip = 'HEAD') {
|
|
2126
|
+
if (tip !== 'HEAD') {
|
|
2127
|
+
const resolved = git(['-C', root, 'rev-parse', `${tip}^{commit}`]).trim();
|
|
2128
|
+
if (pendingOnlyIssues(root, resolved)) {
|
|
2129
|
+
const parent = pendingParent(root, resolved);
|
|
2130
|
+
if (parent) {
|
|
2131
|
+
const head = headOrEmpty(root);
|
|
2132
|
+
if (head === parent)
|
|
2133
|
+
return driftIndex(root);
|
|
2134
|
+
return buildDriftIndex(root, parent, true, true);
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
return buildDriftIndex(root, resolved, true, true);
|
|
2138
|
+
}
|
|
2139
|
+
const head = headOrEmpty(root); // filesystem HEAD, no subprocess — see historyIndex
|
|
2140
|
+
if (!head)
|
|
2141
|
+
return buildDriftIndex(root, 'HEAD', false, true);
|
|
2142
|
+
const cacheKey = indexCacheKey(root, head);
|
|
2143
|
+
touchRoot(driftRoots, driftIdxCache, root, cacheKey);
|
|
2144
|
+
const hit = driftIdxCache.get(cacheKey);
|
|
2145
|
+
if (hit)
|
|
2146
|
+
return hit;
|
|
2147
|
+
const p = buildDriftIndex(root, head, false, true);
|
|
2148
|
+
p.catch(() => { dropFailed(driftIdxCache, cacheKey, p); });
|
|
2149
|
+
driftIdxCache.set(cacheKey, p);
|
|
2150
|
+
return p;
|
|
2151
|
+
}
|
|
2152
|
+
function topologyProjection(out) {
|
|
2153
|
+
const order = new Map(), parents = new Map(), reachable = new Set();
|
|
2154
|
+
let position = 0;
|
|
2155
|
+
for (const line of out.trim().split('\n')) {
|
|
2156
|
+
const [hash, ...parentList] = line.split(' ');
|
|
2157
|
+
if (!hash)
|
|
2158
|
+
continue;
|
|
2159
|
+
order.set(hash, position++);
|
|
2160
|
+
parents.set(hash, parentList);
|
|
2161
|
+
reachable.add(hash);
|
|
2162
|
+
}
|
|
2163
|
+
return { order, parents, reachable };
|
|
2164
|
+
}
|
|
2165
|
+
function textStream(value, kind) {
|
|
2166
|
+
if (typeof value !== 'string')
|
|
2167
|
+
throw new Error(`history event stream '${kind}' did not render text`);
|
|
2168
|
+
return value;
|
|
2169
|
+
}
|
|
2170
|
+
function identityRawStream(value, kind) {
|
|
2171
|
+
if (!Array.isArray(value) || value.some((record) => !('a' in record)))
|
|
2172
|
+
throw new Error(`history event stream '${kind}' did not render identity records`);
|
|
2173
|
+
return value;
|
|
2174
|
+
}
|
|
2175
|
+
async function buildIndexPair(root, tip, transient, useCache = true) {
|
|
2176
|
+
if (isUnbornHistoryTip(tip))
|
|
2177
|
+
return [emptyHistoryIndex(), emptyDriftIndex()];
|
|
2178
|
+
const [allPathsOut, topologyOut] = await Promise.all([
|
|
2179
|
+
strictEventGit(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '--name-only', tip]),
|
|
2180
|
+
strictEventGit(['-C', root, 'rev-list', '--parents', tip]),
|
|
2181
|
+
]);
|
|
2182
|
+
const topology = topologyProjection(topologyOut);
|
|
2183
|
+
const allPaths = new Set(allPathsOut.split('\0').filter(Boolean));
|
|
2184
|
+
const specPaths = new Set([...allPaths].filter((path) => path.startsWith('.spec/')));
|
|
2185
|
+
const requests = indexEventRequests(root, tip, topology.order, topology.reachable);
|
|
2186
|
+
const streams = await deriveEventStreams(root, tip, EVENT_STREAM_KINDS.map((kind) => requests[kind]), !transient, useCache);
|
|
2187
|
+
const shared = {
|
|
2188
|
+
allPaths,
|
|
2189
|
+
specPaths,
|
|
2190
|
+
topology,
|
|
2191
|
+
identityRecords: identityRawStream(streams.get('identity-raw'), 'identity-raw'),
|
|
2192
|
+
mergeIndex: parseMergeHistoryEvents(textStream(streams.get('merge'), 'merge')),
|
|
2193
|
+
};
|
|
2194
|
+
streams.clear();
|
|
2195
|
+
return Promise.all([
|
|
2196
|
+
buildIndex(root, tip, transient, useCache, shared),
|
|
2197
|
+
buildDriftIndex(root, tip, transient, useCache, shared),
|
|
2198
|
+
]);
|
|
2199
|
+
}
|
|
2200
|
+
// Standing correctness oracle: same projection, but every immutable stream comes from Git root history.
|
|
2201
|
+
export function sourceIndexesFull(root, tip = 'HEAD') {
|
|
2202
|
+
const head = tip === 'HEAD' ? headOrEmpty(root) : '';
|
|
2203
|
+
const resolved = tip === 'HEAD'
|
|
2204
|
+
? (head || 'HEAD')
|
|
2205
|
+
: git(['-C', root, 'rev-parse', `${tip}^{commit}`]).trim();
|
|
2206
|
+
return buildIndexPair(root, resolved, true, false);
|
|
2207
|
+
}
|
|
2208
|
+
// History and drift are one product projection. Building them together is what gives one lint one ledger
|
|
2209
|
+
// transaction; the public single-index functions remain for consumers that genuinely need only one side.
|
|
2210
|
+
export function sourceIndexes(root, tip = 'HEAD') {
|
|
2211
|
+
if (tip !== 'HEAD') {
|
|
2212
|
+
const resolved = git(['-C', root, 'rev-parse', `${tip}^{commit}`]).trim();
|
|
2213
|
+
if (pendingOnlyIssues(root, resolved)) {
|
|
2214
|
+
const parent = pendingParent(root, resolved);
|
|
2215
|
+
if (parent)
|
|
2216
|
+
return headOrEmpty(root) === parent
|
|
2217
|
+
? sourceIndexes(root)
|
|
2218
|
+
: buildIndexPair(root, parent, true, true);
|
|
2219
|
+
}
|
|
2220
|
+
return buildIndexPair(root, resolved, true, true);
|
|
2221
|
+
}
|
|
2222
|
+
const head = headOrEmpty(root);
|
|
2223
|
+
if (!head)
|
|
2224
|
+
return buildIndexPair(root, 'HEAD', false, true);
|
|
2225
|
+
const cacheKey = indexCacheKey(root, head);
|
|
2226
|
+
touchRoot(indexRoots, indexCache, root, cacheKey);
|
|
2227
|
+
touchRoot(driftRoots, driftIdxCache, root, cacheKey);
|
|
2228
|
+
const historyHit = indexCache.get(cacheKey), driftHit = driftIdxCache.get(cacheKey);
|
|
2229
|
+
if (historyHit && driftHit)
|
|
2230
|
+
return Promise.all([historyHit, driftHit]);
|
|
2231
|
+
const pair = buildIndexPair(root, head, false, true);
|
|
2232
|
+
const historyPromise = pair.then(([history]) => history);
|
|
2233
|
+
const driftPromise = pair.then(([, drift]) => drift);
|
|
2234
|
+
historyPromise.catch(() => { dropFailed(indexCache, cacheKey, historyPromise); });
|
|
2235
|
+
driftPromise.catch(() => { dropFailed(driftIdxCache, cacheKey, driftPromise); });
|
|
2236
|
+
indexCache.set(cacheKey, historyPromise);
|
|
2237
|
+
driftIdxCache.set(cacheKey, driftPromise);
|
|
2238
|
+
return pair;
|
|
2239
|
+
}
|
|
2240
|
+
export function historyCacheStats() {
|
|
2241
|
+
return { historyHeads: indexCache.size, driftHeads: driftIdxCache.size, historyRoots: indexRoots.size, driftRoots: driftRoots.size };
|
|
2242
|
+
}
|
|
2243
|
+
// Tests deliberately clear process-local promises and path identity to exercise both cold and read-back paths.
|
|
2244
|
+
// Production callers retain the bounded index caches; this is not a correctness escape hatch.
|
|
2245
|
+
export function resetHistoryCachesForTests() {
|
|
2246
|
+
indexCache.clear();
|
|
2247
|
+
driftIdxCache.clear();
|
|
2248
|
+
indexRoots.clear();
|
|
2249
|
+
driftRoots.clear();
|
|
2250
|
+
eventPathMemo.clear();
|
|
2251
|
+
gitObjectFormatMemo.clear();
|
|
2252
|
+
}
|
|
2253
|
+
export function historyEventCachePathForTests(root) { return eventCacheLocation(root).path; }
|
|
2254
|
+
// @@@ one ordinary ancestry memo, with a batch entrance - every closure below has exactly the same dense
|
|
2255
|
+
// bitset shape in idx.anc. A caller that already knows several bases can prime them in one child-to-parent
|
|
2256
|
+
// topology pass; the short-lived frontier is released before returning. Unknown or ad-hoc revisions retain
|
|
2257
|
+
// this direct parent DFS, so neither path introduces a second reachability representation or persistent fact.
|
|
2258
|
+
// undefined when `sha` is not reachable from HEAD (rebased away, an unmerged branch, or never on any
|
|
2259
|
+
// ref) — callers apply their own conservative rule to that "can't prove" case.
|
|
2260
|
+
export function ancestorsOf(idx, sha) {
|
|
2261
|
+
const hit = idx.anc.get(sha);
|
|
2262
|
+
if (hit)
|
|
2263
|
+
return hit;
|
|
2264
|
+
const start = idx.ord.get(sha);
|
|
2265
|
+
if (start === undefined)
|
|
2266
|
+
return undefined;
|
|
2267
|
+
const bits = new Uint8Array((idx.ord.size + 7) >> 3);
|
|
2268
|
+
bits[start >> 3] |= 1 << (start & 7);
|
|
2269
|
+
const stack = [sha];
|
|
2270
|
+
while (stack.length) {
|
|
2271
|
+
for (const p of idx.parents.get(stack.pop()) ?? []) {
|
|
2272
|
+
const o = idx.ord.get(p);
|
|
2273
|
+
if (o === undefined)
|
|
2274
|
+
continue; // shallow-clone boundary: an unwalked parent ends the chain
|
|
2275
|
+
const m = 1 << (o & 7);
|
|
2276
|
+
if (bits[o >> 3] & m)
|
|
2277
|
+
continue;
|
|
2278
|
+
bits[o >> 3] |= m;
|
|
2279
|
+
stack.push(p);
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
idx.anc.set(sha, bits);
|
|
2283
|
+
return bits;
|
|
2284
|
+
}
|
|
2285
|
+
// Fill the existing ancestry memo for a known roster in one child-before-parent topology pass. At each
|
|
2286
|
+
// commit the transient row names requested descendants; emitting those bits into the ordinary closures makes
|
|
2287
|
+
// the resulting bytes identical to calling ancestorsOf() independently for every requested SHA.
|
|
2288
|
+
export function primeAncestorClosures(idx, shas) {
|
|
2289
|
+
const endpoints = [...new Set(shas)].filter((sha) => !idx.anc.has(sha) && idx.ord.has(sha));
|
|
2290
|
+
if (!endpoints.length)
|
|
2291
|
+
return;
|
|
2292
|
+
const count = idx.ord.size;
|
|
2293
|
+
const width = (endpoints.length + 7) >> 3;
|
|
2294
|
+
const closureWidth = (count + 7) >> 3;
|
|
2295
|
+
const endpointAt = new Map(endpoints.map((sha, position) => [sha, position]));
|
|
2296
|
+
const hashAt = new Array(count);
|
|
2297
|
+
for (const [hash, position] of idx.ord)
|
|
2298
|
+
hashAt[position] = hash;
|
|
2299
|
+
// Every row waits until all of its children have contributed. Parents outside the walked topology are
|
|
2300
|
+
// deliberately absent: they are the same shallow boundary ancestorsOf() stops at.
|
|
2301
|
+
const childrenLeft = new Int32Array(count);
|
|
2302
|
+
for (const parents of idx.parents.values())
|
|
2303
|
+
for (const parent of parents) {
|
|
2304
|
+
const position = idx.ord.get(parent);
|
|
2305
|
+
if (position !== undefined)
|
|
2306
|
+
childrenLeft[position]++;
|
|
2307
|
+
}
|
|
2308
|
+
const ready = [];
|
|
2309
|
+
for (let position = 0; position < count; position++)
|
|
2310
|
+
if (childrenLeft[position] === 0)
|
|
2311
|
+
ready.push(position);
|
|
2312
|
+
const closures = endpoints.map(() => new Uint8Array(closureWidth));
|
|
2313
|
+
const rows = new Map(), pool = [];
|
|
2314
|
+
const rowFor = (position) => {
|
|
2315
|
+
const existing = rows.get(position);
|
|
2316
|
+
if (existing)
|
|
2317
|
+
return existing;
|
|
2318
|
+
const row = pool.pop() ?? new Uint8Array(width);
|
|
2319
|
+
rows.set(position, row);
|
|
2320
|
+
return row;
|
|
2321
|
+
};
|
|
2322
|
+
let visited = 0;
|
|
2323
|
+
while (ready.length) {
|
|
2324
|
+
const position = ready.pop();
|
|
2325
|
+
visited++;
|
|
2326
|
+
const row = rowFor(position);
|
|
2327
|
+
rows.delete(position);
|
|
2328
|
+
const endpoint = endpointAt.get(hashAt[position]);
|
|
2329
|
+
if (endpoint !== undefined)
|
|
2330
|
+
row[endpoint >> 3] |= 1 << (endpoint & 7);
|
|
2331
|
+
for (let byte = 0; byte < width; byte++) {
|
|
2332
|
+
let pending = row[byte];
|
|
2333
|
+
while (pending) {
|
|
2334
|
+
const bit = 31 - Math.clz32(pending & -pending);
|
|
2335
|
+
closures[(byte << 3) + bit][position >> 3] |= 1 << (position & 7);
|
|
2336
|
+
pending &= pending - 1;
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
for (const parent of idx.parents.get(hashAt[position]) ?? []) {
|
|
2340
|
+
const parentPosition = idx.ord.get(parent);
|
|
2341
|
+
if (parentPosition === undefined)
|
|
2342
|
+
continue;
|
|
2343
|
+
const target = rowFor(parentPosition);
|
|
2344
|
+
for (let byte = 0; byte < width; byte++)
|
|
2345
|
+
target[byte] |= row[byte];
|
|
2346
|
+
if (--childrenLeft[parentPosition] === 0)
|
|
2347
|
+
ready.push(parentPosition);
|
|
2348
|
+
}
|
|
2349
|
+
row.fill(0);
|
|
2350
|
+
pool.push(row);
|
|
2351
|
+
}
|
|
2352
|
+
if (visited !== count)
|
|
2353
|
+
throw new Error(`cannot prime ancestry closures: topology yielded ${visited} of ${count} reachable commits`);
|
|
2354
|
+
for (let position = 0; position < endpoints.length; position++)
|
|
2355
|
+
idx.anc.set(endpoints[position], closures[position]);
|
|
2356
|
+
}
|
|
2357
|
+
export function inAncestors(idx, bits, sha) {
|
|
2358
|
+
const o = idx.ord.get(sha);
|
|
2359
|
+
return o !== undefined && (bits[o >> 3] & (1 << (o & 7))) !== 0;
|
|
2360
|
+
}
|
|
2361
|
+
// @@@ one walk for a whole roster of revisions, HEAD-reachable or not - the index above projects HEAD's
|
|
2362
|
+
// history, so a revision HEAD cannot reach (an unmerged branch, a rebased-away measurement anchor) has NO
|
|
2363
|
+
// ancestry there and `ancestorsOf` correctly answers undefined. A caller that needs those revisions' own
|
|
2364
|
+
// past gets it from the SAME projection shape, built by one `rev-list --parents` walk over the union of the
|
|
2365
|
+
// roster's histories, with the roster on stdin so argv never grows with it. Deliberately a SEPARATE
|
|
2366
|
+
// structure, never a graft into the shared index: making off-history tips reachable there would silently
|
|
2367
|
+
// switch every ancestry-vs-content decision that keys on `undefined`.
|
|
2368
|
+
export async function unionTopology(root, revisions) {
|
|
2369
|
+
const roster = [...new Set(revisions)].filter(Boolean);
|
|
2370
|
+
const reach = { ord: new Map(), parents: new Map(), anc: new Map() };
|
|
2371
|
+
if (!roster.length)
|
|
2372
|
+
return reach;
|
|
2373
|
+
const out = await gitRequiredA(['-C', root, 'rev-list', '--parents', '--stdin'], 'cannot walk revision topology', {
|
|
2374
|
+
input: roster.map((revision) => `${revision}\n`).join(''),
|
|
2375
|
+
// the roster is already exact object ids; do not reinterpret them if refs/replace moves mid-read.
|
|
2376
|
+
extraEnv: { GIT_NO_REPLACE_OBJECTS: '1' },
|
|
2377
|
+
});
|
|
2378
|
+
const projection = topologyProjection(out);
|
|
2379
|
+
return { ord: projection.order, parents: projection.parents, anc: new Map() };
|
|
2380
|
+
}
|
|
2381
|
+
// @@@ reachability is membership, not a closure - `ancestorsOf` returns undefined for EXACTLY the shas
|
|
2382
|
+
// absent from `idx.ord` (both writers of `idx.anc` gate on ord: the single-sha path after its ord lookup
|
|
2383
|
+
// succeeds, the batch after an explicit `ord.has` filter), so asking it here answered a hash-table
|
|
2384
|
+
// question by walking the whole parent DAG and allocating an ord.size-wide bitset per distinct sha —
|
|
2385
|
+
// per eval reading, on every board build.
|
|
2386
|
+
export function commitReachable(idx, sha) {
|
|
2387
|
+
return idx.ord.has(sha);
|
|
2388
|
+
}
|
|
2389
|
+
// the valid Spec-OK coverage for a node's version commit: `sinceHash` is the node's OWN latest version,
|
|
2390
|
+
// so the node(s) it's a version of (specNodes[sinceHash]) name the node being measured; an ack counts
|
|
2391
|
+
// only if its `Spec-OK:` set names one of those — `Spec-OK: A` quiets A's drift, never B's. An ack that
|
|
2392
|
+
// is itself an ancestor of the version can't speak for it (a re-version invalidates older acks); a valid
|
|
2393
|
+
// ack quiets exactly the commits reachable from it. Shared by driftFor (the count) and the anchor
|
|
2394
|
+
// engine's windowCommits (the commit set) so both read ONE ack rule.
|
|
2395
|
+
function targetNodes(idx, sinceHash, nodeId) {
|
|
2396
|
+
return nodeId ? new Set([nodeId]) : idx.specNodes.get(sinceHash);
|
|
2397
|
+
}
|
|
2398
|
+
export function ackCoverFor(idx, sinceHash, nodeId) {
|
|
2399
|
+
const base = ancestorsOf(idx, sinceHash);
|
|
2400
|
+
if (!base)
|
|
2401
|
+
return [];
|
|
2402
|
+
const targets = targetNodes(idx, sinceHash, nodeId);
|
|
2403
|
+
const cover = [];
|
|
2404
|
+
if (targets) {
|
|
2405
|
+
for (const [h, ackSet] of idx.acks) {
|
|
2406
|
+
if (inAncestors(idx, base, h))
|
|
2407
|
+
continue;
|
|
2408
|
+
if (![...targets].some((t) => ackSet.has(t)))
|
|
2409
|
+
continue;
|
|
2410
|
+
const a = ancestorsOf(idx, h);
|
|
2411
|
+
if (a)
|
|
2412
|
+
cover.push(a);
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
return cover;
|
|
2416
|
+
}
|
|
2417
|
+
export function selfAckCovers(idx, sinceHash, hash, nodeId) {
|
|
2418
|
+
const targets = targetNodes(idx, sinceHash, nodeId);
|
|
2419
|
+
const declared = idx.selfAcks?.get(hash);
|
|
2420
|
+
return !!targets && !!declared && [...targets].some((node) => declared.has(node));
|
|
2421
|
+
}
|
|
2422
|
+
export function pathEvents(idx, path) {
|
|
2423
|
+
return [...(idx.fileEvents.get(path) ?? []), ...(idx.resolutionEvents?.get(path) ?? [])];
|
|
2424
|
+
}
|
|
2425
|
+
// Exact base..tip window for consumers whose subject is not a spec version. The path is an identity at
|
|
2426
|
+
// `identityRevision`; projecting it and every immutable event to the same terminal lineage keys preserves
|
|
2427
|
+
// rename chains, parallel forks, path reuse, and lineages deleted before the tip.
|
|
2428
|
+
export function pathRangeEvents(idx, sinceHash, path, identityRevision = idx.tip ?? sinceHash) {
|
|
2429
|
+
const base = ancestorsOf(idx, sinceHash);
|
|
2430
|
+
if (!base)
|
|
2431
|
+
return null;
|
|
2432
|
+
const seen = new Set();
|
|
2433
|
+
const events = idx.lineageKeys(path, identityRevision).flatMap((key) => idx.lineageEvents.get(key) ?? []);
|
|
2434
|
+
return events.filter((event) => {
|
|
2435
|
+
const key = `${event.commit}\0${event.historicalPath}\0${event.parents.map((parent) => `${parent.commit}:${parent.historicalPath}`).join('\x1e')}`;
|
|
2436
|
+
if (seen.has(key) || inAncestors(idx, base, event.commit))
|
|
2437
|
+
return false;
|
|
2438
|
+
seen.add(key);
|
|
2439
|
+
return true;
|
|
2440
|
+
});
|
|
2441
|
+
}
|
|
2442
|
+
// @@@ eventsSince - THE meaning of "this path changed since <sha>", in one place. A commit touching `path`
|
|
2443
|
+
// lies in `sha..HEAD` exactly when it is NOT an ancestor of `sha` — true DAG reachability, wherever a
|
|
2444
|
+
// date-ordered log happens to place it. `null` is the honest third answer: the anchor commit is not reachable
|
|
2445
|
+
// (folded, rebased, cherry-picked away), so ancestry cannot testify at all and the caller must decide what to
|
|
2446
|
+
// do about that — the spec layer reports no window, the eval layer falls back to comparing content.
|
|
2447
|
+
// Callers add their OWN layer's decoration on top (ack cover is spec-only; the content probe is eval-only);
|
|
2448
|
+
// what none of them may do is restate the reachability rule, which is how it came to exist four times.
|
|
2449
|
+
export function eventsSince(idx, sinceHash, path) {
|
|
2450
|
+
const base = ancestorsOf(idx, sinceHash);
|
|
2451
|
+
if (!base)
|
|
2452
|
+
return null;
|
|
2453
|
+
return pathEvents(idx, path).filter((event) => !inAncestors(idx, base, event.commit));
|
|
2454
|
+
}
|
|
2455
|
+
export function driftPathWindow(idx, sinceHash, path, nodeId) {
|
|
2456
|
+
const events = eventsSince(idx, sinceHash, path);
|
|
2457
|
+
if (!events)
|
|
2458
|
+
return null;
|
|
2459
|
+
const cover = ackCoverFor(idx, sinceHash, nodeId);
|
|
2460
|
+
return events.filter((event) => !cover.some((a) => inAncestors(idx, a, event.commit))
|
|
2461
|
+
&& !selfAckCovers(idx, sinceHash, event.commit, nodeId));
|
|
2462
|
+
}
|
|
2463
|
+
// pure lookup, no git: a commit to `path` is drift iff it is NOT an ancestor of `sinceHash` — it lies
|
|
2464
|
+
// in `sinceHash..HEAD` by true DAG reachability, wherever a date-ordered log happens to place it.
|
|
2465
|
+
// An off-history `sinceHash` → 0: no basis on HEAD to measure from.
|
|
2466
|
+
export function driftFor(idx, sinceHash, path, nodeId) {
|
|
2467
|
+
if (!sinceHash)
|
|
2468
|
+
return 0;
|
|
2469
|
+
return new Set(driftPathWindow(idx, sinceHash, path, nodeId)?.map((event) => event.commit) ?? []).size;
|
|
2470
|
+
}
|
|
2471
|
+
// the paths git is about to commit (index vs HEAD), scoping the pre-commit drift gate to this commit's files.
|
|
2472
|
+
export function stagedFiles(root) {
|
|
2473
|
+
try {
|
|
2474
|
+
return git(['-C', root, '-c', 'core.quotePath=false', 'diff', '--cached', '--name-only'])
|
|
2475
|
+
.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
2476
|
+
}
|
|
2477
|
+
catch {
|
|
2478
|
+
return [];
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
// node id = the directory holding the spec.md (basename of its parent dir). git always emits
|
|
2482
|
+
// forward-slash paths, so split rather than node:path (which is backslash-y on Windows).
|
|
2483
|
+
const nodeIdOf = (p) => { const s = p.split('/'); return s[s.length - 2] ?? p; };
|
|
2484
|
+
const isSpecMd = (p) => p.endsWith('/spec.md');
|
|
2485
|
+
// `git ... --name-status -M` rows: `A\tpath`, `M\tpath`, `D\tpath`, `R100\told\tnew`. Recover the
|
|
2486
|
+
// status letter plus from/to (to === from for non-renames) so callers map letter -> op uniformly.
|
|
2487
|
+
function parseNameStatus(out) {
|
|
2488
|
+
const rows = [];
|
|
2489
|
+
for (const line of out.split('\n')) {
|
|
2490
|
+
if (!line)
|
|
2491
|
+
continue;
|
|
2492
|
+
const parts = line.split('\t');
|
|
2493
|
+
const code = parts[0][0];
|
|
2494
|
+
if ((code === 'R' || code === 'C') && parts.length >= 3)
|
|
2495
|
+
rows.push({ code, from: parts[1], to: parts[2] });
|
|
2496
|
+
else
|
|
2497
|
+
rows.push({ code, from: parts[1], to: parts[1] });
|
|
2498
|
+
}
|
|
2499
|
+
return rows;
|
|
2500
|
+
}
|
|
2501
|
+
const DIFF_STATUS = { A: 'added', M: 'modified', D: 'deleted', R: 'renamed', C: 'copied', T: 'type-changed' };
|
|
2502
|
+
function parseStatPath(token) {
|
|
2503
|
+
const b = token.indexOf('{'), arrow = token.indexOf(' => ', b), close = token.indexOf('}', arrow);
|
|
2504
|
+
if (b >= 0 && arrow > b && close > arrow) {
|
|
2505
|
+
const pre = token.slice(0, b), post = token.slice(close + 1);
|
|
2506
|
+
return { from: `${pre}${token.slice(b + 1, arrow)}${post}`.replace(/\/\//g, '/'), to: `${pre}${token.slice(arrow + 4, close)}${post}`.replace(/\/\//g, '/') };
|
|
2507
|
+
}
|
|
2508
|
+
const arrowAt = token.indexOf(' => ');
|
|
2509
|
+
return arrowAt >= 0 ? { from: token.slice(0, arrowAt), to: token.slice(arrowAt + 4) } : { from: token, to: token };
|
|
2510
|
+
}
|
|
2511
|
+
export async function mergeBaseDiff(wtPath, mainRef = 'main', headRef = 'HEAD') {
|
|
2512
|
+
const run = (args) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args]);
|
|
2513
|
+
const base = (await run(['merge-base', mainRef, headRef])).trim();
|
|
2514
|
+
if (!base)
|
|
2515
|
+
return [];
|
|
2516
|
+
const [numstatOut, statusOut] = await Promise.all([
|
|
2517
|
+
run(['diff', '--numstat', '-M', `${base}..${headRef}`]),
|
|
2518
|
+
run(['diff', '--name-status', '-M', `${base}..${headRef}`]),
|
|
2519
|
+
]);
|
|
2520
|
+
const status = new Map();
|
|
2521
|
+
for (const r of parseNameStatus(statusOut))
|
|
2522
|
+
status.set(r.to, { status: DIFF_STATUS[r.code] ?? r.code, from: r.from });
|
|
2523
|
+
const files = [];
|
|
2524
|
+
for (const line of numstatOut.split('\n')) {
|
|
2525
|
+
const m = line.match(/^(-|\d+)\t(-|\d+)\t(.+)$/);
|
|
2526
|
+
if (!m)
|
|
2527
|
+
continue;
|
|
2528
|
+
const { from, to } = parseStatPath(m[3]);
|
|
2529
|
+
const detail = status.get(to);
|
|
2530
|
+
files.push({
|
|
2531
|
+
path: to,
|
|
2532
|
+
...(from !== to ? { oldPath: detail?.from ?? from } : {}),
|
|
2533
|
+
status: detail?.status ?? 'modified',
|
|
2534
|
+
additions: m[1] === '-' ? 0 : +m[1],
|
|
2535
|
+
deletions: m[2] === '-' ? 0 : +m[2],
|
|
2536
|
+
});
|
|
2537
|
+
}
|
|
2538
|
+
return files;
|
|
2539
|
+
}
|
|
2540
|
+
export function mergeConflicts(wtPath, mainRef = 'main', headRef = 'HEAD') {
|
|
2541
|
+
return new Promise((resolve) => {
|
|
2542
|
+
const env = { ...process.env };
|
|
2543
|
+
delete env.GIT_DIR;
|
|
2544
|
+
delete env.GIT_WORK_TREE;
|
|
2545
|
+
delete env.GIT_INDEX_FILE;
|
|
2546
|
+
delete env.GIT_OBJECT_DIRECTORY;
|
|
2547
|
+
execFile(gitBinary(env), ['-C', wtPath, 'merge-tree', '--write-tree', '--no-messages', mainRef, headRef], { encoding: 'utf8', env, maxBuffer: 1 << 24 },
|
|
2548
|
+
// execFile sets err.code to the numeric EXIT code on a non-zero exit (1 = conflicts), or a string
|
|
2549
|
+
// errno (e.g. 'ENOENT') if git can't be spawned — only the exit-1 case is a real conflict verdict.
|
|
2550
|
+
(err) => resolve(!!err && err.code === 1));
|
|
2551
|
+
});
|
|
2552
|
+
}
|
|
2553
|
+
// this worktree's spec ops vs main ([[worktree-linker]]): an op must BOTH differ from main's current tip
|
|
2554
|
+
// (the proposal — the vs-main working diff supplies the op set and each op's TYPE, so merge terms are
|
|
2555
|
+
// spoken: content equal to main is no op, an existing node reads `edited` never `added`) AND have been
|
|
2556
|
+
// touched by this branch since its fork point (attribution — main's own post-fork movement is not this
|
|
2557
|
+
// worktree's op). A `status --porcelain` pass adds untracked spec.md, a third diff vs HEAD marks committed.
|
|
2558
|
+
function projectWorktreeSpecDelta(mainOut, workOut, commOut, statusOut) {
|
|
2559
|
+
const proposals = parseNameStatus(mainOut);
|
|
2560
|
+
// the branch's own footprint since its fork point — both sides of every row, so a rename matches
|
|
2561
|
+
// whichever side the vs-main diff names.
|
|
2562
|
+
const touched = new Set();
|
|
2563
|
+
for (const r of parseNameStatus(workOut)) {
|
|
2564
|
+
touched.add(r.to);
|
|
2565
|
+
if (r.from)
|
|
2566
|
+
touched.add(r.from);
|
|
2567
|
+
}
|
|
2568
|
+
const committed = new Set(parseNameStatus(commOut).map((r) => r.to));
|
|
2569
|
+
// --untracked-files=all: list every untracked spec.md individually (the default collapses a wholly
|
|
2570
|
+
// new node's directory to `.spec/.../node/`, which we'd never recognise as a spec.md add).
|
|
2571
|
+
const dirty = new Set(), untracked = [];
|
|
2572
|
+
for (const line of statusOut.split('\n')) {
|
|
2573
|
+
if (!line)
|
|
2574
|
+
continue;
|
|
2575
|
+
const xy = line.slice(0, 2);
|
|
2576
|
+
let path = line.slice(3);
|
|
2577
|
+
const arrow = path.indexOf(' -> ');
|
|
2578
|
+
if (arrow >= 0)
|
|
2579
|
+
path = path.slice(arrow + 4);
|
|
2580
|
+
dirty.add(path);
|
|
2581
|
+
if (xy === '??' && isSpecMd(path))
|
|
2582
|
+
untracked.push(path);
|
|
2583
|
+
}
|
|
2584
|
+
const codeFor = { A: 'added', M: 'edited', D: 'deleted', R: 'moved', C: 'added', T: 'edited' };
|
|
2585
|
+
const ops = [], seen = new Set();
|
|
2586
|
+
for (const r of proposals) {
|
|
2587
|
+
const path = r.code === 'D' ? r.from : r.to;
|
|
2588
|
+
if (!isSpecMd(path))
|
|
2589
|
+
continue;
|
|
2590
|
+
if (!touched.has(r.to) && !touched.has(r.from))
|
|
2591
|
+
continue;
|
|
2592
|
+
seen.add(path);
|
|
2593
|
+
const op = codeFor[r.code] ?? 'edited';
|
|
2594
|
+
ops.push({
|
|
2595
|
+
nodeId: nodeIdOf(path), op, path,
|
|
2596
|
+
...(op === 'moved' ? { fromPath: r.from, toPath: r.to } : {}),
|
|
2597
|
+
committed: committed.has(r.to) || committed.has(r.from),
|
|
2598
|
+
dirty: dirty.has(path) || dirty.has(r.from),
|
|
2599
|
+
});
|
|
2600
|
+
}
|
|
2601
|
+
for (const path of untracked) {
|
|
2602
|
+
if (seen.has(path))
|
|
2603
|
+
continue;
|
|
2604
|
+
ops.push({ nodeId: nodeIdOf(path), op: 'added', path, committed: false, dirty: true });
|
|
2605
|
+
}
|
|
2606
|
+
return ops;
|
|
2607
|
+
}
|
|
2608
|
+
async function boundedMap(values, concurrency, fn) {
|
|
2609
|
+
const results = new Array(values.length);
|
|
2610
|
+
let next = 0;
|
|
2611
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, async () => {
|
|
2612
|
+
while (true) {
|
|
2613
|
+
const index = next++;
|
|
2614
|
+
if (index >= values.length)
|
|
2615
|
+
return;
|
|
2616
|
+
results[index] = await fn(values[index]);
|
|
2617
|
+
}
|
|
2618
|
+
}));
|
|
2619
|
+
return results;
|
|
2620
|
+
}
|
|
2621
|
+
// One cold layout demand across many linked worktrees. Merge-base and working-status are path facts, so they
|
|
2622
|
+
// stay per worktree under a fixed concurrency. Clean rows then collapse to immutable main→HEAD and base→HEAD
|
|
2623
|
+
// pairs in one framed child; dirty rows retain the ordinary worktree-aware projection.
|
|
2624
|
+
export async function worktreeSpecDeltas(root, mainSha, demands, interpretation = gitInterpretationIdentity(root)) {
|
|
2625
|
+
const results = new Map();
|
|
2626
|
+
if (!demands.length)
|
|
2627
|
+
return results;
|
|
2628
|
+
if (gitInterpretationIdentity(root) !== interpretation) {
|
|
2629
|
+
const error = new Error('Git interpretation changed before the worktree overlay batch');
|
|
2630
|
+
for (const demand of demands)
|
|
2631
|
+
results.set(demand.path, { error });
|
|
2632
|
+
return results;
|
|
2633
|
+
}
|
|
2634
|
+
const prepared = await boundedMap(demands, 4, async (demand) => {
|
|
2635
|
+
try {
|
|
2636
|
+
const run = (args) => gitARequired(['-C', demand.path, '-c', 'core.quotePath=false', ...args]);
|
|
2637
|
+
const [baseOut, statusOut] = await Promise.all([
|
|
2638
|
+
run(['merge-base', mainSha, demand.head]),
|
|
2639
|
+
run(['status', '--porcelain', '--untracked-files=all', '--', '.spec']),
|
|
2640
|
+
]);
|
|
2641
|
+
const base = baseOut.trim();
|
|
2642
|
+
if (!base)
|
|
2643
|
+
throw new Error(`git merge-base returned no base for ${demand.path}`);
|
|
2644
|
+
return { ...demand, base, statusOut };
|
|
2645
|
+
}
|
|
2646
|
+
catch (error) {
|
|
2647
|
+
results.set(demand.path, { error });
|
|
2648
|
+
return null;
|
|
2649
|
+
}
|
|
2650
|
+
});
|
|
2651
|
+
const clean = prepared.filter((row) => !!row && row.statusOut.trim() === '');
|
|
2652
|
+
const dirty = prepared.filter((row) => !!row && row.statusOut.trim() !== '');
|
|
2653
|
+
const pairs = [];
|
|
2654
|
+
const pairIndex = new Map();
|
|
2655
|
+
const addPair = (from, to) => {
|
|
2656
|
+
const key = `${from}\0${to}`;
|
|
2657
|
+
const hit = pairIndex.get(key);
|
|
2658
|
+
if (hit != null)
|
|
2659
|
+
return hit;
|
|
2660
|
+
const index = pairs.length;
|
|
2661
|
+
pairIndex.set(key, index);
|
|
2662
|
+
pairs.push({ from, to });
|
|
2663
|
+
return index;
|
|
2664
|
+
};
|
|
2665
|
+
const cleanIndexes = clean.map((row) => ({
|
|
2666
|
+
row,
|
|
2667
|
+
main: addPair(mainSha, row.head),
|
|
2668
|
+
branch: addPair(row.base, row.head),
|
|
2669
|
+
}));
|
|
2670
|
+
if (pairs.length) {
|
|
2671
|
+
try {
|
|
2672
|
+
// diff-tree's stdin pair syntax is `<new> <old>`; reverse each ordinary from→to pair deliberately.
|
|
2673
|
+
// --always is load-bearing: it emits a frame for an empty pair, preserving positional ownership.
|
|
2674
|
+
const input = pairs.map(({ from, to }) => `${to} ${from}`).join('\n') + '\n';
|
|
2675
|
+
const out = await gitARequired([
|
|
2676
|
+
'-C', root, '-c', 'core.quotePath=false', 'diff-tree', '--stdin', '--no-commit-id', '--always',
|
|
2677
|
+
'-r', '--name-status', '-M', `--format=${RS}%H`, '--', '.spec',
|
|
2678
|
+
], input);
|
|
2679
|
+
// For two-tree stdin rows, diff-tree writes that row's name-status payload BEFORE its --format marker.
|
|
2680
|
+
// The first split segment is therefore pair 0; each later pair lives after the previous marker, while
|
|
2681
|
+
// the final marker carries no following pair. Treating markers as openers shifts every non-empty result.
|
|
2682
|
+
const records = out.split(RS);
|
|
2683
|
+
if (records.length - 1 !== pairs.length)
|
|
2684
|
+
throw new Error(`git diff-tree --stdin returned ${records.length - 1} frames for ${pairs.length} pairs`);
|
|
2685
|
+
const frames = pairs.map((_, index) => {
|
|
2686
|
+
if (index === 0)
|
|
2687
|
+
return records[0].split('\n').filter(Boolean).join('\n');
|
|
2688
|
+
const lines = records[index].replace(/^\n/, '').split('\n');
|
|
2689
|
+
lines.shift();
|
|
2690
|
+
return lines.filter(Boolean).join('\n');
|
|
2691
|
+
});
|
|
2692
|
+
for (const { row, main, branch } of cleanIndexes) {
|
|
2693
|
+
results.set(row.path, { base: row.base, ops: projectWorktreeSpecDelta(frames[main], frames[branch], frames[branch], '') });
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
catch (error) {
|
|
2697
|
+
for (const { row } of cleanIndexes)
|
|
2698
|
+
results.set(row.path, { error });
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
await Promise.all(dirty.map(async (row) => {
|
|
2702
|
+
try {
|
|
2703
|
+
const run = (args) => gitARequired(['-C', row.path, '-c', 'core.quotePath=false', ...args]);
|
|
2704
|
+
const [mainOut, workOut, commOut] = await Promise.all([
|
|
2705
|
+
run(['diff', '--name-status', '-M', mainSha, '--', '.spec']),
|
|
2706
|
+
run(['diff', '--name-status', '-M', row.base, '--', '.spec']),
|
|
2707
|
+
run(['diff', '--name-status', '-M', `${row.base}...${row.head}`, '--', '.spec']),
|
|
2708
|
+
]);
|
|
2709
|
+
results.set(row.path, { base: row.base, ops: projectWorktreeSpecDelta(mainOut, workOut, commOut, row.statusOut) });
|
|
2710
|
+
}
|
|
2711
|
+
catch (error) {
|
|
2712
|
+
results.set(row.path, { error });
|
|
2713
|
+
}
|
|
2714
|
+
}));
|
|
2715
|
+
if (gitInterpretationIdentity(root) !== interpretation) {
|
|
2716
|
+
const error = new Error('Git interpretation changed during the worktree overlay batch');
|
|
2717
|
+
for (const demand of demands)
|
|
2718
|
+
results.set(demand.path, { error });
|
|
2719
|
+
}
|
|
2720
|
+
return results;
|
|
2721
|
+
}
|
|
2722
|
+
export async function worktreeSpecDelta(wtPath, mainRef, baseHint) {
|
|
2723
|
+
const run = (args) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args]);
|
|
2724
|
+
// fork point = where this worktree branched from main; '' (no common ancestor / unreadable ref) falls
|
|
2725
|
+
// back to mainRef so we still surface changes rather than going silent. The caller (cachedDelta) already
|
|
2726
|
+
// computes this same merge-base to key its cache, so it passes it in to avoid a redundant subprocess.
|
|
2727
|
+
const base = baseHint || (await run(['merge-base', mainRef, 'HEAD'])).trim() || mainRef;
|
|
2728
|
+
// the four queries are independent — run them in parallel.
|
|
2729
|
+
const [mainOut, workOut, commOut, statusOut] = await Promise.all([
|
|
2730
|
+
run(['diff', '--name-status', '-M', mainRef, '--', '.spec']),
|
|
2731
|
+
run(['diff', '--name-status', '-M', base, '--', '.spec']),
|
|
2732
|
+
run(['diff', '--name-status', '-M', `${base}...HEAD`, '--', '.spec']),
|
|
2733
|
+
run(['status', '--porcelain', '--untracked-files=all', '--', '.spec']),
|
|
2734
|
+
]);
|
|
2735
|
+
return projectWorktreeSpecDelta(mainOut, workOut, commOut, statusOut);
|
|
2736
|
+
}
|