@awebai/oats 0.31.0 → 0.32.0
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/bin/oats.mjs +141 -36
- package/docs/configuration.md +65 -8
- package/docs/desktop-cli-api.md +121 -16
- package/docs/desktop.md +16 -0
- package/docs/implementation.md +58 -0
- package/docs/oats-local.schema.json +2 -1
- package/docs/plans/0.30-close-out.md +7 -4
- package/docs/release-notes/v0.32.0.md +137 -0
- package/lib/automations.mjs +5 -1
- package/lib/core.mjs +75 -29
- package/lib/instance-inspect.mjs +2 -1
- package/lib/instance-resolution.mjs +3 -3
- package/lib/local-inputs.mjs +46 -0
- package/lib/materialize.mjs +3 -2
- package/lib/packages.mjs +26 -6
- package/lib/remote.mjs +792 -36
- package/lib/resolve.mjs +23 -7
- package/lib/servers.mjs +7 -0
- package/lib/workspace.mjs +163 -56
- package/package.json +1 -1
package/lib/remote.mjs
CHANGED
|
@@ -76,20 +76,69 @@
|
|
|
76
76
|
* is set, else "644" (Git stores only 100644/100755, so a local checkout under
|
|
77
77
|
* any umask digests identically to the fetched tree). Empty directories do not
|
|
78
78
|
* enter identity. Result: "sha256-<hex>". Empty tree → sha256 of the empty input.
|
|
79
|
+
*
|
|
80
|
+
* READ SESSION (one per CLI command; docs/implementation.md "The remote read path"):
|
|
81
|
+
* every in-process memo this module keeps lives in a session object carried as
|
|
82
|
+
* `options.session` (createReadSession). Without one, every call behaves exactly as
|
|
83
|
+
* it did before sessions existed: no memo, no batch process, no file under the cache
|
|
84
|
+
* root beyond the cache repos. A session holds
|
|
85
|
+
* - one `ls-remote` per (cache root, url, ref args): a host that is also a member is
|
|
86
|
+
* observed once, and every reader in the command sees the same commit; at most
|
|
87
|
+
* OBSERVE_LIMIT observations run at once, each holding its slot for ALL its git work
|
|
88
|
+
* (the ls-remote, and the fetch of the commit it names or of a reused record's);
|
|
89
|
+
* - the member prefetch (prefetchObservation; lib/workspace.mjs discoverWorkspace): a
|
|
90
|
+
* workspace discovery starts its members' observations with the host's, from the
|
|
91
|
+
* member list at the host's last observed commit (lastObservedCommit + peekAtCommit,
|
|
92
|
+
* no git process); a prefetched failure is adopted, never retried in the command;
|
|
93
|
+
* - positive peels per (cache repo, oid) — a negative peel is never kept;
|
|
94
|
+
* - one recursive tree index per (cache repo, commit): `git ls-tree -r -t -l -z` once,
|
|
95
|
+
* answering entryAt / listRemoteTree / remoteTreeOids exactly as per-path listings
|
|
96
|
+
* would; any index failure (the output budget, a timeout) falls back to per-path;
|
|
97
|
+
* - one long-lived `git cat-file --batch` per cache repo (at most BATCH_LIMIT alive),
|
|
98
|
+
* unref'd while idle and ended by session.close();
|
|
99
|
+
* - `maxAge` (seconds): > 0 lets observeRemote reuse a recorded head observation
|
|
100
|
+
* (the observation store below) no older than that;
|
|
101
|
+
* - the head observations used, for the `observation` output block.
|
|
102
|
+
*
|
|
103
|
+
* PARSED CACHE (`memoAtCommit`, session only): a value derived only from the bytes at
|
|
104
|
+
* (repo key, commit) by a given kernel never changes, so it is kept on disk under
|
|
105
|
+
* `<cacheRoot>/.parsed/<kernel fingerprint>/<sha256(item key)>.json`, written
|
|
106
|
+
* atomically (temp file + rename), read back only when every recorded field matches,
|
|
107
|
+
* bounded (LRU by mtime, pruned at most once per session), and never holding a
|
|
108
|
+
* transient failure (any E_REMOTE_UNREADABLE) or a value whose JSON round trip would
|
|
109
|
+
* differ. A corrupt entry is a miss, never an error.
|
|
110
|
+
*
|
|
111
|
+
* OBSERVATION STORE (session only): `<cacheRoot>/.observed/<sha256(key NUL at-args NUL urlDigest)>.json`
|
|
112
|
+
* records each successful live head observation { v, key, args, urlDigest, commit, ref,
|
|
113
|
+
* observedAt } — never the url itself (it may carry userinfo); two spellings of one repo
|
|
114
|
+
* keep a record each. A record is reused only
|
|
115
|
+
* under `maxAge`, only when key, ref args and urlDigest all match, and never in place of
|
|
116
|
+
* a live observation that failed.
|
|
79
117
|
*/
|
|
80
|
-
import {
|
|
81
|
-
import { createHash } from "node:crypto";
|
|
118
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
119
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
120
|
+
import { setMaxListeners } from "node:events";
|
|
82
121
|
import {
|
|
83
122
|
closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync,
|
|
84
|
-
writeFileSync, writeSync, symlinkSync, readlinkSync } from "node:fs";
|
|
123
|
+
statSync, utimesSync, writeFileSync, writeSync, symlinkSync, readlinkSync } from "node:fs";
|
|
85
124
|
import { homedir } from "node:os";
|
|
86
|
-
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
125
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
87
126
|
import { fileURLToPath } from "node:url";
|
|
88
127
|
import { oatsError } from "./errors.mjs";
|
|
128
|
+
import { killGroup } from "./process-group.mjs";
|
|
89
129
|
|
|
90
130
|
export const FILE_BUDGET = 4 * 1024 * 1024; // readRemoteFile: 4 MiB per file
|
|
91
131
|
export const TREE_BUDGET = 64 * 1024 * 1024; // fetchRemoteTree: 64 MiB per subtree
|
|
92
132
|
export const GIT_TIMEOUT_MS = 30_000;
|
|
133
|
+
/** A session's tree index: the output budget of one `ls-tree -r -t -l -z <commit>`. */
|
|
134
|
+
export const TREE_INDEX_BUDGET = 64 * 1024 * 1024;
|
|
135
|
+
/** `--max-age` bounds (seconds). */
|
|
136
|
+
export const MAX_AGE_LIMIT = 86_400;
|
|
137
|
+
/** At most this many head observations running at once per session (real and prefetched), each for all its
|
|
138
|
+
* git work: ls-remote and the fetch of its commit, or a reused record's fetch. */
|
|
139
|
+
export const OBSERVE_LIMIT = 8;
|
|
140
|
+
/** At most this many `cat-file --batch` children alive per session (idle ones are closed first). */
|
|
141
|
+
const BATCH_LIMIT = 12;
|
|
93
142
|
const OID_RE = /^[0-9a-f]{40}$/;
|
|
94
143
|
const SEGMENT_RE = /^[A-Za-z0-9_.-]+$/;
|
|
95
144
|
/** A ref name we are willing to hand to `git ls-remote` as a pattern: never a leading
|
|
@@ -210,25 +259,64 @@ function gitEnv() {
|
|
|
210
259
|
|
|
211
260
|
/** Default exec dependency: runs `git <args>`; resolves { stdout, stderr } (Buffers);
|
|
212
261
|
* rejects with { code, signal, killed, stderr, stdout, timedOut, overflowed }.
|
|
213
|
-
* timedOut — the `timeout` kill (
|
|
214
|
-
* overflowed — stdout
|
|
262
|
+
* timedOut — the `timeout` kill (killed=true, signal SIGKILL, no error.code);
|
|
263
|
+
* overflowed — stdout or stderr exceeded `maxBuffer` (the child is killed too, and
|
|
215
264
|
* error.code = ERR_CHILD_PROCESS_STDIO_MAXBUFFER): NOT a timeout.
|
|
216
|
-
*
|
|
217
|
-
|
|
265
|
+
* An abort of `signal` rejects with an AbortError (code ABORT_ERR). git runs detached,
|
|
266
|
+
* as its own process group: a timeout, an overflow or an abort kills the group, so
|
|
267
|
+
* git's ssh or remote helper dies with it (a kill of git alone left them running until
|
|
268
|
+
* their connection ended). Injectable via options.exec. */
|
|
269
|
+
export function runGit(args, { cwd, maxBuffer = 16 * 1024 * 1024, timeout = GIT_TIMEOUT_MS, signal } = {}) {
|
|
218
270
|
return new Promise((resolvePromise, reject) => {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
271
|
+
if (signal?.aborted) { reject(abortError(signal)); return; }
|
|
272
|
+
// stdin is a pipe that is never written, as execFile gave it.
|
|
273
|
+
const child = spawn("git", args, { cwd, env: gitEnv(), detached: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
274
|
+
const out = [], err = [], size = { out: 0, err: 0 };
|
|
275
|
+
let done = false;
|
|
276
|
+
const output = () => ({ stdout: Buffer.concat(out), stderr: Buffer.concat(err) });
|
|
277
|
+
const settle = (error, value) => {
|
|
278
|
+
if (done) return;
|
|
279
|
+
done = true;
|
|
280
|
+
clearTimeout(timer);
|
|
281
|
+
signal?.removeEventListener("abort", onAbort);
|
|
282
|
+
if (error) reject(error); else resolvePromise(value);
|
|
283
|
+
};
|
|
284
|
+
const stop = (error) => {
|
|
285
|
+
if (done) return;
|
|
286
|
+
killGroup(child);
|
|
287
|
+
child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy();
|
|
288
|
+
settle(Object.assign(error, output()));
|
|
289
|
+
};
|
|
290
|
+
const onAbort = () => stop(abortError(signal));
|
|
291
|
+
const timer = timeout > 0 ? setTimeout(() => stop(Object.assign(new Error(`Command failed: git ${args.join(" ")} (timed out after ${timeout} ms)`),
|
|
292
|
+
{ code: null, killed: true, signal: "SIGKILL", timedOut: true, overflowed: false })), timeout) : null;
|
|
293
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
294
|
+
const collect = (chunks, key) => (chunk) => {
|
|
295
|
+
const room = maxBuffer - size[key];
|
|
296
|
+
if (chunk.length > room) {
|
|
297
|
+
chunks.push(chunk.subarray(0, Math.max(0, room))); size[key] = maxBuffer;
|
|
298
|
+
stop(Object.assign(new RangeError(`${key === "out" ? "stdout" : "stderr"} maxBuffer length exceeded`),
|
|
299
|
+
{ code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", killed: true, signal: "SIGKILL", overflowed: true, timedOut: false }));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
chunks.push(chunk); size[key] += chunk.length;
|
|
303
|
+
};
|
|
304
|
+
child.stdout.on("data", collect(out, "out"));
|
|
305
|
+
child.stderr.on("data", collect(err, "err"));
|
|
306
|
+
child.on("error", (error) => { killGroup(child); settle(Object.assign(error, output(), { overflowed: false, timedOut: false })); });
|
|
307
|
+
child.on("close", (code, exitSignal) => {
|
|
308
|
+
if (code === 0) { settle(null, output()); return; }
|
|
309
|
+
const { stdout, stderr } = output();
|
|
310
|
+
settle(Object.assign(new Error(`Command failed: git ${args.join(" ")}\n${stderr.toString("utf8")}`),
|
|
311
|
+
{ code, signal: exitSignal, killed: false, stdout, stderr, overflowed: false, timedOut: exitSignal === "SIGKILL" }));
|
|
312
|
+
});
|
|
229
313
|
});
|
|
230
314
|
}
|
|
231
315
|
|
|
316
|
+
function abortError(signal) {
|
|
317
|
+
return Object.assign(new Error("The operation was aborted", { cause: signal?.reason }), { name: "AbortError", code: "ABORT_ERR", overflowed: false, timedOut: false });
|
|
318
|
+
}
|
|
319
|
+
|
|
232
320
|
function stderrText(error) {
|
|
233
321
|
const s = error?.stderr;
|
|
234
322
|
return Buffer.isBuffer(s) ? s.toString("utf8") : typeof s === "string" ? s : String(error?.message ?? "");
|
|
@@ -280,10 +368,23 @@ async function withCacheLock(dir, fn) {
|
|
|
280
368
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
281
369
|
const isLockRace = (error) => /\.lock': File exists|Unable to create .*\.lock|another git process seems to be running/i.test(stderrText(error));
|
|
282
370
|
|
|
371
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
372
|
+
const cacheRootOf = (options) => options.cacheDir ?? defaultCacheRoot();
|
|
373
|
+
const cacheDirOf = (root, ref) => join(root, sha256(ref.key));
|
|
374
|
+
|
|
375
|
+
/** A url without its userinfo (`ssh://user:secret@host/…` → `ssh://host/…`): what may be written to disk. */
|
|
376
|
+
function redactUrl(url) {
|
|
377
|
+
return typeof url === "string" ? url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^@/]*@/i, "$1") : url;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function repoHandle(dir, exec, session) {
|
|
381
|
+
const local = (args, opts = {}) => exec(["-C", dir, "-c", "gc.auto=0", ...args], { cwd: dir, ...(session ? { signal: session.signal } : {}), ...opts });
|
|
382
|
+
return { dir, exec, local, session };
|
|
383
|
+
}
|
|
384
|
+
|
|
283
385
|
async function cacheRepo(ref, options) {
|
|
284
386
|
const exec = options.exec ?? runGit;
|
|
285
|
-
const
|
|
286
|
-
const dir = join(root, createHash("sha256").update(ref.key).digest("hex"));
|
|
387
|
+
const dir = cacheDirOf(cacheRootOf(options), ref);
|
|
287
388
|
if (!existsSync(join(dir, "HEAD"))) {
|
|
288
389
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
289
390
|
try { await exec(["init", "-q", "--bare", dir]); }
|
|
@@ -291,14 +392,381 @@ async function cacheRepo(ref, options) {
|
|
|
291
392
|
// Another process may have won the init race; a usable repo is all we need.
|
|
292
393
|
if (!existsSync(join(dir, "HEAD"))) throw unreadable(ref, error, { cacheDir: dir, stage: "init" });
|
|
293
394
|
}
|
|
294
|
-
try { writeFileSync(join(dir, "oats-remote.json"), JSON.stringify({ key: ref.key, url: ref.url }, null, 2) + "\n", { flag: "wx" }); } catch {}
|
|
395
|
+
try { writeFileSync(join(dir, "oats-remote.json"), JSON.stringify({ key: ref.key, url: redactUrl(ref.url) }, null, 2) + "\n", { flag: "wx" }); } catch {}
|
|
295
396
|
}
|
|
296
|
-
|
|
297
|
-
return { dir, exec, local };
|
|
397
|
+
return repoHandle(dir, exec, sessionOf(options));
|
|
298
398
|
}
|
|
299
399
|
|
|
300
400
|
function pinRef(oid) { return `refs/oats/commits/${oid}`; }
|
|
301
401
|
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// read session
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
class ReadSession {
|
|
407
|
+
constructor({ maxAge = 0, now = Date.now, fingerprint = null, treeIndexBudget = TREE_INDEX_BUDGET, parsedLimits = null, batchTimeoutMs = GIT_TIMEOUT_MS } = {}) {
|
|
408
|
+
if (!Number.isInteger(maxAge) || maxAge < 0 || maxAge > MAX_AGE_LIMIT) throw new TypeError(`maxAge must be an integer from 0 to ${MAX_AGE_LIMIT}`);
|
|
409
|
+
this.maxAge = maxAge;
|
|
410
|
+
this.now = now;
|
|
411
|
+
this.startedAt = new Date(now()).toISOString();
|
|
412
|
+
this.fingerprint = fingerprint; // tests inject one; else the kernel's own (kernelFingerprint)
|
|
413
|
+
this.treeIndexBudget = treeIndexBudget;
|
|
414
|
+
this.batchTimeoutMs = batchTimeoutMs; // tests shorten it; a batch answer is otherwise waited for as long as any git call
|
|
415
|
+
this.parsedLimits = parsedLimits; // tests inject small prune bounds
|
|
416
|
+
this.observations = new Map(); // memo key → Promise<head observation>
|
|
417
|
+
this.used = new Map(); // memo key → { observedAt, reused }: the heads this command used
|
|
418
|
+
this.peels = new Map(); // `${cacheRepo}\0${oid}` → peeled commit (positive answers only)
|
|
419
|
+
this.trees = new Map(); // `${cacheRepo}\0${commit}` → Promise<tree index | null>
|
|
420
|
+
this.batches = new Map(); // cacheRepo → batch reader, in least-recently-used order
|
|
421
|
+
this.retiring = new Set(); // evicted batch readers still ending
|
|
422
|
+
this.parsedDirs = new Set(); // fingerprint dirs touched (mtime bumped) this session
|
|
423
|
+
this.observeActive = 0; // observations running now (at most OBSERVE_LIMIT) …
|
|
424
|
+
this.observeQueue = []; // … and the ones waiting for a slot, in order: { resolve, reject }
|
|
425
|
+
this.prefetched = new Map(); // memo key → prefetch entry { promise, abandoned } no caller has adopted yet
|
|
426
|
+
this.aborter = new AbortController(); // close()/closeNow() kill every git child still running for this session
|
|
427
|
+
this.signal = this.aborter.signal;
|
|
428
|
+
setMaxListeners(0, this.signal); // every running git child listens (up to 16 at once): no leak warning
|
|
429
|
+
this.pruned = false;
|
|
430
|
+
this.closed = false;
|
|
431
|
+
}
|
|
432
|
+
/** One of the session's OBSERVE_LIMIT observation slots, shared by real and prefetched observations and
|
|
433
|
+
* held for all of an observation's git work (no unbounded fan-out over a large workspace). A released
|
|
434
|
+
* slot passes straight to the next waiter. Nothing holding a slot ever waits for another one. */
|
|
435
|
+
observeSlot() {
|
|
436
|
+
if (this.closed) return Promise.reject(abandonedError());
|
|
437
|
+
if (this.observeActive < OBSERVE_LIMIT) { this.observeActive++; return Promise.resolve(); }
|
|
438
|
+
return new Promise((resolve, reject) => this.observeQueue.push({ resolve, reject }));
|
|
439
|
+
}
|
|
440
|
+
observeDone() {
|
|
441
|
+
const next = this.observeQueue.shift();
|
|
442
|
+
if (next) next.resolve(); else this.observeActive--;
|
|
443
|
+
}
|
|
444
|
+
/** Give up every prefetch no caller has adopted: a queued one never runs its git, and its memo entry is
|
|
445
|
+
* dropped so a later caller observes afresh. One already running finishes, or is killed by close(). */
|
|
446
|
+
abandonPrefetches() {
|
|
447
|
+
for (const [key, entry] of this.prefetched) {
|
|
448
|
+
entry.abandoned = true;
|
|
449
|
+
if (this.observations.get(key) === entry.promise) this.observations.delete(key);
|
|
450
|
+
}
|
|
451
|
+
this.prefetched.clear();
|
|
452
|
+
}
|
|
453
|
+
/** The command is over: nothing queued runs, and every git child still running is killed. */
|
|
454
|
+
stop() {
|
|
455
|
+
this.closed = true;
|
|
456
|
+
this.abandonPrefetches();
|
|
457
|
+
for (const waiter of this.observeQueue.splice(0)) waiter.reject(abandonedError());
|
|
458
|
+
this.aborter.abort();
|
|
459
|
+
}
|
|
460
|
+
/** End the batch readers now (a long provider command is about to run) without ending the session:
|
|
461
|
+
* a later read opens a new one. */
|
|
462
|
+
async closeBatches() {
|
|
463
|
+
const batches = [...this.batches.values(), ...this.retiring];
|
|
464
|
+
this.batches.clear();
|
|
465
|
+
await Promise.all(batches.map((b) => b.close()));
|
|
466
|
+
this.retiring.clear();
|
|
467
|
+
}
|
|
468
|
+
/** `{ observedAt, reused }`: the OLDEST head observation this command used (else the time the
|
|
469
|
+
* session began), and whether any of them came from the observation store. */
|
|
470
|
+
observation() {
|
|
471
|
+
let oldest = null, reused = false;
|
|
472
|
+
for (const u of this.used.values()) {
|
|
473
|
+
if (u.reused) reused = true;
|
|
474
|
+
if (oldest === null || Date.parse(u.observedAt) < Date.parse(oldest)) oldest = u.observedAt;
|
|
475
|
+
}
|
|
476
|
+
return { observedAt: oldest ?? this.startedAt, reused };
|
|
477
|
+
}
|
|
478
|
+
/** End the session: nothing queued runs, every git child still running is killed, and every batch child
|
|
479
|
+
* is ended (stdin closed, awaited (bounded), killed if it lingers). */
|
|
480
|
+
async close() {
|
|
481
|
+
this.stop();
|
|
482
|
+
await this.closeBatches();
|
|
483
|
+
}
|
|
484
|
+
/** Synchronous close for `process.on("exit")`: no waiting is possible there. */
|
|
485
|
+
closeNow() {
|
|
486
|
+
this.stop();
|
|
487
|
+
for (const b of [...this.batches.values(), ...this.retiring]) b.kill();
|
|
488
|
+
this.batches.clear();
|
|
489
|
+
this.retiring.clear();
|
|
490
|
+
}
|
|
491
|
+
/** A session rides remoteOptions, which callers may serialise (memo keys): never its innards. */
|
|
492
|
+
toJSON() { return "[oats read session]"; }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** One command's read session (see the module header). `maxAge` seconds (0 = observe live). Test seams:
|
|
496
|
+
* `now`, `fingerprint`, `treeIndexBudget`, `parsedLimits`, `batchTimeoutMs`. */
|
|
497
|
+
export function createReadSession(options = {}) { return new ReadSession(options); }
|
|
498
|
+
/** An observation the command no longer wants (its session closed, or its prefetch abandoned): never adopted
|
|
499
|
+
* by a caller that is still reading, so its shape only has to be a typed remote failure. */
|
|
500
|
+
function abandonedError() {
|
|
501
|
+
return fail("E_REMOTE_UNREADABLE", "observation abandoned: the command no longer needs it", { reason: "unknown", abandoned: true });
|
|
502
|
+
}
|
|
503
|
+
/** Give up this session's prefetches that no caller has adopted (lib/workspace.mjs discoverWorkspace, when the
|
|
504
|
+
* host cannot be observed or once its members are read). Session only; never throws. */
|
|
505
|
+
export function abandonPrefetches(options = {}) { sessionOf(options)?.abandonPrefetches(); }
|
|
506
|
+
function sessionOf(options) { return options?.session instanceof ReadSession ? options.session : null; }
|
|
507
|
+
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
// the parsed cache: values derived from the bytes at (repo key, commit)
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
/** The prune bounds (count AND bytes, least recently used first; maintainer amendment 5). */
|
|
513
|
+
export const PARSED_LIMITS = Object.freeze({
|
|
514
|
+
maxEntries: 8192, maxBytes: 128 * 1024 * 1024, // prune when either is exceeded …
|
|
515
|
+
keepEntries: 6144, keepBytes: 96 * 1024 * 1024, // … down to both of these
|
|
516
|
+
staleFingerprintMs: 7 * 24 * 3600 * 1000, // another kernel's directory, unused this long
|
|
517
|
+
staleObservationMs: 30 * 24 * 3600 * 1000, // an observation record this old
|
|
518
|
+
staleTempMs: 3600 * 1000, // a writer's temp file left by a crash
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
/** The kernel code fingerprint: sha256 over package.json's version, the resolved version of the
|
|
522
|
+
* `yaml` parser, then every lib/**\/*.mjs and docs/*.schema.json (path NUL bytes NUL), in sorted
|
|
523
|
+
* path order. A checkout whose parsing changed (its code, or a parser bumped without a kernel
|
|
524
|
+
* version bump) never reads another build's entries. Computed once per process, lazily. */
|
|
525
|
+
let fingerprintMemo = null;
|
|
526
|
+
export function kernelFingerprint() {
|
|
527
|
+
if (fingerprintMemo) return fingerprintMemo;
|
|
528
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
529
|
+
const files = [];
|
|
530
|
+
const walk = (dir) => {
|
|
531
|
+
for (const d of readdirSync(dir, { withFileTypes: true })) {
|
|
532
|
+
const p = join(dir, d.name);
|
|
533
|
+
if (d.isDirectory()) walk(p);
|
|
534
|
+
else if (d.isFile() && d.name.endsWith(".mjs")) files.push(p);
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
walk(join(root, "lib"));
|
|
538
|
+
for (const name of readdirSync(join(root, "docs"))) if (name.endsWith(".schema.json")) files.push(join(root, "docs", name));
|
|
539
|
+
const rels = files.map((f) => relative(root, f).split(sep).join("/")).sort();
|
|
540
|
+
const hash = createHash("sha256");
|
|
541
|
+
hash.update(String(JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version)); hash.update("\0");
|
|
542
|
+
hash.update(`yaml@${resolvedVersion("yaml")}`); hash.update("\0");
|
|
543
|
+
for (const rel of rels) { hash.update(rel); hash.update("\0"); hash.update(readFileSync(join(root, rel))); hash.update("\0"); }
|
|
544
|
+
fingerprintMemo = hash.digest("hex");
|
|
545
|
+
return fingerprintMemo;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** The version of the package `name` this kernel imports (the package.json above its resolved entry),
|
|
549
|
+
* or "unresolved". */
|
|
550
|
+
function resolvedVersion(name) {
|
|
551
|
+
try {
|
|
552
|
+
let dir = dirname(fileURLToPath(import.meta.resolve(name)));
|
|
553
|
+
for (;;) {
|
|
554
|
+
try {
|
|
555
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
556
|
+
if (pkg.name === name) return String(pkg.version);
|
|
557
|
+
} catch { /* no package.json here */ }
|
|
558
|
+
const up = dirname(dir);
|
|
559
|
+
if (up === dir) return "unresolved";
|
|
560
|
+
dir = up;
|
|
561
|
+
}
|
|
562
|
+
} catch { return "unresolved"; }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const TRANSIENT_REASONS = new Set(["timeout", "network", "auth", "unknown"]);
|
|
566
|
+
/** A value the parsed cache may keep: JSON-safe (its round trip is deepStrictEqual — plain or
|
|
567
|
+
* null-prototype objects, dense arrays, finite numbers other than -0, strings, booleans, null; no
|
|
568
|
+
* undefined, no cycle) and free of transient failures (any E_REMOTE_UNREADABLE, or a remote
|
|
569
|
+
* failure reason timeout/network/auth/unknown). → encoded { data, nullProto: [pointer] } | null */
|
|
570
|
+
function encodeCacheable(value) {
|
|
571
|
+
const nullProto = [];
|
|
572
|
+
const ancestors = new Set();
|
|
573
|
+
const esc = (k) => k.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
574
|
+
const ok = (v, ptr) => {
|
|
575
|
+
if (v === null || typeof v === "string" || typeof v === "boolean") return true;
|
|
576
|
+
if (typeof v === "number") return Number.isFinite(v) && !Object.is(v, -0);
|
|
577
|
+
if (typeof v !== "object" || ancestors.has(v)) return false;
|
|
578
|
+
if (Object.getOwnPropertySymbols(v).length) return false;
|
|
579
|
+
ancestors.add(v);
|
|
580
|
+
try {
|
|
581
|
+
const descriptors = Object.getOwnPropertyDescriptors(v);
|
|
582
|
+
if (Array.isArray(v)) {
|
|
583
|
+
if (Object.keys(descriptors).length !== v.length + 1) return false; // indices + length only
|
|
584
|
+
for (let i = 0; i < v.length; i++) { const d = descriptors[i]; if (!d || !("value" in d) || !d.enumerable || !ok(d.value, `${ptr}/${i}`)) return false; }
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
const proto = Object.getPrototypeOf(v);
|
|
588
|
+
if (proto !== Object.prototype && proto !== null) return false;
|
|
589
|
+
if (proto === null) nullProto.push(ptr);
|
|
590
|
+
if (v.code === "E_REMOTE_UNREADABLE" || (typeof v.code === "string" && v.code.startsWith("E_REMOTE_") && TRANSIENT_REASONS.has(v.reason))) return false;
|
|
591
|
+
for (const [k, d] of Object.entries(descriptors)) if (!("value" in d) || !d.enumerable || !ok(d.value, `${ptr}/${esc(k)}`)) return false;
|
|
592
|
+
return true;
|
|
593
|
+
} finally { ancestors.delete(v); }
|
|
594
|
+
};
|
|
595
|
+
return ok(value, "") ? { data: value, nullProto } : null;
|
|
596
|
+
}
|
|
597
|
+
/** A URL that may carry a secret: any userinfo on http(s) (a bare token), or a `user:password` userinfo on any
|
|
598
|
+
* scheme. Repo content can hold one (a member ref written with credentials); such a value is never written
|
|
599
|
+
* to the parsed cache. `ssh://git@host/…` (a user, no password) is not a secret. */
|
|
600
|
+
const CREDENTIAL_URL_RE = /\bhttps?:\/\/[^\s"'\/@]+@|\b[a-z][a-z0-9+.-]*:\/\/[^\s"'\/@:]+:[^\s"'\/@]*@/i;
|
|
601
|
+
function carriesCredential(text) { return CREDENTIAL_URL_RE.test(text); }
|
|
602
|
+
function reviveCached(data, nullProto) {
|
|
603
|
+
if (!nullProto.length) return data;
|
|
604
|
+
const wanted = new Set(nullProto);
|
|
605
|
+
const esc = (k) => k.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
606
|
+
const walk = (v, ptr) => {
|
|
607
|
+
if (Array.isArray(v)) { for (let i = 0; i < v.length; i++) v[i] = walk(v[i], `${ptr}/${i}`); return v; }
|
|
608
|
+
if (v === null || typeof v !== "object") return v;
|
|
609
|
+
const out = wanted.has(ptr) ? Object.create(null) : v;
|
|
610
|
+
for (const k of Object.keys(v)) {
|
|
611
|
+
const child = walk(v[k], `${ptr}/${esc(k)}`);
|
|
612
|
+
if (out === v && k !== "__proto__") v[k] = child; // an own "__proto__" key is data: never an assignment
|
|
613
|
+
else Object.defineProperty(out, k, { value: child, enumerable: true, writable: true, configurable: true });
|
|
614
|
+
}
|
|
615
|
+
return out;
|
|
616
|
+
};
|
|
617
|
+
return walk(data, "");
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Write `text` to `file` atomically: a temp file in the same directory, then rename. Failures are ignored
|
|
621
|
+
* (every store here is an optimisation, never an error). → whether it was written. */
|
|
622
|
+
function writeAtomicQuiet(file, text) {
|
|
623
|
+
const dir = dirname(file);
|
|
624
|
+
const tmp = join(dir, `.${file.slice(dir.length + 1)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
625
|
+
try {
|
|
626
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
627
|
+
writeFileSync(tmp, text, { mode: 0o600 });
|
|
628
|
+
renameSync(tmp, file);
|
|
629
|
+
return true;
|
|
630
|
+
} catch {
|
|
631
|
+
try { rmSync(tmp, { force: true }); } catch { /* nothing */ }
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
/** Read and parse a JSON store file → value | undefined (absent, unreadable, corrupt: a corrupt or partial
|
|
636
|
+
* file is removed when it can be). */
|
|
637
|
+
function readStoreFile(file) {
|
|
638
|
+
let text;
|
|
639
|
+
try { text = readFileSync(file, "utf8"); } catch { return undefined; }
|
|
640
|
+
try { return JSON.parse(text); }
|
|
641
|
+
catch { try { rmSync(file, { force: true }); } catch { /* nothing */ } return undefined; }
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function parsedDir(root, session) { return join(root, ".parsed", session.fingerprint ?? kernelFingerprint()); }
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* The parsed cache (see the module header): `compute()`'s value for `item` at (refText's key, commit),
|
|
648
|
+
* from `<cacheRoot>/.parsed/<fingerprint>/` when an intact entry is there — no git process — else computed,
|
|
649
|
+
* kept when cacheable, and returned. Without a session (library callers), or for a commit that is not a
|
|
650
|
+
* full OID, it only computes. A thrown compute() is never kept.
|
|
651
|
+
*/
|
|
652
|
+
export async function memoAtCommit(refText, commit, item, compute, options = {}) {
|
|
653
|
+
const slot = parsedSlot(refText, commit, item, options);
|
|
654
|
+
if (!slot) return compute();
|
|
655
|
+
if (slot.hit) return slot.value;
|
|
656
|
+
const { session, key, root, dir, fingerprint, file } = slot;
|
|
657
|
+
const value = await compute();
|
|
658
|
+
const encoded = encodeCacheable(value);
|
|
659
|
+
const text = encoded ? JSON.stringify({ v: 1, fingerprint, key, commit, item, nullProto: encoded.nullProto, value: encoded.data }) : null;
|
|
660
|
+
if (text !== null && !carriesCredential(text) && writeAtomicQuiet(file, text)) {
|
|
661
|
+
session.parsedDirs.add(dir);
|
|
662
|
+
if (!session.pruned) { session.pruned = true; pruneStores(root, { fingerprint, limits: session.parsedLimits ?? PARSED_LIMITS, now: session.now() }); }
|
|
663
|
+
}
|
|
664
|
+
return value;
|
|
665
|
+
}
|
|
666
|
+
/** The parsed-cache slot of (refText's key, commit, item) in this session → null when there is no cache
|
|
667
|
+
* (no session, not a full OID, an unparseable ref), else { …where, hit, value? } — a hit read and revived,
|
|
668
|
+
* a corrupt or mismatched entry removed (a miss). */
|
|
669
|
+
function parsedSlot(refText, commit, item, options) {
|
|
670
|
+
const session = sessionOf(options);
|
|
671
|
+
if (!session || typeof commit !== "string" || !OID_RE.test(commit) || typeof item !== "string") return null;
|
|
672
|
+
let key;
|
|
673
|
+
try { key = parseRepoRef(refText, options).key; } catch { return null; }
|
|
674
|
+
const root = cacheRootOf(options);
|
|
675
|
+
const dir = parsedDir(root, session);
|
|
676
|
+
const fingerprint = session.fingerprint ?? kernelFingerprint();
|
|
677
|
+
const file = join(dir, `${sha256(JSON.stringify([key, commit, item]))}.json`);
|
|
678
|
+
const slot = { session, key, root, dir, fingerprint, file, hit: false };
|
|
679
|
+
const entry = readStoreFile(file);
|
|
680
|
+
if (entry !== undefined) {
|
|
681
|
+
const intact = entry && typeof entry === "object" && entry.v === 1 && entry.fingerprint === fingerprint && entry.key === key
|
|
682
|
+
&& entry.commit === commit && entry.item === item && Object.hasOwn(entry, "value") && Array.isArray(entry.nullProto);
|
|
683
|
+
if (intact) {
|
|
684
|
+
touchParsed(file, dir, session);
|
|
685
|
+
return { ...slot, hit: true, value: reviveCached(entry.value, entry.nullProto) };
|
|
686
|
+
}
|
|
687
|
+
try { rmSync(file, { force: true }); } catch { /* a miss either way */ }
|
|
688
|
+
}
|
|
689
|
+
return slot;
|
|
690
|
+
}
|
|
691
|
+
/** A parsed-cache entry, read only: its value when an intact entry is there, else undefined. Never computes,
|
|
692
|
+
* never starts a git process (the member prefetch's source). */
|
|
693
|
+
export function peekAtCommit(refText, commit, item, options = {}) {
|
|
694
|
+
const slot = parsedSlot(refText, commit, item, options);
|
|
695
|
+
return slot?.hit ? slot.value : undefined;
|
|
696
|
+
}
|
|
697
|
+
/** A hit bumps the entry's mtime (LRU) and, once per session, its fingerprint directory's (in use). */
|
|
698
|
+
function touchParsed(file, dir, session) {
|
|
699
|
+
const now = new Date(session.now());
|
|
700
|
+
try { utimesSync(file, now, now); } catch { /* ignore */ }
|
|
701
|
+
if (!session.parsedDirs.has(dir)) { session.parsedDirs.add(dir); try { utimesSync(dir, now, now); } catch { /* ignore */ } }
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Bound the stores under `root` (one scan): the current fingerprint's entries, least recently used first,
|
|
706
|
+
* down to keepEntries AND keepBytes once over maxEntries OR maxBytes; other fingerprint directories unused
|
|
707
|
+
* for staleFingerprintMs; observation records older than staleObservationMs; temp files a crashed writer
|
|
708
|
+
* left. Never throws. → { removed, entries, bytes } for the current fingerprint.
|
|
709
|
+
*/
|
|
710
|
+
export function pruneStores(root, { fingerprint = kernelFingerprint(), limits = PARSED_LIMITS, now = Date.now() } = {}) {
|
|
711
|
+
const L = { ...PARSED_LIMITS, ...limits };
|
|
712
|
+
const list = (dir) => { try { return readdirSync(dir); } catch { return []; } };
|
|
713
|
+
const stat = (p) => { try { return statSync(p); } catch { return null; } };
|
|
714
|
+
const remove = (p, opts = {}) => { try { rmSync(p, { force: true, ...opts }); return true; } catch { return false; } };
|
|
715
|
+
const parsed = join(root, ".parsed");
|
|
716
|
+
let removed = 0, entries = 0, bytes = 0;
|
|
717
|
+
for (const name of list(parsed)) {
|
|
718
|
+
const dir = join(parsed, name);
|
|
719
|
+
if (name === fingerprint) {
|
|
720
|
+
const files = [];
|
|
721
|
+
for (const f of list(dir)) {
|
|
722
|
+
const p = join(dir, f), st = stat(p);
|
|
723
|
+
if (!st) continue;
|
|
724
|
+
if (f.endsWith(".tmp")) { if (now - st.mtimeMs > L.staleTempMs && remove(p)) removed++; continue; }
|
|
725
|
+
files.push({ p, size: st.size, mtime: st.mtimeMs });
|
|
726
|
+
}
|
|
727
|
+
entries = files.length; bytes = files.reduce((n, f) => n + f.size, 0);
|
|
728
|
+
if (entries > L.maxEntries || bytes > L.maxBytes) {
|
|
729
|
+
files.sort((a, b) => a.mtime - b.mtime);
|
|
730
|
+
for (const f of files) {
|
|
731
|
+
if (entries <= L.keepEntries && bytes <= L.keepBytes) break;
|
|
732
|
+
if (remove(f.p)) { removed++; entries--; bytes -= f.size; }
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
} else {
|
|
736
|
+
const st = stat(dir);
|
|
737
|
+
if (st && now - st.mtimeMs > L.staleFingerprintMs && remove(dir, { recursive: true })) removed++;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
const observed = join(root, ".observed");
|
|
741
|
+
for (const f of list(observed)) {
|
|
742
|
+
const p = join(observed, f), st = stat(p);
|
|
743
|
+
if (st && now - st.mtimeMs > (f.endsWith(".tmp") ? L.staleTempMs : L.staleObservationMs) && remove(p)) removed++;
|
|
744
|
+
}
|
|
745
|
+
return { removed, entries, bytes };
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// ---------------------------------------------------------------------------
|
|
749
|
+
// the observation store: successful live head observations, for --max-age
|
|
750
|
+
// ---------------------------------------------------------------------------
|
|
751
|
+
|
|
752
|
+
const observationFile = (root, ref, atArgs) => join(root, ".observed", `${sha256(`${ref.key}\0${atArgs.join("\0")}\0${sha256(ref.url)}`)}.json`);
|
|
753
|
+
|
|
754
|
+
/** A recorded observation of (ref.key, atArgs) reusable now under session.maxAge, else null. With
|
|
755
|
+
* `anyAge`, an intact record of any age (what the prefetch learns the last-read commit from). */
|
|
756
|
+
function readObservation(root, ref, atArgs, session, { anyAge = false } = {}) {
|
|
757
|
+
const rec = readStoreFile(observationFile(root, ref, atArgs));
|
|
758
|
+
if (!rec || typeof rec !== "object" || rec.v !== 1 || rec.key !== ref.key || rec.urlDigest !== sha256(ref.url)) return null;
|
|
759
|
+
if (!Array.isArray(rec.args) || rec.args.length !== atArgs.length || rec.args.some((a, i) => a !== atArgs[i])) return null;
|
|
760
|
+
if (typeof rec.commit !== "string" || !OID_RE.test(rec.commit) || (rec.ref !== null && typeof rec.ref !== "string")) return null;
|
|
761
|
+
const at = typeof rec.observedAt === "string" ? Date.parse(rec.observedAt) : NaN;
|
|
762
|
+
const now = session.now();
|
|
763
|
+
if (!Number.isFinite(at) || (!anyAge && (at > now + 5000 || now - at > session.maxAge * 1000))) return null;
|
|
764
|
+
return { commit: rec.commit, ref: rec.ref, observedAt: rec.observedAt };
|
|
765
|
+
}
|
|
766
|
+
function writeObservation(root, ref, atArgs, obs) {
|
|
767
|
+
writeAtomicQuiet(observationFile(root, ref, atArgs), JSON.stringify({ v: 1, key: ref.key, args: atArgs, urlDigest: sha256(ref.url), commit: obs.commit, ref: obs.ref, observedAt: obs.observedAt }));
|
|
768
|
+
}
|
|
769
|
+
|
|
302
770
|
/** The COMMIT <oid> peels to, when <oid> is present in the cache and is a commit or an
|
|
303
771
|
* annotated tag chain ending in one; null on a missing object, a pruned store, or a
|
|
304
772
|
* tag/tree/blob that does not peel to a commit. (`rev-parse <oid>^{commit}` peels.) */
|
|
@@ -321,12 +789,17 @@ async function objectType(repo, oid) {
|
|
|
321
789
|
* `refs/oats/tags/<oid>` pin so gc cannot break the chain either.
|
|
322
790
|
*/
|
|
323
791
|
async function ensureCommit(ref, oid, options) {
|
|
324
|
-
const
|
|
325
|
-
|
|
792
|
+
const dir = cacheDirOf(cacheRootOf(options), ref);
|
|
793
|
+
// A session keeps POSITIVE peels: a commit present in the cache stays present for the command
|
|
794
|
+
// (pinned, and nothing here runs gc). A negative answer is never kept.
|
|
795
|
+
const session = sessionOf(options);
|
|
796
|
+
const known = session?.peels.get(`${dir}\0${oid}`);
|
|
797
|
+
if (known) return { repo: repoHandle(dir, options.exec ?? runGit, session), commit: known };
|
|
798
|
+
const remember = (commit) => { if (session) { session.peels.set(`${dir}\0${oid}`, commit); session.peels.set(`${dir}\0${commit}`, commit); } return commit; };
|
|
326
799
|
return withCacheLock(dir, async () => {
|
|
327
800
|
const repo = await cacheRepo(ref, options);
|
|
328
801
|
const cached = await peelCommit(repo, oid);
|
|
329
|
-
if (cached) return { repo, commit: cached }; // pinned AND present AND (peels to) a commit
|
|
802
|
+
if (cached) return { repo, commit: remember(cached) }; // pinned AND present AND (peels to) a commit
|
|
330
803
|
let lastError;
|
|
331
804
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
332
805
|
try {
|
|
@@ -347,7 +820,7 @@ async function ensureCommit(ref, oid, options) {
|
|
|
347
820
|
}
|
|
348
821
|
await repo.local(["update-ref", pinRef(commit), commit]);
|
|
349
822
|
if (commit !== oid) await repo.local(["update-ref", `refs/oats/tags/${oid}`, oid]);
|
|
350
|
-
return { repo, commit };
|
|
823
|
+
return { repo, commit: remember(commit) };
|
|
351
824
|
});
|
|
352
825
|
}
|
|
353
826
|
|
|
@@ -391,23 +864,120 @@ function resolveAt(parsed, at) {
|
|
|
391
864
|
* at: undefined → remote default branch (ls-remote --symref HEAD); a full OID; or a tag/branch name.
|
|
392
865
|
* → { key, url, commit, ref, observedAt } | throws E_REMOTE_UNREADABLE { url, reason }.
|
|
393
866
|
* Never half-succeeds; never prompts. A full OID already in the cache costs no network call.
|
|
867
|
+
* With a session: one head observation per (cache root, url, ref) per command, and — under
|
|
868
|
+
* session.maxAge — a recorded observation no older than that is reused instead of `ls-remote`
|
|
869
|
+
* (the result then carries the RECORDED observedAt and `reused: true`). A full-OID `at` is not a
|
|
870
|
+
* head observation: never recorded, reused or counted.
|
|
394
871
|
*/
|
|
395
872
|
export async function observeRemote(refText, { at, ...options } = {}) {
|
|
396
873
|
const ref = parseRepoRef(refText, options);
|
|
397
|
-
const exec = options.exec ?? runGit;
|
|
398
874
|
if (at !== undefined && at !== null && typeof at !== "string") throw fail("E_REPO_REF", "at must be a string (full OID, tag or branch name)", { at });
|
|
399
875
|
if (typeof at === "string" && OID_RE.test(at)) {
|
|
400
876
|
// A tag OID is accepted here; the commit recorded is the one it peels to (M4).
|
|
401
877
|
const { commit } = await ensureCommit(ref, at, options);
|
|
402
878
|
return { key: ref.key, url: ref.url, commit, ref: null, observedAt: new Date().toISOString() };
|
|
403
879
|
}
|
|
880
|
+
const args = lsRemoteArgs(ref, at);
|
|
881
|
+
const session = sessionOf(options);
|
|
882
|
+
if (!session) return observeLive(ref, args, at, options);
|
|
883
|
+
const memoKey = JSON.stringify([cacheRootOf(options), args]);
|
|
884
|
+
const obs = await sessionObservation(ref, args, at, options, session, memoKey, false);
|
|
885
|
+
// Counted only here, by a caller that uses it: a prefetched head nobody asks for never is.
|
|
886
|
+
session.used.set(memoKey, { observedAt: obs.observedAt, reused: obs.reused === true });
|
|
887
|
+
return { ...obs, key: ref.key, url: ref.url };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** The `ls-remote` argv of a head observation (`at`: HEAD, a tag or a branch; never a full OID). */
|
|
891
|
+
function lsRemoteArgs(ref, at) {
|
|
404
892
|
const wantHead = at === undefined || at === null || at === "" || at === "HEAD";
|
|
405
893
|
if (!wantHead && AT_BAD_RE.test(at)) throw fail("E_REPO_REF", `at must be a full OID or a plain tag/branch name, got ${JSON.stringify(at)}`, { at });
|
|
406
894
|
const args = ["ls-remote", "--symref", ref.url];
|
|
407
895
|
if (wantHead) args.push("HEAD");
|
|
408
896
|
else args.push(`refs/tags/${at}`, `refs/tags/${at}^{}`, `refs/heads/${at}`, at);
|
|
897
|
+
return args;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* The session's one observation of (cache root, url, ref args): started by the first caller, adopted by
|
|
902
|
+
* every later one. A failure started by an ordinary caller is dropped from the memo, so a later caller
|
|
903
|
+
* retries. A failure started by a PREFETCH (prefetchObservation) is kept and adopted: it happened moments
|
|
904
|
+
* earlier in this same command, and a retry would double a 30 s timeout on an unreachable member
|
|
905
|
+
* (spec Addendum 3, item 4).
|
|
906
|
+
*/
|
|
907
|
+
function sessionObservation(ref, args, at, options, session, memoKey, prefetch) {
|
|
908
|
+
let pending = session.observations.get(memoKey);
|
|
909
|
+
if (!pending) {
|
|
910
|
+
const entry = prefetch ? { promise: null, abandoned: false } : null;
|
|
911
|
+
pending = observeInSession(ref, args, at, options, session, entry);
|
|
912
|
+
if (!prefetch) pending = pending.catch((error) => { session.observations.delete(memoKey); throw error; });
|
|
913
|
+
else { entry.promise = pending; session.prefetched.set(memoKey, entry); }
|
|
914
|
+
session.observations.set(memoKey, pending);
|
|
915
|
+
} else if (!prefetch) session.prefetched.delete(memoKey); // adopted: never abandoned from here on
|
|
916
|
+
return pending;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* The commit this machine last observed `refText` at (`at` as observeRemote takes it), from its observation
|
|
921
|
+
* record — of any age: this is not reuse — or the full OID `at` itself. Session only; no git process.
|
|
922
|
+
* → full OID | null (no session, no intact record for this key, ref args and url digest).
|
|
923
|
+
*/
|
|
924
|
+
export function lastObservedCommit(refText, { at, ...options } = {}) {
|
|
925
|
+
const session = sessionOf(options);
|
|
926
|
+
if (!session) return null;
|
|
927
|
+
try {
|
|
928
|
+
if (typeof at === "string" && OID_RE.test(at)) return at;
|
|
929
|
+
const ref = parseRepoRef(refText, options);
|
|
930
|
+
const record = readObservation(cacheRootOf(options), ref, lsRemoteArgs(ref, at).slice(3), session, { anyAge: true });
|
|
931
|
+
return record ? record.commit : null;
|
|
932
|
+
} catch { return null; }
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Start the session's head observation of `refText` now, without waiting for it (the member prefetch,
|
|
937
|
+
* spec Addendum 3): the same memoised observation a later observeRemote adopts — success or failure —
|
|
938
|
+
* under the same observation limit, reusing a record exactly as observeRemote would. Session only; an
|
|
939
|
+
* unparseable ref or a full-OID `at` is ignored. Never throws, never rejects unhandled.
|
|
940
|
+
*/
|
|
941
|
+
export function prefetchObservation(refText, { at, ...options } = {}) {
|
|
942
|
+
const session = sessionOf(options);
|
|
943
|
+
if (!session || (typeof at === "string" && OID_RE.test(at))) return;
|
|
944
|
+
try {
|
|
945
|
+
const ref = parseRepoRef(refText, options);
|
|
946
|
+
const args = lsRemoteArgs(ref, at);
|
|
947
|
+
sessionObservation(ref, args, at, options, session, JSON.stringify([cacheRootOf(options), args]), true).catch(() => { /* adopted by its caller */ });
|
|
948
|
+
} catch { /* not a ref: nothing to prefetch */ }
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** A head observation in a session: a reusable record (maxAge > 0) whose commit is pinned or can still be
|
|
952
|
+
* fetched, else live — and every successful live observation is recorded. A problem with a record is
|
|
953
|
+
* never an error (live instead); a failed live observation is exactly today's error. */
|
|
954
|
+
async function observeInSession(ref, args, at, options, session, prefetch = null) {
|
|
955
|
+
const root = cacheRootOf(options);
|
|
956
|
+
const atArgs = args.slice(3);
|
|
957
|
+
await session.observeSlot(); // held for every git process below: the reuse path's fetch as much as ls-remote's
|
|
958
|
+
try {
|
|
959
|
+
// A prefetch given up while it queued (or a session closed meanwhile) runs no git at all.
|
|
960
|
+
if (session.closed || prefetch?.abandoned) throw abandonedError();
|
|
961
|
+
if (session.maxAge > 0) {
|
|
962
|
+
const record = readObservation(root, ref, atArgs, session);
|
|
963
|
+
if (record) {
|
|
964
|
+
const reused = { key: ref.key, url: ref.url, commit: record.commit, ref: record.ref, observedAt: record.observedAt, reused: true };
|
|
965
|
+
if (existsSync(join(cacheDirOf(root, ref), pinRef(record.commit)))) return reused;
|
|
966
|
+
try { return { ...reused, commit: (await ensureCommit(ref, record.commit, options)).commit }; }
|
|
967
|
+
catch { /* the recorded commit can no longer be fetched: observe live */ }
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
const obs = await observeLive(ref, args, at, options);
|
|
971
|
+
writeObservation(root, ref, atArgs, obs);
|
|
972
|
+
return obs;
|
|
973
|
+
} finally { session.observeDone(); }
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
async function observeLive(ref, args, at, options) {
|
|
977
|
+
const exec = options.exec ?? runGit;
|
|
978
|
+
const signal = sessionOf(options)?.signal;
|
|
409
979
|
let out;
|
|
410
|
-
try { out = await exec(args, { timeout: GIT_TIMEOUT_MS }); }
|
|
980
|
+
try { out = await exec(args, { timeout: GIT_TIMEOUT_MS, ...(signal ? { signal } : {}) }); }
|
|
411
981
|
catch (error) { throw unreadable(ref, error, { at: at ?? null }); }
|
|
412
982
|
const parsed = parseLsRemote(out.stdout);
|
|
413
983
|
const hit = resolveAt(parsed, at);
|
|
@@ -472,11 +1042,11 @@ function assertNoCollisions(entries, ref, commit, prefix) {
|
|
|
472
1042
|
* Any other failure is E_REMOTE_UNREADABLE (reason "timeout" for the timeout kill, else
|
|
473
1043
|
* "unknown") — never a raw Node/git error: enumerateRepo turns E_REMOTE_* into a problem
|
|
474
1044
|
* row and would otherwise abort the whole discovery on one unexplained listing (L4). */
|
|
475
|
-
async function lsTree(repo, spec, { flags = [], path, ref, commit } = {}) {
|
|
1045
|
+
async function lsTree(repo, spec, { flags = [], path, ref, commit, maxBuffer = 64 * 1024 * 1024 } = {}) {
|
|
476
1046
|
try {
|
|
477
1047
|
const args = ["ls-tree", "-l", "-z", ...flags, spec];
|
|
478
1048
|
if (path !== undefined) args.push("--", ...[].concat(path));
|
|
479
|
-
const out = await repo.local(args, { maxBuffer
|
|
1049
|
+
const out = await repo.local(args, { maxBuffer });
|
|
480
1050
|
return parseLsTree(out.stdout);
|
|
481
1051
|
} catch (error) {
|
|
482
1052
|
if (typeof error?.code === "string" && error.code.startsWith("E_")) throw error; // already an oats error
|
|
@@ -488,13 +1058,177 @@ async function lsTree(repo, spec, { flags = [], path, ref, commit } = {}) {
|
|
|
488
1058
|
}
|
|
489
1059
|
}
|
|
490
1060
|
|
|
1061
|
+
/** A session's recursive listing of <commit> (one `ls-tree -r -t -l -z` per cache repo and commit):
|
|
1062
|
+
* → Promise<{ list, pos } | null>, null without a session or when the listing failed (the output budget,
|
|
1063
|
+
* a timeout, anything) — every caller then falls back to today's per-path listing, so the index can
|
|
1064
|
+
* never raise an error per-path reading would not. `list` is in git's order: an entry, then (for a
|
|
1065
|
+
* tree) everything below it, contiguously. `pos` maps a path to its FIRST entry. */
|
|
1066
|
+
function treeIndex(repo, commit, ref) {
|
|
1067
|
+
const session = repo.session;
|
|
1068
|
+
if (!session) return null;
|
|
1069
|
+
const k = `${repo.dir}\0${commit}`;
|
|
1070
|
+
let index = session.trees.get(k);
|
|
1071
|
+
if (!index) {
|
|
1072
|
+
index = lsTree(repo, commit, { flags: ["-r", "-t"], ref, commit, maxBuffer: session.treeIndexBudget }).then((list) => {
|
|
1073
|
+
if (!list) return null;
|
|
1074
|
+
const pos = new Map();
|
|
1075
|
+
list.forEach((e, i) => { if (!pos.has(e.path)) pos.set(e.path, i); });
|
|
1076
|
+
return { list, pos };
|
|
1077
|
+
}, () => null);
|
|
1078
|
+
session.trees.set(k, index);
|
|
1079
|
+
}
|
|
1080
|
+
return index;
|
|
1081
|
+
}
|
|
1082
|
+
/** The index entries below the tree at `rel` ("" = the root), paths relative to it — what
|
|
1083
|
+
* `ls-tree -r -t <commit>:<rel>` lists, in the same order. */
|
|
1084
|
+
function indexDescendants(index, rel) {
|
|
1085
|
+
if (!rel) return index.list;
|
|
1086
|
+
const i = index.pos.get(rel);
|
|
1087
|
+
if (i === undefined) return [];
|
|
1088
|
+
const prefix = `${rel}/`, out = [];
|
|
1089
|
+
for (let j = i + 1; j < index.list.length && index.list[j].path.startsWith(prefix); j++) out.push({ ...index.list[j], path: index.list[j].path.slice(prefix.length) });
|
|
1090
|
+
return out;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
491
1093
|
/** Return the single ls-tree entry for <commit>:<path>, or null when absent. */
|
|
492
1094
|
async function entryAt(repo, commit, path, ref) {
|
|
1095
|
+
const index = await treeIndex(repo, commit, ref);
|
|
1096
|
+
if (index) { const i = index.pos.get(path); return i === undefined ? null : index.list[i]; }
|
|
493
1097
|
const entries = await lsTree(repo, commit, { path, ref, commit });
|
|
494
1098
|
if (!entries) return null;
|
|
495
1099
|
return entries.find((e) => e.path === path) ?? null;
|
|
496
1100
|
}
|
|
497
1101
|
|
|
1102
|
+
/** Kill our own, still-running child (never a pid a failed spawn left at 0: lib/process-group.mjs). */
|
|
1103
|
+
function killChild(child) {
|
|
1104
|
+
if (child.exitCode === null && child.signalCode === null) killGroup(child, "SIGKILL");
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* A session's `git cat-file --batch` for one cache repo: read(oid, budget) → Promise<Buffer>. Spawned
|
|
1109
|
+
* like repo.local (same `-C <dir> -c gc.auto=0`, cwd, helper-free gitEnv()); unref'd while idle, so it
|
|
1110
|
+
* never keeps the process alive. A header over `budget` rejects that request with `{ oversize }` and its
|
|
1111
|
+
* body is skipped, never buffered; a non-blob answers as `cat-file blob` would ("bad file"). `missing`,
|
|
1112
|
+
* the child dying (or never starting: ENOENT), or no answer within GIT_TIMEOUT_MS rejects EVERY pending
|
|
1113
|
+
* request with a git-shaped error (classifyRemoteFailure reads it as today: timeout → "timeout") and
|
|
1114
|
+
* ends the child; the next read starts a new one.
|
|
1115
|
+
*/
|
|
1116
|
+
function openBatch(dir, timeoutMs = GIT_TIMEOUT_MS) {
|
|
1117
|
+
// Detached, as its own process group: killChild's group kill also ends what git started (a lazy fetch).
|
|
1118
|
+
const child = spawn("git", ["-C", dir, "-c", "gc.auto=0", "cat-file", "--batch"], { cwd: dir, env: gitEnv(), detached: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
1119
|
+
const queue = []; // { resolve, reject, budget, size? }
|
|
1120
|
+
let chunks = [], length = 0, skip = 0, stderr = "", timer = null, dead = null, exited = null;
|
|
1121
|
+
const exitedPromise = new Promise((r) => { exited = r; });
|
|
1122
|
+
const handles = [child, child.stdin, child.stdout, child.stderr];
|
|
1123
|
+
const idle = () => { for (const h of handles) h?.unref?.(); };
|
|
1124
|
+
const busy = () => { for (const h of handles) h?.ref?.(); };
|
|
1125
|
+
const arm = () => {
|
|
1126
|
+
clearTimeout(timer); timer = null;
|
|
1127
|
+
if (!queue.length) { idle(); return; }
|
|
1128
|
+
timer = setTimeout(() => die(Object.assign(new Error(`git cat-file --batch: no answer within ${timeoutMs} ms`), { killed: true, signal: "SIGKILL", timedOut: true, stderr: Buffer.from(stderr) })), timeoutMs);
|
|
1129
|
+
};
|
|
1130
|
+
const die = (error) => {
|
|
1131
|
+
if (dead) return;
|
|
1132
|
+
dead = error; reader.dead = true;
|
|
1133
|
+
clearTimeout(timer); timer = null;
|
|
1134
|
+
killChild(child);
|
|
1135
|
+
while (queue.length) queue.shift().reject(error);
|
|
1136
|
+
idle();
|
|
1137
|
+
};
|
|
1138
|
+
const take = (n) => {
|
|
1139
|
+
const out = Buffer.allocUnsafe(n);
|
|
1140
|
+
let off = 0;
|
|
1141
|
+
while (off < n) {
|
|
1142
|
+
const c = chunks[0], k = Math.min(c.length, n - off);
|
|
1143
|
+
c.copy(out, off, 0, k); off += k;
|
|
1144
|
+
if (k === c.length) chunks.shift(); else chunks[0] = c.subarray(k);
|
|
1145
|
+
}
|
|
1146
|
+
length -= n;
|
|
1147
|
+
return out;
|
|
1148
|
+
};
|
|
1149
|
+
const drop = (n) => {
|
|
1150
|
+
length -= n;
|
|
1151
|
+
while (n > 0) { const c = chunks[0]; if (c.length <= n) { n -= c.length; chunks.shift(); } else { chunks[0] = c.subarray(n); n = 0; } }
|
|
1152
|
+
};
|
|
1153
|
+
const lineEnd = () => { let off = 0; for (const c of chunks) { const i = c.indexOf(10); if (i >= 0) return off + i; off += c.length; } return -1; };
|
|
1154
|
+
const pump = () => {
|
|
1155
|
+
for (;;) {
|
|
1156
|
+
if (skip > 0) { const n = Math.min(skip, length); drop(n); skip -= n; if (skip > 0) return; }
|
|
1157
|
+
if (!length) return;
|
|
1158
|
+
const head = queue[0];
|
|
1159
|
+
if (!head) { die(Object.assign(new Error("git cat-file --batch: unexpected output"), { stderr: Buffer.from(stderr) })); return; }
|
|
1160
|
+
if (head.size === undefined) {
|
|
1161
|
+
const nl = lineEnd();
|
|
1162
|
+
if (nl < 0) { if (length > 4096) die(Object.assign(new Error("git cat-file --batch: malformed header"), { stderr: Buffer.from(stderr) })); return; }
|
|
1163
|
+
const line = take(nl + 1).toString("utf8").slice(0, -1);
|
|
1164
|
+
const [name, type, sizeText, ...rest] = line.split(" ");
|
|
1165
|
+
if (type === "missing" || type === undefined) { die(Object.assign(new Error(line), { code: 128, stderr: Buffer.from(`fatal: Not a valid object name ${name}\n`) })); return; }
|
|
1166
|
+
const size = Number(sizeText);
|
|
1167
|
+
if (rest.length || !Number.isSafeInteger(size) || size < 0) { die(Object.assign(new Error(`git cat-file --batch: malformed header ${JSON.stringify(line)}`), { stderr: Buffer.from(stderr) })); return; }
|
|
1168
|
+
if (type !== "blob") { queue.shift().reject(Object.assign(new Error(`${name} is a ${type}`), { code: 128, stderr: Buffer.from(`fatal: git cat-file ${name}: bad file\n`) })); skip = size + 1; arm(); continue; }
|
|
1169
|
+
if (size > head.budget) { queue.shift().reject(Object.assign(new Error(`${name} is ${size} bytes`), { oversize: size })); skip = size + 1; arm(); continue; }
|
|
1170
|
+
head.size = size;
|
|
1171
|
+
}
|
|
1172
|
+
if (length < head.size + 1) return;
|
|
1173
|
+
const body = take(head.size);
|
|
1174
|
+
drop(1); // the LF after every body
|
|
1175
|
+
queue.shift().resolve(body);
|
|
1176
|
+
arm();
|
|
1177
|
+
}
|
|
1178
|
+
};
|
|
1179
|
+
child.stdout.on("data", (chunk) => { chunks.push(chunk); length += chunk.length; pump(); });
|
|
1180
|
+
child.stderr.on("data", (chunk) => { if (stderr.length < 65536) stderr += chunk.toString("utf8"); });
|
|
1181
|
+
child.stdin.on("error", () => { /* EPIPE after the child died: 'close' reports it */ });
|
|
1182
|
+
child.on("error", (error) => { die(error); exited(); }); // ENOENT and friends: never a hang
|
|
1183
|
+
child.on("close", (code, signal) => { die(Object.assign(new Error(`git cat-file --batch exited (${signal ?? code})`), { code, signal, stderr: Buffer.from(stderr) })); exited(); });
|
|
1184
|
+
idle();
|
|
1185
|
+
const reader = {
|
|
1186
|
+
dead: false,
|
|
1187
|
+
read(oid, budget) {
|
|
1188
|
+
if (dead) return Promise.reject(dead);
|
|
1189
|
+
return new Promise((resolvePromise, reject) => {
|
|
1190
|
+
queue.push({ resolve: resolvePromise, reject, budget });
|
|
1191
|
+
busy();
|
|
1192
|
+
if (queue.length === 1) arm();
|
|
1193
|
+
child.stdin.write(`${oid}\n`);
|
|
1194
|
+
});
|
|
1195
|
+
},
|
|
1196
|
+
/** End stdin; wait (bounded) for the child to go; kill it if it lingers. */
|
|
1197
|
+
async close() {
|
|
1198
|
+
reader.dead = true; // never handed out again; git still answers what it was already asked
|
|
1199
|
+
if (!dead) { try { child.stdin.end(); } catch { /* already closed */ } }
|
|
1200
|
+
if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined) return;
|
|
1201
|
+
const t = setTimeout(() => killChild(child), 2000);
|
|
1202
|
+
await exitedPromise;
|
|
1203
|
+
clearTimeout(t);
|
|
1204
|
+
},
|
|
1205
|
+
/** End it now (process exit, eviction): dead at once, so nothing is ever queued on it again. */
|
|
1206
|
+
kill() { try { child.stdin.destroy(); } catch { /* nothing */ } die(Object.assign(new Error("git cat-file --batch was ended"), { stderr: Buffer.from(stderr) })); },
|
|
1207
|
+
};
|
|
1208
|
+
return reader;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/** The session's batch reader for this cache repo (least recently used idle ones closed beyond BATCH_LIMIT);
|
|
1212
|
+
* null without a session, after close(), or with an injected exec (tests keep per-blob reads). */
|
|
1213
|
+
function catFileBatch(repo) {
|
|
1214
|
+
const session = repo.session;
|
|
1215
|
+
if (!session || session.closed || repo.exec !== runGit) return null;
|
|
1216
|
+
let reader = session.batches.get(repo.dir);
|
|
1217
|
+
if (reader && !reader.dead) { session.batches.delete(repo.dir); session.batches.set(repo.dir, reader); return reader; }
|
|
1218
|
+
reader = openBatch(repo.dir, session.batchTimeoutMs);
|
|
1219
|
+
session.batches.set(repo.dir, reader);
|
|
1220
|
+
if (session.batches.size > BATCH_LIMIT) {
|
|
1221
|
+
for (const [dir, other] of session.batches) {
|
|
1222
|
+
if (session.batches.size <= BATCH_LIMIT) break;
|
|
1223
|
+
if (other === reader) continue;
|
|
1224
|
+
session.batches.delete(dir);
|
|
1225
|
+
session.retiring.add(other); // git answers what it was already asked, then ends on EOF
|
|
1226
|
+
other.close().finally(() => session.retiring.delete(other));
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
return reader;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
498
1232
|
/**
|
|
499
1233
|
* → { bytes, size } | E_REMOTE_UNREADABLE | E_REMOTE_PATH_MISSING { path } | E_REMOTE_FILE_OVERSIZE { path, size, budget }
|
|
500
1234
|
* A symlink at <path> is refused (E_REMOTE_TREE_UNSAFE { path, why: "symlink" }); a directory → E_REMOTE_PATH_MISSING.
|
|
@@ -507,7 +1241,18 @@ export async function readRemoteFile(refText, commitArg, path, options = {}) {
|
|
|
507
1241
|
const entry = await entryAt(repo, commit, rel, ref);
|
|
508
1242
|
if (!entry || entry.type !== "blob") throw fail("E_REMOTE_PATH_MISSING", `${rel} is not a file in ${ref.key}@${commit.slice(0, 12)}`, { path: rel, key: ref.key, commit });
|
|
509
1243
|
if (entry.mode === "120000") throw fail("E_REMOTE_TREE_UNSAFE", `${rel} is a symlink`, { path: rel, why: "symlink", key: ref.key, commit });
|
|
510
|
-
|
|
1244
|
+
const oversize = (size) => fail("E_REMOTE_FILE_OVERSIZE", `${rel} is ${size} bytes (budget ${FILE_BUDGET})`, { path: rel, size, budget: FILE_BUDGET, key: ref.key, commit });
|
|
1245
|
+
if (entry.size > FILE_BUDGET) throw oversize(entry.size);
|
|
1246
|
+
const batch = catFileBatch(repo);
|
|
1247
|
+
if (batch) {
|
|
1248
|
+
let bytes;
|
|
1249
|
+
try { bytes = await batch.read(entry.oid, FILE_BUDGET); }
|
|
1250
|
+
catch (error) {
|
|
1251
|
+
if (error?.oversize !== undefined) throw oversize(error.oversize);
|
|
1252
|
+
throw unreadable(ref, error, { commit, path: rel });
|
|
1253
|
+
}
|
|
1254
|
+
return { bytes, size: bytes.length };
|
|
1255
|
+
}
|
|
511
1256
|
let out;
|
|
512
1257
|
try { out = await repo.local(["cat-file", "blob", entry.oid], { maxBuffer: FILE_BUDGET + 1024 }); }
|
|
513
1258
|
catch (error) { throw unreadable(ref, error, { commit, path: rel }); }
|
|
@@ -521,9 +1266,19 @@ export async function remoteTreeOids(refText, commitArg, dirs, options = {}) {
|
|
|
521
1266
|
const ref = parseRepoRef(refText, options);
|
|
522
1267
|
requireCommit(commitArg);
|
|
523
1268
|
const rels = dirs.map((dir) => normalizeTreePath(dir, { allowRoot: false }));
|
|
524
|
-
|
|
525
|
-
const
|
|
526
|
-
|
|
1269
|
+
// Pure at the commit: a session keeps the answer in the parsed cache (as [dir, oid] pairs).
|
|
1270
|
+
const pairs = await memoAtCommit(ref, commitArg, `tree-oids\0${rels.join("\0")}`, async () => {
|
|
1271
|
+
const { repo, commit } = await ensureCommit(ref, commitArg, options);
|
|
1272
|
+
// One listing of nested paths (`a` beside `a/b`) recurses into `a` and never shows it: only the
|
|
1273
|
+
// per-path listing reproduces that, so nested requests keep it.
|
|
1274
|
+
const requested = new Set(rels);
|
|
1275
|
+
const nested = rels.some((rel) => rel.split("/").slice(0, -1).some((_, i, parts) => requested.has(parts.slice(0, i + 1).join("/"))));
|
|
1276
|
+
const index = rels.length && !nested ? await treeIndex(repo, commit, ref) : null;
|
|
1277
|
+
if (index) return rels.map((rel) => { const i = index.pos.get(rel); return i !== undefined && index.list[i].type === "tree" ? index.list[i].oid : null; });
|
|
1278
|
+
const entries = rels.length ? (await lsTree(repo, commit, { path: rels, ref, commit })) ?? [] : [];
|
|
1279
|
+
return rels.map((rel) => entries.find((e) => e.path === rel && e.type === "tree")?.oid ?? null);
|
|
1280
|
+
}, options);
|
|
1281
|
+
return new Map(dirs.map((dir, i) => [dir, pairs[i]]));
|
|
527
1282
|
}
|
|
528
1283
|
|
|
529
1284
|
/** A browsable URL of a repo at a commit (feature desktop-facts): the file's page with `path`, else the tree.
|
|
@@ -549,7 +1304,8 @@ export async function listRemoteTree(refText, commitArg, dir, { depth = 2, ...op
|
|
|
549
1304
|
const entry = await entryAt(repo, commit, rel, ref);
|
|
550
1305
|
if (!entry || entry.type !== "tree") return [];
|
|
551
1306
|
}
|
|
552
|
-
const
|
|
1307
|
+
const index = await treeIndex(repo, commit, ref);
|
|
1308
|
+
const entries = index ? indexDescendants(index, rel) : await lsTree(repo, spec, { flags: ["-r", "-t"], ref, commit });
|
|
553
1309
|
if (!entries) return [];
|
|
554
1310
|
const result = [];
|
|
555
1311
|
for (const e of entries) {
|