@awebai/oats 0.32.0 → 0.33.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/lib/remote.mjs CHANGED
@@ -6,30 +6,41 @@
6
6
  * 1. `observeRemote` resolves `at` with `git ls-remote --symref <url> …` —
7
7
  * never a fetch when the caller already gave a full OID.
8
8
  * 2. Every read (`readRemoteFile`, `listRemoteTree`, `fetchRemoteTree`) needs the
9
- * commit's objects locally. `ensureCommit` does a shallow
10
- * `git fetch --depth 1 --no-tags <url> <oid>` into a content-addressed BARE
11
- * cache repo under `<cacheRoot>/<sha256(key)>/` and then pins the commit with
12
- * `refs/oats/commits/<oid>` so `gc` cannot prune it. The pin doubles as the
13
- * "already fetched" marker: a pinned commit is never fetched again.
9
+ * commit locally. `ensureCommit` does a shallow, partial
10
+ * `git fetch --depth 1 --no-tags --filter=blob:limit=64k origin <oid>` into a
11
+ * content-addressed BARE cache repo under `<cacheRoot>/<sha256(key)>/` and then
12
+ * pins the commit with `refs/oats/commits/<oid>` so `gc` cannot prune it (the
13
+ * pins are also what a later fetch advertises as `have`, so it is incremental).
14
+ * The commit arrives with ALL its trees (every listing is complete) and the blobs
15
+ * up to SMALL_BLOB_LIMIT: every file discovery reads, in one round trip.
14
16
  * 3. Reads are then local plumbing: `git ls-tree -r -t -l -z` for listing and
15
- * `git cat-file blob` for bytes. `git archive --remote` is NOT used: GitHub and
16
- * most https hosts refuse it, and per-entry plumbing lets us inspect every
17
- * mode (symlink / submodule / oversize) BEFORE anything touches the disk.
17
+ * `git cat-file blob` for bytes. A read that needs a larger blob fetches it first
18
+ * (`ensureBlobs`: one fetch by blob id per request, a whole subtree for
19
+ * `fetchRemoteTree`); git never fetches one on its own (GIT_NO_LAZY_FETCH=1, and
20
+ * the cache stores no url to fetch from). `git archive --remote` is NOT used:
21
+ * GitHub and most https hosts refuse it, and per-entry plumbing lets us inspect
22
+ * every mode (symlink / submodule / oversize) BEFORE anything touches the disk.
18
23
  *
19
- * DEVIATION FROM THE CONTRACT (named on purpose, not silently resolved): the
20
- * contract says "shallow git fetch --depth 1 --filter=blob:none". A blob-less
21
- * partial fetch would make every later `cat-file` a lazy per-blob network
22
- * round-trip through the promisor machinery, and topping a filtered commit up
23
- * to a full one afterwards requires `--refetch` semantics that vary by server.
24
- * We fetch depth-1 WITHOUT a blob filter: one round-trip per commit, blobs
25
- * present, correct on every server that allows fetching an advertised OID.
24
+ * PARTIAL CACHES (awebai/oats#384): the cache is a partial clone of the remote
25
+ * "origin" whose url is never written (it may carry credentials): every fetch passes
26
+ * it as `-c remote.origin.url=<url>`. `ls-tree -l` prints "BAD" for the size of a blob
27
+ * the cache lacks, so an unknown size is null until ensureBlobs fetches the blob, and
28
+ * every budget is applied to real sizes. A server that cannot serve partial fetches
29
+ * (no filter support; or blob wants refused, so the commit is fetched again with
30
+ * `--refetch`) gets whole trees from then on: the cache records `oats.fetch = full`,
31
+ * and the session's `notices` say so once. So does a git older than PARTIAL_FETCH_GIT
32
+ * (2.45, which brought GIT_NO_LAZY_FETCH): an older git would fetch a missing blob on
33
+ * its own or die, so every cache it touches fetches whole trees, and a partial cache it
34
+ * finds is deleted and fetched again whole (rebuildWhole). A lost `.lock` race on the
35
+ * cache repo's config or objects (another process starting the same cache) is retried.
26
36
  *
27
37
  * The cache is invisible plumbing: it may be wiped at any time (a wiped cache
28
38
  * simply re-fetches), and nothing outside this module references it.
29
39
  *
30
40
  * Nothing here ever prompts: GIT_TERMINAL_PROMPT=0, GIT_ASKPASS=/usr/bin/false,
31
41
  * ssh ALWAYS in BatchMode — `-o BatchMode=yes` is appended to the operator's own
32
- * GIT_SSH_COMMAND / core.sshCommand (or to plain `ssh`). Timeout 30 s per call.
42
+ * GIT_SSH_COMMAND / core.sshCommand (or to plain `ssh`). Timeout 30 s per call;
43
+ * 10 minutes for the fetch of a commit (GIT_FETCH_TIMEOUT_MS).
33
44
  *
34
45
  * KEY vs URL (post-0.25.0 fix M2): the canonical KEY (`<host>/<path>`, lowercase
35
46
  * host, no scheme, no `.git`) is the identity everywhere — the same repo written
@@ -59,9 +70,17 @@
59
70
  * collide on a case-/normalization-insensitive filesystem (README.md vs
60
71
  * readme.md, NFC vs NFD) are E_REMOTE_TREE_UNSAFE { why: "collision" }.
61
72
  *
62
- * CONCURRENCY: per-key operations on one cache repo are serialized in-process
63
- * (two observes of the same remote never race `git init` or `fetch`), and a
64
- * fetch that loses an on-disk `.lock` race to another process is retried.
73
+ * CONCURRENCY (awebai/oats#386): per-key operations on one cache repo are serialized
74
+ * in-process (withCacheLock), and every WRITE to a cache repo (init, config, fetch, pin)
75
+ * holds its cross-process write lock `<cacheRoot>/.locks/<repo>.lock` (withCacheWriteLock):
76
+ * a live holder is waited for (bounded by a whole fetch) and never stolen from, a dead
77
+ * one is reclaimed; reads take no lock. A cache repo is created whole (init into a private
78
+ * directory, then rename). A git `.lock` met under the write lock belongs to an older
79
+ * kernel's live write (retried briefly) or to a git that was killed: one older than the
80
+ * longest fetch, inside the cache and a regular file, is removed (a session notice says
81
+ * so) and the write made once more; any other is E_REMOTE_UNREADABLE { reason: "cache" }
82
+ * naming it. git is ended with SIGTERM first (it removes its own locks), SIGKILL after a
83
+ * grace unless its group is seen empty first (process-group.mjs terminateGroup, watchGroup).
65
84
  *
66
85
  * CACHE PIN vs OBJECTS: the pin ref is the fast-path marker, but a wiped or
67
86
  * pruned object store is detected (`rev-parse <oid>^{commit}`) and refetched;
@@ -119,17 +138,25 @@ import { execFileSync, spawn } from "node:child_process";
119
138
  import { createHash, randomBytes } from "node:crypto";
120
139
  import { setMaxListeners } from "node:events";
121
140
  import {
122
- closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync,
123
- statSync, utimesSync, writeFileSync, writeSync, symlinkSync, readlinkSync } from "node:fs";
141
+ closeSync, existsSync, linkSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync,
142
+ statSync, unlinkSync, utimesSync, writeFileSync, writeSync, symlinkSync, readlinkSync } from "node:fs";
124
143
  import { homedir } from "node:os";
125
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
144
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
126
145
  import { fileURLToPath } from "node:url";
127
146
  import { oatsError } from "./errors.mjs";
128
- import { killGroup } from "./process-group.mjs";
147
+ import { killGroup, signalGroup, terminateGroup, watchGroup } from "./process-group.mjs";
129
148
 
130
149
  export const FILE_BUDGET = 4 * 1024 * 1024; // readRemoteFile: 4 MiB per file
131
150
  export const TREE_BUDGET = 64 * 1024 * 1024; // fetchRemoteTree: 64 MiB per subtree
151
+ /** A commit is fetched with its trees and the blobs up to this size; a read fetches any larger blob it needs. */
152
+ export const SMALL_BLOB_LIMIT = 64 * 1024;
153
+ /** The oldest git that keeps a partial cache honest: GIT_NO_LAZY_FETCH arrived in 2.45; an older git fetches a
154
+ * missing blob on its own, or dies trying. With an older git every cache fetches whole trees. */
155
+ export const PARTIAL_FETCH_GIT = [2, 45];
132
156
  export const GIT_TIMEOUT_MS = 30_000;
157
+ /** The fetch of a commit into the cache: the first one transfers the commit's whole tree, which for a
158
+ * large workspace host takes far longer than GIT_TIMEOUT_MS (awebai/oats#362). */
159
+ export const GIT_FETCH_TIMEOUT_MS = 600_000;
133
160
  /** A session's tree index: the output budget of one `ls-tree -r -t -l -z <commit>`. */
134
161
  export const TREE_INDEX_BUDGET = 64 * 1024 * 1024;
135
162
  /** `--max-age` bounds (seconds). */
@@ -254,23 +281,50 @@ function gitEnv() {
254
281
  GIT_ASKPASS: "/usr/bin/false",
255
282
  GIT_SSH_COMMAND: sshCommand(),
256
283
  GIT_LITERAL_PATHSPECS: "1",
284
+ // A cache holds a commit's trees and its small blobs (SMALL_BLOB_LIMIT); a read fetches what else it needs
285
+ // first (ensureBlobs). git must never fetch a missing blob on its own, one round trip at a time.
286
+ GIT_NO_LAZY_FETCH: "1",
257
287
  };
258
288
  }
259
289
 
290
+ /** Every git child this module started that has not exited yet (runGit's and the batch readers'): what
291
+ * reapOnExit still has to end when the process exits. */
292
+ const liveChildren = new Set();
293
+ /** The exit path (`process.on("exit")`, ReadSession.closeNow) cannot wait for a timer, so the graceful kill
294
+ * is done synchronously and bounded: every live git group gets SIGTERM, the process blocks for at most
295
+ * EXIT_GRACE_MS (git removes its lock files on SIGTERM in far less), then SIGKILL ends what is left. A
296
+ * child's pid cannot be reused meanwhile: Node has not reaped it. */
297
+ const EXIT_GRACE_MS = 200;
298
+ function reapOnExit() {
299
+ // A child stays tracked until its stdio closes: a leader that exited while a descendant still holds its pipes
300
+ // is still here, and its group still gets both signals.
301
+ const children = [...liveChildren];
302
+ if (!children.length) return;
303
+ for (const child of children) signalGroup(child, "SIGTERM");
304
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, EXIT_GRACE_MS);
305
+ for (const child of children) signalGroup(child, "SIGKILL");
306
+ liveChildren.clear();
307
+ }
308
+
260
309
  /** Default exec dependency: runs `git <args>`; resolves { stdout, stderr } (Buffers);
261
310
  * rejects with { code, signal, killed, stderr, stdout, timedOut, overflowed }.
262
- * timedOut — the `timeout` kill (killed=true, signal SIGKILL, no error.code);
311
+ * timedOut — the `timeout` kill (killed=true, signal SIGTERM, no error.code); set by
312
+ * our own timer only: a git that some other process killed (an OOM kill)
313
+ * exits with its signal and timedOut false;
263
314
  * overflowed — stdout or stderr exceeded `maxBuffer` (the child is killed too, and
264
315
  * error.code = ERR_CHILD_PROCESS_STDIO_MAXBUFFER): NOT a timeout.
265
316
  * An abort of `signal` rejects with an AbortError (code ABORT_ERR). git runs detached,
266
317
  * as its own process group: a timeout, an overflow or an abort kills the group, so
267
318
  * 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 } = {}) {
319
+ * their connection ended). `input` is written to git's stdin, which is then closed. Injectable via options.exec. */
320
+ export function runGit(args, { cwd, maxBuffer = 16 * 1024 * 1024, timeout = GIT_TIMEOUT_MS, input, signal } = {}) {
270
321
  return new Promise((resolvePromise, reject) => {
271
322
  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"] });
323
+ // stdin is a pipe: written with `input` and closed, else never written, as execFile gave it.
324
+ const child = watchGroup(spawn("git", args, { cwd, env: gitEnv(), detached: true, stdio: ["pipe", "pipe", "pipe"] }));
325
+ liveChildren.add(child);
326
+ child.once("close", () => liveChildren.delete(child));
327
+ if (input !== undefined) { child.stdin.on("error", () => { /* EPIPE after the child died: 'close' reports it */ }); child.stdin.end(input); }
274
328
  const out = [], err = [], size = { out: 0, err: 0 };
275
329
  let done = false;
276
330
  const output = () => ({ stdout: Buffer.concat(out), stderr: Buffer.concat(err) });
@@ -283,20 +337,25 @@ export function runGit(args, { cwd, maxBuffer = 16 * 1024 * 1024, timeout = GIT_
283
337
  };
284
338
  const stop = (error) => {
285
339
  if (done) return;
286
- killGroup(child);
287
- child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy();
340
+ // SIGTERM first (git removes its lock files), SIGKILL after the grace. The error says which kill this
341
+ // is (`timedOut` for our own timer), never the signal that finally ended git.
342
+ terminateGroup(child);
343
+ // stdout and stderr are drained, never destroyed: 'close' must still wait for a descendant holding them, so
344
+ // terminateGroup's SIGKILL reaches it (and only it). Unref'd, they never keep the command alive.
345
+ child.stdin.destroy();
346
+ for (const s of [child.stdout, child.stderr]) { s.removeAllListeners("data"); s.resume(); s.unref?.(); }
288
347
  settle(Object.assign(error, output()));
289
348
  };
290
349
  const onAbort = () => stop(abortError(signal));
291
350
  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;
351
+ { code: null, killed: true, signal: "SIGTERM", timedOut: true, overflowed: false })), timeout) : null;
293
352
  signal?.addEventListener("abort", onAbort, { once: true });
294
353
  const collect = (chunks, key) => (chunk) => {
295
354
  const room = maxBuffer - size[key];
296
355
  if (chunk.length > room) {
297
356
  chunks.push(chunk.subarray(0, Math.max(0, room))); size[key] = maxBuffer;
298
357
  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 }));
358
+ { code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", killed: true, signal: "SIGTERM", overflowed: true, timedOut: false }));
300
359
  return;
301
360
  }
302
361
  chunks.push(chunk); size[key] += chunk.length;
@@ -308,7 +367,7 @@ export function runGit(args, { cwd, maxBuffer = 16 * 1024 * 1024, timeout = GIT_
308
367
  if (code === 0) { settle(null, output()); return; }
309
368
  const { stdout, stderr } = output();
310
369
  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" }));
370
+ { code, signal: exitSignal, killed: false, stdout, stderr, overflowed: false, timedOut: false }));
312
371
  });
313
372
  });
314
373
  }
@@ -322,12 +381,18 @@ function stderrText(error) {
322
381
  return Buffer.isBuffer(s) ? s.toString("utf8") : typeof s === "string" ? s : String(error?.message ?? "");
323
382
  }
324
383
 
325
- /** Classify a failed network git call into the contract's four reasons. A `maxBuffer`
384
+ /** Classify a failed network git call into the contract's reasons. A `maxBuffer`
326
385
  * overflow is never a timeout (the child is killed in both cases; only the timeout kill
327
- * counts) — it falls through to the stderr text, else "network". */
386
+ * counts) — it falls through to the stderr text, else "network". A timeout is our own
387
+ * timer's kill (`timedOut`), whatever signal it sent. A git lock still held by another
388
+ * process (a lost race that outlived retryLockRace, or a dead process's stale lock) is
389
+ * "cache": a local cache write failed, nothing about the remote. Any other signal exit is
390
+ * "killed" (the system killed git, e.g. out of memory). */
328
391
  export function classifyRemoteFailure(error) {
329
392
  if (error?.overflowed === true || error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return classifyText(error) ?? "network";
330
- if (error?.timedOut || error?.signal === "SIGKILL" || error?.signal === "SIGTERM") return "timeout";
393
+ if (error?.timedOut) return "timeout";
394
+ if (isLockRace(error) || isLocalWriteFailure(error)) return "cache";
395
+ if (typeof error?.signal === "string" && error.signal) return "killed";
331
396
  return classifyText(error) ?? "network";
332
397
  }
333
398
 
@@ -341,8 +406,44 @@ function classifyText(error) {
341
406
  }
342
407
 
343
408
  function unreadable(ref, error, extra = {}) {
344
- const reason = classifyRemoteFailure(error);
345
- return fail("E_REMOTE_UNREADABLE", `cannot read remote ${ref.url} (${reason})`, { url: ref.url, key: ref.key, reason, ...extra });
409
+ const reason = classifyRemoteFailure(error), killed = reason === "killed";
410
+ if (reason === "cache") return cacheFailure(ref, error, extra);
411
+ return fail("E_REMOTE_UNREADABLE", `cannot read remote ${ref.url} (${reason})${killed ? `: git was killed (signal ${error.signal})` : ""}`,
412
+ { url: ref.url, key: ref.key, reason, ...(killed ? { signal: error.signal } : {}), ...extra });
413
+ }
414
+
415
+ /** The lock file a git lock error names (`Unable to create '<path>.lock'`, `could not lock config file <path>`),
416
+ * else null. */
417
+ function lockFileOf(error, cwd = null) {
418
+ const text = stderrText(error);
419
+ const m = /Unable to create '([^']+\.lock)'/.exec(text) ?? /could not lock config file ([^:\s]+)/.exec(text);
420
+ if (!m) return null;
421
+ const file = m[1].endsWith(".lock") ? m[1] : `${m[1]}.lock`;
422
+ // git names a lock relative to its cwd (`could not lock config file config`), or as `<dir>/./refs/…`.
423
+ return isAbsolute(file) ? resolve(file) : cwd ? resolve(cwd, file) : file;
424
+ }
425
+
426
+ /** git could not write a file of the local cache repo (a FETCH_HEAD, a pack, an object it cannot open or
427
+ * create: permissions, a directory in the way, a read-only or full disk): a fact about this machine, never
428
+ * about the remote. Only local file wording counts: "Permission denied (publickey)" stays auth. */
429
+ const isLocalWriteFailure = (error) => /cannot open '[^']+': |unable to create temporary file|insufficient permission for adding an object|no space left on device|read-only file system|unable to write (?:file|new|loose|sha1|index)|could not write (?:to|file|index)/i.test(stderrText(error));
430
+
431
+ /** A write to the local cache repo failed — reason "cache" (a timeout stays "timeout"), never "network" — with a
432
+ * message that says what to do: a lock still held names its file, safe to remove once no oats process runs;
433
+ * any other failure carries git's own words. `extra.stage` says which write (init, pin, config, fetch). */
434
+ function cacheFailure(ref, error, extra = {}) {
435
+ const url = redactUrl(ref.url);
436
+ if (error?.timedOut) return fail("E_REMOTE_UNREADABLE", `cannot write the cache of ${url} (timeout)`, { url: ref.url, key: ref.key, reason: "timeout", ...extra });
437
+ const where = extra.cacheDir ? ` at ${extra.cacheDir}` : "";
438
+ const lock = isLockRace(error) ? lockFileOf(error, extra.cacheDir) : null;
439
+ if (isLockRace(error)) {
440
+ const held = lock ? `${lock} is still held` : "a git lock in it is still held";
441
+ return fail("E_REMOTE_UNREADABLE", `cannot write the cache of ${url}${where} (cache${extra.stage ? `, ${extra.stage}` : ""}): ${held} by another git process, or left by one that died; it is safe to remove once no oats or git process is running`,
442
+ { url: ref.url, key: ref.key, reason: "cache", ...extra, ...(lock ? { lock } : {}) });
443
+ }
444
+ const why = stderrText(error).trim().split("\n").filter(Boolean).join(" ") || error?.message || error?.code || "git failed";
445
+ const check = isLocalWriteFailure(error) ? `; check that ${extra.cacheDir ?? "the cache"} is writable by this user and its disk has room` : "";
446
+ return fail("E_REMOTE_UNREADABLE", `cannot write the cache of ${url}${where} (cache${extra.stage ? `, ${extra.stage}` : ""}): ${why}${check}`, { url: ref.url, key: ref.key, reason: "cache", ...extra });
346
447
  }
347
448
 
348
449
  // ---------------------------------------------------------------------------
@@ -366,7 +467,174 @@ async function withCacheLock(dir, fn) {
366
467
  }
367
468
 
368
469
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
369
- const isLockRace = (error) => /\.lock': File exists|Unable to create .*\.lock|another git process seems to be running/i.test(stderrText(error));
470
+ /** A write another process won (a git lock it holds, or the cache's `shallow` file it rewrote mid-fetch): a race
471
+ * on the local cache, retried, and never a fact about the remote. */
472
+ const isLockRace = (error) => /\.lock': File exists|Unable to create .*\.lock|another git process seems to be running|could not lock config file|shallow file has changed since we read it/i.test(stderrText(error));
473
+ /** How long a git lock is waited for before it is judged. Every oats writer of this kernel holds the cache's
474
+ * write lock (withCacheWriteLock), so a git lock met under it belongs to an older kernel's live write, or to a
475
+ * git that was killed: the wait covers the first, short writes; a fetch's lock is judged by its age. */
476
+ const LOCK_WAIT_MS = 3_000;
477
+ /** Run `fn` (one git call that writes the cache repo), again after a lost on-disk `.lock` race, until
478
+ * LOCK_WAIT_MS has passed (backoff up to 500 ms, jittered); a timeout or any other failure is never retried. */
479
+ async function retryLockRace(fn) {
480
+ const deadline = Date.now() + LOCK_WAIT_MS;
481
+ for (let attempt = 0; ; attempt++) {
482
+ try { return await fn(); }
483
+ catch (error) {
484
+ if (!isLockRace(error) || error.timedOut) throw error;
485
+ if (Date.now() >= deadline) throw error;
486
+ await sleep(Math.min(500, 50 * 2 ** attempt) * (0.5 + Math.random()));
487
+ }
488
+ }
489
+ }
490
+
491
+ // ---------------------------------------------------------------------------
492
+ // the cache write lock (cross-process)
493
+ // ---------------------------------------------------------------------------
494
+
495
+ /** How long a write waits for another live oats process writing the same cache: a whole fetch, and a margin. */
496
+ const CACHE_WRITE_WAIT_MS = GIT_FETCH_TIMEOUT_MS + 60_000;
497
+ /** A write lock with no readable owner (made by hand, or by a filesystem fault: ours is linked into place
498
+ * whole) is judged by its age instead. */
499
+ const UNREADABLE_LOCK_STALE_MS = 30_000;
500
+ /** How old a git `*.lock` must be before a write holding the cache write lock may remove it: older than the
501
+ * longest fetch any oats allows, so an older kernel's live fetch (it takes no write lock) is never broken. */
502
+ const GIT_LOCK_STALE_MS = GIT_FETCH_TIMEOUT_MS + 60_000;
503
+
504
+ function pidAlive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } }
505
+
506
+ /** The write lock of the cache repo `dir`: `<cacheRoot>/.locks/<repo>.lock`, outside git's own namespace. */
507
+ const cacheWriteLockOf = (dir) => join(dirname(dir), ".locks", `${basename(dir)}.lock`);
508
+
509
+ /** The write lock as { owner, ino, mtimeMs } (owner null when it holds no readable { pid, token }), or null
510
+ * when it is gone. A symlink or anything but a regular file is an unreadable lock; a lock that cannot be read
511
+ * at all (a directory in its place, EACCES) is thrown: retrying cannot clear it. */
512
+ function readCacheWriteLock(path) {
513
+ let st;
514
+ try { st = lstatSync(path); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
515
+ let owner = null;
516
+ if (st.isFile()) {
517
+ try { const o = JSON.parse(readFileSync(path, "utf8")); if (Number.isSafeInteger(o?.pid) && typeof o?.token === "string") owner = o; }
518
+ catch (error) { if (error.code === "ENOENT") return null; if (error.code && error.code !== "ENOENT" && !(error instanceof SyntaxError)) throw error; }
519
+ }
520
+ return { owner, ino: st.ino, mtimeMs: st.mtimeMs };
521
+ }
522
+
523
+ /**
524
+ * Run `fn` holding the cache repo's cross-process write lock (docs: oats-kernel-expert decision
525
+ * record-lock-liveness-tradeoff): every oats write to a cache repo — init, config, fetch, pin — happens under
526
+ * it, so two processes never write one cache at once; reads take no lock. The lock is an exclusive-create file
527
+ * holding { pid, token, startedAt }, linked into place whole. A live holder is waited for, never stolen from,
528
+ * whatever its age; a dead holder's lock (or an unreadable one past UNREADABLE_LOCK_STALE_MS) is removed after
529
+ * checking it is still the same file. Every pass that does not acquire falls through one deadline check and
530
+ * one sleep. At the deadline: E_REMOTE_UNREADABLE { reason: "cache", stage, cacheDir, lock, holderPid }; at once
531
+ * when a reclaimer died holding the reclaim guard (reclaimCacheWriteLock), with details.guard.
532
+ * `fn({ waited })`: whether another process held the lock first (what it wrote is worth checking again).
533
+ */
534
+ async function withCacheWriteLock(ref, dir, stage, session, fn) {
535
+ const signal = session?.signal;
536
+ const lock = cacheWriteLockOf(dir);
537
+ const owner = { pid: process.pid, token: randomBytes(12).toString("hex"), startedAt: new Date().toISOString() };
538
+ const url = redactUrl(ref.url);
539
+ const refuse = (message, extra = {}) => fail("E_REMOTE_UNREADABLE", `cannot write the cache of ${url} at ${dir} (cache, ${stage}): ${message}`,
540
+ { url: ref.url, key: ref.key, reason: "cache", stage, cacheDir: dir, lock, ...extra });
541
+ try { mkdirSync(dirname(lock), { recursive: true, mode: 0o700 }); } catch (error) { throw refuse(`cannot create ${dirname(lock)} (${error.code ?? error.message})`); }
542
+ const deadline = Date.now() + (session?.cacheWriteWaitMs ?? CACHE_WRITE_WAIT_MS);
543
+ let held = null, waited = false;
544
+ for (let attempt = 0; ; attempt++) {
545
+ if (signal?.aborted) throw unreadable(ref, abortError(signal), { cacheDir: dir, stage });
546
+ const tmp = `${lock}.${owner.pid}.${owner.token}`;
547
+ try {
548
+ writeFileSync(tmp, JSON.stringify(owner) + "\n", { mode: 0o600 });
549
+ try { linkSync(tmp, lock); break; } catch (error) { if (error.code !== "EEXIST") throw error; }
550
+ } catch (error) { throw refuse(`cannot take its write lock ${lock} (${error.code ?? error.message})`); }
551
+ finally { try { unlinkSync(tmp); } catch { /* never written */ } }
552
+ try { held = readCacheWriteLock(lock); } catch (error) { throw refuse(`cannot read its write lock ${lock} (${error.code ?? error.message}); remove it if no oats process is running`); }
553
+ if (held && isStaleLock(held)) {
554
+ const abandoned = reclaimCacheWriteLock(lock, held, owner);
555
+ if (abandoned) {
556
+ throw refuse(`${abandoned.guard} was left by ${abandoned.pid ? `oats process ${abandoned.pid}, which died` : "an oats process that died"} while reclaiming the write lock ${lock}; it is safe to remove once no oats process is running`,
557
+ { guard: abandoned.guard, ...(held.owner ? { holderPid: held.owner.pid } : {}) });
558
+ }
559
+ }
560
+ if (Date.now() >= deadline) {
561
+ throw refuse(held?.owner ? `oats process ${held.owner.pid} has been writing it since ${held.owner.startedAt} (lock ${lock}); try again once it finishes`
562
+ : `its write lock ${lock} is held; it is safe to remove once no oats process is running`, held?.owner ? { holderPid: held.owner.pid } : {});
563
+ }
564
+ waited = true;
565
+ await sleep(Math.min(250, 25 * 2 ** attempt) * (0.5 + Math.random()));
566
+ }
567
+ try { return await fn({ waited }); }
568
+ finally {
569
+ // Release only our own lock: one reclaimed from us meanwhile belongs to its new holder.
570
+ try { if (readCacheWriteLock(lock)?.owner?.token === owner.token) unlinkSync(lock); } catch { /* gone */ }
571
+ }
572
+ }
573
+
574
+ /** Whether a lock record (readCacheWriteLock) is provably abandoned: its owner's pid is gone, or it has no
575
+ * readable owner and is older than UNREADABLE_LOCK_STALE_MS. A live owner is never stale, whatever its age. */
576
+ const isStaleLock = (held) => (held.owner ? !pidAlive(held.owner.pid) : Date.now() - held.mtimeMs > UNREADABLE_LOCK_STALE_MS);
577
+ /** The same record still at `path`: the same owner token, or (no readable owner) the same file. */
578
+ const sameLock = (now, held) => (held.owner ? now?.owner?.token === held.owner.token : now && !now.owner && now.ino === held.ino && now.mtimeMs === held.mtimeMs);
579
+
580
+ /**
581
+ * Remove the write lock `held`, proven stale — serialized among reclaimers by the guard `<lock>.reclaim`
582
+ * (exclusive create, holding { pid, token }). Under the guard the lock is read again and removed only if it is
583
+ * still that stale record. Only a reclaimer ever removes another process's lock, and reclaimers hold the guard,
584
+ * so the lock cannot change between that check and the unlink: a reclaimer that paused cannot delete the lock a
585
+ * live process took meanwhile. A live guard is waited for (the caller's next pass). A guard whose holder died
586
+ * (or unreadable and old) is never removed: removing it would race exactly as removing the lock does, with no
587
+ * guard left to serialize that, so two reclaimers could each delete the other's live guard, then lock. It is
588
+ * returned instead, { guard, pid }, and the caller refuses naming it: a reclaimer dying inside its guard is a
589
+ * microseconds window, and a human removing the file once no oats process runs is the safe recovery. Any other
590
+ * failure leaves the lock to the caller's next pass and its deadline. → null, or the abandoned guard.
591
+ */
592
+ function reclaimCacheWriteLock(lock, held, me) {
593
+ const guard = `${lock}.reclaim`;
594
+ try { writeFileSync(guard, JSON.stringify({ pid: me.pid, token: me.token }) + "\n", { flag: "wx", mode: 0o600 }); }
595
+ catch (error) {
596
+ if (error?.code !== "EEXIST") return null;
597
+ try { const g = readCacheWriteLock(guard); if (g && isStaleLock(g)) return { guard, pid: g.owner?.pid ?? null }; } catch { /* next pass */ }
598
+ return null;
599
+ }
600
+ try {
601
+ const now = readCacheWriteLock(lock);
602
+ if (now && sameLock(now, held) && isStaleLock(now)) unlinkSync(lock);
603
+ } catch { /* next pass */ }
604
+ finally { try { if (readCacheWriteLock(guard)?.owner?.token === me.token) unlinkSync(guard); } catch { /* gone */ } }
605
+ return null;
606
+ }
607
+
608
+ /** Remove the git lock file `lockFile` that a cache write met, if — and only if — it provably belongs to no live
609
+ * writer: it is inside this cache repo, ends in `.lock`, is a regular file (never a symlink), and is older than
610
+ * GIT_LOCK_STALE_MS. Called only while holding the cache write lock (no oats writer of this kernel can hold it).
611
+ * → whether it was removed. */
612
+ function reclaimGitLock(repo, lockFile) {
613
+ if (typeof lockFile !== "string" || !lockFile.endsWith(".lock") || !isAbsolute(lockFile)) return false;
614
+ let root, parent;
615
+ try { root = realpathSync(repo.dir); parent = realpathSync(dirname(lockFile)); } catch { return false; }
616
+ if (parent !== root && !parent.startsWith(root + sep)) return false;
617
+ const file = join(parent, basename(lockFile));
618
+ let st;
619
+ try { st = lstatSync(file); } catch { return false; }
620
+ if (!st.isFile() || Date.now() - st.mtimeMs <= GIT_LOCK_STALE_MS) return false;
621
+ try { unlinkSync(file); return true; } catch { return false; }
622
+ }
623
+
624
+ /** One git call that writes the cache repo (config, fetch, update-ref), made while holding its write lock:
625
+ * a lost lock race (an older kernel's write) is retried briefly; a git lock that outlives that and is
626
+ * provably stale (reclaimGitLock: a git killed mid-write) is removed — said once as a session notice — and
627
+ * the call made once more. Anything else is thrown as git raised it. */
628
+ async function cacheGit(repo, args, opts = {}) {
629
+ const run = () => repo.local(args, opts);
630
+ try { return await retryLockRace(run); }
631
+ catch (error) {
632
+ const lockFile = isLockRace(error) ? lockFileOf(error, repo.dir) : null;
633
+ if (!lockFile || !reclaimGitLock(repo, lockFile)) throw error;
634
+ repo.session?.notices.push(`removed a stale git lock ${lockFile} (left by a git process that was killed mid-write)`);
635
+ return await run();
636
+ }
637
+ }
370
638
 
371
639
  const sha256 = (text) => createHash("sha256").update(text).digest("hex");
372
640
  const cacheRootOf = (options) => options.cacheDir ?? defaultCacheRoot();
@@ -385,16 +653,48 @@ function repoHandle(dir, exec, session) {
385
653
  async function cacheRepo(ref, options) {
386
654
  const exec = options.exec ?? runGit;
387
655
  const dir = cacheDirOf(cacheRootOf(options), ref);
388
- if (!existsSync(join(dir, "HEAD"))) {
389
- mkdirSync(dir, { recursive: true, mode: 0o700 });
390
- try { await exec(["init", "-q", "--bare", dir]); }
391
- catch (error) {
392
- // Another process may have won the init race; a usable repo is all we need.
393
- if (!existsSync(join(dir, "HEAD"))) throw unreadable(ref, error, { cacheDir: dir, stage: "init" });
656
+ if (!existsSync(join(dir, "HEAD"))) await initCacheRepo(ref, dir, exec);
657
+ return repoHandle(dir, exec, sessionOf(options));
658
+ }
659
+
660
+ /** How long a cache directory without HEAD is waited for (an older kernel initialising it in place) before it
661
+ * is taken for a crash's leftover and replaced. */
662
+ const HALF_INIT_WAIT_MS = 2_000;
663
+
664
+ /**
665
+ * Create the cache repo at `dir` in one atomic step: `git init --bare` into a private sibling directory, then
666
+ * rename it into place, so `dir` never exists half-initialised. Processes racing to create one cache all
667
+ * succeed: a loser's rename finds the winner's complete repo and drops its own copy. A directory at `dir`
668
+ * without HEAD (a crash's leftover, or an older kernel initialising in place) is waited for (bounded), then
669
+ * moved aside and replaced; the cache is disposable. Any other failure is E_REMOTE_UNREADABLE
670
+ * { reason: "cache", stage: "init", cacheDir }.
671
+ */
672
+ async function initCacheRepo(ref, dir, exec) {
673
+ const failInit = (error) => cacheFailure(ref, error, { cacheDir: dir, stage: "init" });
674
+ const tmp = `${dir}.init-${process.pid}-${randomBytes(4).toString("hex")}`;
675
+ try {
676
+ try { mkdirSync(tmp, { recursive: true, mode: 0o700 }); } catch (error) { throw failInit(error); }
677
+ try { await exec(["init", "-q", "--bare", tmp]); } catch (error) { throw failInit(error); }
678
+ try { writeFileSync(join(tmp, "oats-remote.json"), JSON.stringify({ key: ref.key, url: redactUrl(ref.url) }, null, 2) + "\n"); } catch {}
679
+ const deadline = Date.now() + HALF_INIT_WAIT_MS;
680
+ for (;;) {
681
+ try { renameSync(tmp, dir); return; } // replaces nothing, or an EMPTY directory
682
+ catch (error) {
683
+ if (error?.code !== "ENOTEMPTY" && error?.code !== "EEXIST") throw failInit(error);
684
+ }
685
+ if (existsSync(join(dir, "HEAD"))) return; // another process created it first
686
+ if (Date.now() < deadline) { await sleep(50); continue; }
687
+ // No HEAD after the wait: a leftover. Move it aside (atomic; a process racing to do the same loses
688
+ // with ENOENT, which is fine) and take its place.
689
+ const aside = `${dir}.stale-${process.pid}-${randomBytes(4).toString("hex")}`;
690
+ try { renameSync(dir, aside); } catch (error) { if (error?.code !== "ENOENT") throw failInit(error); }
691
+ // A repo another process renamed in between the check and the move is complete: put it back.
692
+ if (existsSync(join(aside, "HEAD"))) { try { renameSync(aside, dir); continue; } catch { /* taken again meanwhile */ } }
693
+ rmSync(aside, { recursive: true, force: true });
394
694
  }
395
- try { writeFileSync(join(dir, "oats-remote.json"), JSON.stringify({ key: ref.key, url: redactUrl(ref.url) }, null, 2) + "\n", { flag: "wx" }); } catch {}
695
+ } finally {
696
+ rmSync(tmp, { recursive: true, force: true }); // gone already after a successful rename
396
697
  }
397
- return repoHandle(dir, exec, sessionOf(options));
398
698
  }
399
699
 
400
700
  function pinRef(oid) { return `refs/oats/commits/${oid}`; }
@@ -404,7 +704,8 @@ function pinRef(oid) { return `refs/oats/commits/${oid}`; }
404
704
  // ---------------------------------------------------------------------------
405
705
 
406
706
  class ReadSession {
407
- constructor({ maxAge = 0, now = Date.now, fingerprint = null, treeIndexBudget = TREE_INDEX_BUDGET, parsedLimits = null, batchTimeoutMs = GIT_TIMEOUT_MS } = {}) {
707
+ constructor({ maxAge = 0, now = Date.now, fingerprint = null, treeIndexBudget = TREE_INDEX_BUDGET, parsedLimits = null, batchTimeoutMs = GIT_TIMEOUT_MS,
708
+ cacheWriteWaitMs = CACHE_WRITE_WAIT_MS, fetchTimeoutMs = GIT_FETCH_TIMEOUT_MS } = {}) {
408
709
  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
710
  this.maxAge = maxAge;
410
711
  this.now = now;
@@ -412,6 +713,8 @@ class ReadSession {
412
713
  this.fingerprint = fingerprint; // tests inject one; else the kernel's own (kernelFingerprint)
413
714
  this.treeIndexBudget = treeIndexBudget;
414
715
  this.batchTimeoutMs = batchTimeoutMs; // tests shorten it; a batch answer is otherwise waited for as long as any git call
716
+ this.cacheWriteWaitMs = cacheWriteWaitMs; // tests shorten it: how long a write waits for another live writer of its cache
717
+ this.fetchTimeoutMs = fetchTimeoutMs; // tests shorten it: a fetch's own timeout
415
718
  this.parsedLimits = parsedLimits; // tests inject small prune bounds
416
719
  this.observations = new Map(); // memo key → Promise<head observation>
417
720
  this.used = new Map(); // memo key → { observedAt, reused }: the heads this command used
@@ -426,6 +729,7 @@ class ReadSession {
426
729
  this.aborter = new AbortController(); // close()/closeNow() kill every git child still running for this session
427
730
  this.signal = this.aborter.signal;
428
731
  setMaxListeners(0, this.signal); // every running git child listens (up to 16 at once): no leak warning
732
+ this.notices = []; // what the command should tell the operator once it ends (the CLI prints them)
429
733
  this.pruned = false;
430
734
  this.closed = false;
431
735
  }
@@ -487,13 +791,14 @@ class ReadSession {
487
791
  for (const b of [...this.batches.values(), ...this.retiring]) b.kill();
488
792
  this.batches.clear();
489
793
  this.retiring.clear();
794
+ reapOnExit(); // no timer runs on the exit path: SIGTERM, a bounded synchronous grace, then SIGKILL
490
795
  }
491
796
  /** A session rides remoteOptions, which callers may serialise (memo keys): never its innards. */
492
797
  toJSON() { return "[oats read session]"; }
493
798
  }
494
799
 
495
800
  /** One command's read session (see the module header). `maxAge` seconds (0 = observe live). Test seams:
496
- * `now`, `fingerprint`, `treeIndexBudget`, `parsedLimits`, `batchTimeoutMs`. */
801
+ * `now`, `fingerprint`, `treeIndexBudget`, `parsedLimits`, `batchTimeoutMs`, `cacheWriteWaitMs`, `fetchTimeoutMs`. */
497
802
  export function createReadSession(options = {}) { return new ReadSession(options); }
498
803
  /** An observation the command no longer wants (its session closed, or its prefetch abandoned): never adopted
499
804
  * by a caller that is still reading, so its shape only has to be a typed remote failure. */
@@ -562,7 +867,7 @@ function resolvedVersion(name) {
562
867
  } catch { return "unresolved"; }
563
868
  }
564
869
 
565
- const TRANSIENT_REASONS = new Set(["timeout", "network", "auth", "unknown"]);
870
+ const TRANSIENT_REASONS = new Set(["timeout", "network", "auth", "unknown", "cache"]);
566
871
  /** A value the parsed cache may keep: JSON-safe (its round trip is deepStrictEqual — plain or
567
872
  * null-prototype objects, dense arrays, finite numbers other than -0, strings, booleans, null; no
568
873
  * undefined, no cycle) and free of transient failures (any E_REMOTE_UNREADABLE, or a remote
@@ -782,11 +1087,11 @@ async function objectType(repo, oid) {
782
1087
  }
783
1088
 
784
1089
  /**
785
- * Ensure <oid> (a full OID of a COMMIT, or of an annotated TAG that peels to one) and all
786
- * its trees/blobs are present in the cache. → { repo, commit } where `commit` is the PEELED
787
- * commit: a tag OID given as `at` is accepted, but the commit recorded everywhere is the
788
- * commit it points to (fix M4). The pin is on the peeled commit; a tag object gets its own
789
- * `refs/oats/tags/<oid>` pin so gc cannot break the chain either.
1090
+ * Ensure <oid> (a full OID of a COMMIT, or of an annotated TAG that peels to one), its trees and its
1091
+ * small blobs are present in the cache (larger blobs: ensureBlobs, when a read needs them). → { repo,
1092
+ * commit } where `commit` is the PEELED commit: a tag OID given as `at` is accepted, but the commit
1093
+ * recorded everywhere is the commit it points to (fix M4). The pin is on the peeled commit; a tag
1094
+ * object gets its own `refs/oats/tags/<oid>` pin so gc cannot break the chain either.
790
1095
  */
791
1096
  async function ensureCommit(ref, oid, options) {
792
1097
  const dir = cacheDirOf(cacheRootOf(options), ref);
@@ -797,30 +1102,191 @@ async function ensureCommit(ref, oid, options) {
797
1102
  if (known) return { repo: repoHandle(dir, options.exec ?? runGit, session), commit: known };
798
1103
  const remember = (commit) => { if (session) { session.peels.set(`${dir}\0${oid}`, commit); session.peels.set(`${dir}\0${commit}`, commit); } return commit; };
799
1104
  return withCacheLock(dir, async () => {
800
- const repo = await cacheRepo(ref, options);
801
- const cached = await peelCommit(repo, oid);
1105
+ // Already present (the common case): a read, so no write lock.
1106
+ let peeled = false; // the object store was asked already, and said no
1107
+ const usable = async (repo) => {
1108
+ if (!existsSync(join(dir, "HEAD"))) return null;
1109
+ if (!keepsPartialCache(await gitVersion(repo.exec)) && await fetchMode(repo) === "partial") return null;
1110
+ peeled = true;
1111
+ return peelCommit(repo, oid);
1112
+ };
1113
+ const present = await usable(repoHandle(dir, options.exec ?? runGit, session));
1114
+ if (present) return { repo: repoHandle(dir, options.exec ?? runGit, session), commit: remember(present) };
1115
+ // Every write below (init, config, fetch, pin) holds the cache's cross-process write lock; a process that
1116
+ // waited for it finds what the holder fetched.
1117
+ return withCacheWriteLock(ref, dir, "fetch", session, async ({ waited }) => {
1118
+ let repo = await cacheRepo(ref, options);
1119
+ const version = await gitVersion(repo.exec);
1120
+ // A partial cache is only safe where git cannot fetch a missing blob on its own: an older git rebuilds it whole.
1121
+ if (!keepsPartialCache(version) && await fetchMode(repo) === "partial") repo = await rebuildWhole(repo, ref, options, version);
1122
+ const cached = peeled && !waited ? null : await peelCommit(repo, oid);
802
1123
  if (cached) return { repo, commit: remember(cached) }; // pinned AND present AND (peels to) a commit
803
- let lastError;
804
- for (let attempt = 0; attempt < 3; attempt++) {
805
- try {
806
- await repo.local(["fetch", "-q", "--depth", "1", "--no-tags", "--no-recurse-submodules", ref.url, oid]);
807
- lastError = null;
808
- break;
809
- } catch (error) {
810
- lastError = error;
811
- if (!isLockRace(error) || error.timedOut) break;
812
- await sleep(100 * (attempt + 1) ** 2);
813
- }
814
- }
815
- if (lastError) throw unreadable(ref, lastError, { commit: oid });
1124
+ const mode = await fetchMode(repo)
1125
+ ?? (keepsPartialCache(version) ? await startPartialFetches(repo, ref) : await recordFullFetches(repo, ref, olderGitNotice(version, ref)));
1126
+ const filter = mode === "partial" ? [`--filter=${PARTIAL_FILTER}`] : ["--no-filter"];
1127
+ const { stderr } = await fetchInto(repo, ref, ["fetch", "-q", "--depth", "1", "--no-tags", "--no-recurse-submodules", ...filter, "origin", oid], { what: oid, commit: oid });
1128
+ // A server without filters says so and sends the whole tree: nothing is lost, the cache remembers.
1129
+ if (mode === "partial" && /filtering not recognized by server/i.test(stderr.toString("utf8"))) await recordFullFetches(repo, ref);
816
1130
  const commit = await peelCommit(repo, oid);
817
1131
  if (!commit) {
818
1132
  const type = await objectType(repo, oid);
819
1133
  throw fail("E_REMOTE_UNREADABLE", `${oid} in ${ref.url} is ${type ? `a ${type}` : "missing"}, not a commit`, { url: ref.url, key: ref.key, reason: "not-found", commit: oid, type });
820
1134
  }
821
- await repo.local(["update-ref", pinRef(commit), commit]);
822
- if (commit !== oid) await repo.local(["update-ref", `refs/oats/tags/${oid}`, oid]);
1135
+ await writePin(repo, ref, pinRef(commit), commit, commit);
1136
+ if (commit !== oid) await writePin(repo, ref, `refs/oats/tags/${oid}`, oid, commit);
823
1137
  return { repo, commit: remember(commit) };
1138
+ });
1139
+ });
1140
+ }
1141
+
1142
+ /** Pin `name` to `oid` in the cache repo. Two processes pinning one commit write the same value: a lost lock
1143
+ * race is retried, and a ref that already holds `oid` (another process's write) counts as written. */
1144
+ async function writePin(repo, ref, name, oid, commit) {
1145
+ try { await cacheGit(repo, ["update-ref", name, oid]); }
1146
+ catch (error) {
1147
+ let current = null;
1148
+ try { current = (await repo.local(["rev-parse", "--verify", "-q", name])).stdout.toString("utf8").trim(); } catch {}
1149
+ if (current === oid) return;
1150
+ throw cacheFailure(ref, error, { cacheDir: repo.dir, stage: "pin", commit });
1151
+ }
1152
+ }
1153
+
1154
+ /** The filter a partial fetch asks for: every blob up to SMALL_BLOB_LIMIT comes with the commit. */
1155
+ const PARTIAL_FILTER = `blob:limit=${SMALL_BLOB_LIMIT / 1024}k`;
1156
+
1157
+ /** How this cache fetches (its own `oats.fetch` config): "partial", "full" (its server cannot serve
1158
+ * partial fetches), or null before its first fetch. */
1159
+ async function fetchMode(repo) {
1160
+ try { return (await repo.local(["config", "--get", "oats.fetch"])).stdout.toString("utf8").trim() || null; } catch { return null; }
1161
+ }
1162
+
1163
+ /** Make the cache a partial clone of the remote "origin". Its url is never written (it may carry
1164
+ * credentials): every fetch passes it with `-c remote.origin.url=` (fetchInto). */
1165
+ async function startPartialFetches(repo, ref) {
1166
+ for (const [key, value] of [["core.repositoryformatversion", "1"], ["extensions.partialclone", "origin"], ["remote.origin.promisor", "true"],
1167
+ ["remote.origin.partialclonefilter", PARTIAL_FILTER], ["oats.fetch", "partial"]]) await writeConfig(repo, ref, key, value);
1168
+ return "partial";
1169
+ }
1170
+
1171
+ /** One `git config <key> <value>` in the cache repo. Two processes starting one cache write the same values,
1172
+ * so a lost lock race is simply retried. */
1173
+ async function writeConfig(repo, ref, key, value) {
1174
+ try { await cacheGit(repo, ["config", key, value]); }
1175
+ catch (error) { throw unreadable(ref, error, { cacheDir: repo.dir, stage: "config" }); }
1176
+ }
1177
+
1178
+ /** This cache fetches whole trees from now on (its server cannot serve partial fetches, or this git cannot keep
1179
+ * a partial cache): recorded, and said once (the session's notices; the CLI prints them when the command ends). */
1180
+ async function recordFullFetches(repo, ref, notice = `${redactUrl(ref.url)} does not serve partial fetches; OATS fetches whole trees from it`) {
1181
+ await writeConfig(repo, ref, "oats.fetch", "full");
1182
+ repo.session?.notices.push(notice);
1183
+ return "full";
1184
+ }
1185
+
1186
+ const olderGitNotice = (version, ref) =>
1187
+ `git ${version?.text ?? "(unknown version)"} cannot keep a partial cache (it needs ${PARTIAL_FETCH_GIT.join(".")}); OATS fetches whole trees from ${redactUrl(ref.url)}`;
1188
+
1189
+ /** `git --version` of the git `exec` runs, asked once per exec: → { text: "2.54.0", major, minor } | null. */
1190
+ const gitVersions = new WeakMap();
1191
+ export function gitVersion(exec = runGit) {
1192
+ let version = gitVersions.get(exec);
1193
+ if (!version) {
1194
+ version = Promise.resolve().then(() => exec(["--version"])).then((out) => {
1195
+ const m = /git version ((\d+)\.(\d+)[^\s]*)/.exec(out.stdout.toString("utf8"));
1196
+ return m ? { text: m[1], major: Number(m[2]), minor: Number(m[3]) } : null;
1197
+ }, () => null);
1198
+ gitVersions.set(exec, version);
1199
+ }
1200
+ return version;
1201
+ }
1202
+ /** Whether this git keeps a partial cache honest (GIT_NO_LAZY_FETCH): an unknown version does not. */
1203
+ export function keepsPartialCache(version) {
1204
+ const [major, minor] = PARTIAL_FETCH_GIT;
1205
+ return version != null && (version.major > major || (version.major === major && version.minor >= minor));
1206
+ }
1207
+
1208
+ /** Delete a partial cache an older git cannot read (it would fetch a missing blob on its own, or die), and start
1209
+ * it again recording whole-tree fetches. The cache is disposable: everything in it is fetched again. */
1210
+ async function rebuildWhole(repo, ref, options, version) {
1211
+ const session = repo.session;
1212
+ if (session) {
1213
+ session.batches.get(repo.dir)?.kill();
1214
+ session.batches.delete(repo.dir);
1215
+ for (const map of [session.peels, session.trees]) for (const k of [...map.keys()]) if (k.startsWith(`${repo.dir}\0`)) map.delete(k);
1216
+ }
1217
+ rmSync(repo.dir, { recursive: true, force: true });
1218
+ const fresh = await cacheRepo(ref, options);
1219
+ await recordFullFetches(fresh, ref, olderGitNotice(version, ref));
1220
+ return fresh;
1221
+ }
1222
+
1223
+ /** `git fetch` from the remote into the cache (args start at the subcommand and name the remote "origin"):
1224
+ * a lost on-disk `.lock` race is retried, a timeout names the fetch (`what`) and how long it ran, any other
1225
+ * failure is E_REMOTE_UNREADABLE (`raw`: the git error itself, for a caller that reads its stderr). → { stdout, stderr } */
1226
+ async function fetchInto(repo, ref, args, { what, commit, input, raw = false } = {}) {
1227
+ const started = Date.now();
1228
+ let lastError;
1229
+ try { return await cacheGit(repo, ["-c", `remote.origin.url=${ref.url}`, ...args], { timeout: repo.session?.fetchTimeoutMs ?? GIT_FETCH_TIMEOUT_MS, ...(input !== undefined ? { input } : {}) }); }
1230
+ catch (error) { lastError = error; }
1231
+ if (lastError?.timedOut) {
1232
+ const elapsedMs = Date.now() - started;
1233
+ throw fail("E_REMOTE_UNREADABLE", `cannot read remote ${ref.url} (timeout): git fetch of ${what} timed out after ${Math.round(elapsedMs / 1000)} s`,
1234
+ { url: ref.url, key: ref.key, reason: "timeout", commit, operation: "fetch", elapsedMs });
1235
+ }
1236
+ throw raw ? lastError : unreadable(ref, lastError, { commit, ...(classifyRemoteFailure(lastError) === "cache" ? { cacheDir: repo.dir, stage: "fetch" } : {}) });
1237
+ }
1238
+
1239
+ /** The sizes of `oids` in the cache, one `cat-file --batch-check`: → Map oid → size, null when the cache lacks it. */
1240
+ async function blobSizes(repo, oids) {
1241
+ const out = (await repo.local(["cat-file", "--batch-check"], { input: oids.map((o) => `${o}\n`).join("") })).stdout.toString("utf8");
1242
+ const sizes = new Map(oids.map((o) => [o, null]));
1243
+ for (const line of out.split("\n")) {
1244
+ const [oid, type, size] = line.split(" ");
1245
+ if (sizes.has(oid) && type !== "missing" && /^[0-9]+$/.test(size ?? "")) sizes.set(oid, Number(size));
1246
+ }
1247
+ return sizes;
1248
+ }
1249
+
1250
+ const isRefusedWant = (error) => /unadvertised object|not our ref|does not allow request|allow-(tip|reachable|any)-sha1-in-want/i.test(stderrText(error));
1251
+
1252
+ /**
1253
+ * Make the blobs of `entries` (ls-tree entries of `commit`) present before anything reads them: those
1254
+ * whose size is unknown (null: the cache lacks them) are fetched in ONE fetch, by id, and every entry's
1255
+ * `size` is then the blob's real size. A server that refuses wants of blobs by id gets the commit fetched
1256
+ * again whole (`--refetch`), and the cache records it (recordFullFetches).
1257
+ */
1258
+ async function ensureBlobs(repo, ref, commit, entries) {
1259
+ const unknown = entries.filter((e) => e.size === null);
1260
+ if (!unknown.length) return;
1261
+ const oids = [...new Set(unknown.map((e) => e.oid))];
1262
+ await withCacheLock(repo.dir, async () => {
1263
+ let sizes = await blobSizes(repo, oids); // another read may have fetched them meanwhile
1264
+ let wanted = oids.filter((o) => sizes.get(o) === null);
1265
+ if (wanted.length) await withCacheWriteLock(ref, repo.dir, "fetch", repo.session, async ({ waited }) => {
1266
+ if (waited) { // another process may have fetched them while this one waited
1267
+ sizes = await blobSizes(repo, oids);
1268
+ wanted = oids.filter((o) => sizes.get(o) === null);
1269
+ if (!wanted.length) return;
1270
+ }
1271
+ const what = `${wanted.length} blob${wanted.length === 1 ? "" : "s"} at ${commit}`;
1272
+ const refetch = () => fetchInto(repo, ref, ["fetch", "-q", "--refetch", "--no-filter", "--depth", "1", "--no-tags", "--no-recurse-submodules", "origin", commit], { what: commit, commit });
1273
+ if (await fetchMode(repo) === "full") await refetch(); // a commit fetched partially before its server was found out
1274
+ else {
1275
+ try {
1276
+ await fetchInto(repo, ref, ["-c", "fetch.negotiationAlgorithm=noop", "fetch", "-q", "--no-tags", "--no-write-fetch-head", "--stdin", "origin"],
1277
+ { what, commit, input: wanted.map((o) => `${o}\n`).join(""), raw: true });
1278
+ } catch (error) {
1279
+ if (typeof error?.code === "string" && error.code.startsWith("E_")) throw error;
1280
+ if (!isRefusedWant(error)) throw unreadable(ref, error, { commit, ...(classifyRemoteFailure(error) === "cache" ? { cacheDir: repo.dir, stage: "fetch" } : {}) });
1281
+ await refetch();
1282
+ await recordFullFetches(repo, ref);
1283
+ }
1284
+ }
1285
+ sizes = await blobSizes(repo, oids);
1286
+ const still = oids.find((o) => sizes.get(o) === null);
1287
+ if (still) throw fail("E_REMOTE_UNREADABLE", `cannot read remote ${ref.url} (not-found): blob ${still} at ${commit} was not fetched`, { url: ref.url, key: ref.key, reason: "not-found", commit, oid: still });
1288
+ });
1289
+ for (const e of unknown) e.size = sizes.get(e.oid);
824
1290
  });
825
1291
  }
826
1292
 
@@ -1003,7 +1469,8 @@ function normalizeTreePath(path, { allowRoot }) {
1003
1469
  return parts.join("/");
1004
1470
  }
1005
1471
 
1006
- /** Parse `git ls-tree -l -z` output → [{ mode, type, oid, size, path }]. */
1472
+ /** Parse `git ls-tree -l -z` output → [{ mode, type, oid, size, path }]. `size` is null for a tree, and for a
1473
+ * blob the cache does not hold (git prints "BAD" for it and still succeeds): ensureBlobs learns it. */
1007
1474
  function parseLsTree(stdout) {
1008
1475
  const entries = [];
1009
1476
  for (const record of stdout.toString("utf8").split("\0")) {
@@ -1011,7 +1478,7 @@ function parseLsTree(stdout) {
1011
1478
  const tab = record.indexOf("\t");
1012
1479
  const meta = record.slice(0, tab).trim().split(/\s+/), path = record.slice(tab + 1);
1013
1480
  const [mode, type, oid, size] = meta;
1014
- entries.push({ mode, type, oid, size: size === "-" ? null : Number(size), path });
1481
+ entries.push({ mode, type, oid, size: /^[0-9]+$/.test(size) ? Number(size) : null, path });
1015
1482
  }
1016
1483
  return entries;
1017
1484
  }
@@ -1039,8 +1506,8 @@ function assertNoCollisions(entries, ref, commit, prefix) {
1039
1506
  }
1040
1507
 
1041
1508
  /** `git ls-tree -l -z [flags] <spec> [-- <path>]`; null when <spec> names no tree.
1042
- * Any other failure is E_REMOTE_UNREADABLE (reason "timeout" for the timeout kill, else
1043
- * "unknown") — never a raw Node/git error: enumerateRepo turns E_REMOTE_* into a problem
1509
+ * Any other failure is E_REMOTE_UNREADABLE (reason "timeout" for the timeout kill, "killed"
1510
+ * for any other signal exit, else "unknown") — never a raw Node/git error: enumerateRepo turns E_REMOTE_* into a problem
1044
1511
  * row and would otherwise abort the whole discovery on one unexplained listing (L4). */
1045
1512
  async function lsTree(repo, spec, { flags = [], path, ref, commit, maxBuffer = 64 * 1024 * 1024 } = {}) {
1046
1513
  try {
@@ -1052,9 +1519,10 @@ async function lsTree(repo, spec, { flags = [], path, ref, commit, maxBuffer = 6
1052
1519
  if (typeof error?.code === "string" && error.code.startsWith("E_")) throw error; // already an oats error
1053
1520
  const text = stderrText(error).toLowerCase();
1054
1521
  if (/not a tree object|not a valid object name|does not exist|bad object|fatal: not a tree|path .* does not exist|exists on disk, but not in/.test(text)) return null;
1055
- const reason = error?.timedOut ? "timeout" : "unknown";
1056
- const why = error?.overflowed ? "listing exceeded the output budget" : (text.trim().split("\n")[0] || error?.code || error?.message || "git ls-tree failed");
1057
- throw fail("E_REMOTE_UNREADABLE", `cannot list ${spec}${path !== undefined ? ` -- ${path}` : ""} in ${ref?.key ?? repo.dir} (${reason}: ${why})`, { url: ref?.url ?? null, key: ref?.key ?? null, reason, commit: commit ?? null, spec, path: path ?? null, cause: error?.code ?? null, overflowed: error?.overflowed === true });
1522
+ const killed = !error?.timedOut && !error?.overflowed && typeof error?.signal === "string" && error.signal !== "";
1523
+ const reason = error?.timedOut ? "timeout" : killed ? "killed" : "unknown";
1524
+ const why = error?.overflowed ? "listing exceeded the output budget" : killed ? `git was killed (signal ${error.signal})` : (text.trim().split("\n")[0] || error?.code || error?.message || "git ls-tree failed");
1525
+ throw fail("E_REMOTE_UNREADABLE", `cannot list ${spec}${path !== undefined ? ` -- ${path}` : ""} in ${ref?.key ?? repo.dir} (${reason}: ${why})`, { url: ref?.url ?? null, key: ref?.key ?? null, reason, commit: commit ?? null, spec, path: path ?? null, cause: error?.code ?? null, overflowed: error?.overflowed === true, ...(killed ? { signal: error.signal } : {}) });
1058
1526
  }
1059
1527
  }
1060
1528
 
@@ -1099,9 +1567,10 @@ async function entryAt(repo, commit, path, ref) {
1099
1567
  return entries.find((e) => e.path === path) ?? null;
1100
1568
  }
1101
1569
 
1102
- /** Kill our own, still-running child (never a pid a failed spawn left at 0: lib/process-group.mjs). */
1570
+ /** End our own, still-running child gracefully (SIGTERM, then SIGKILL after the grace; never a pid a failed
1571
+ * spawn left at 0: lib/process-group.mjs). */
1103
1572
  function killChild(child) {
1104
- if (child.exitCode === null && child.signalCode === null) killGroup(child, "SIGKILL");
1573
+ if (child.exitCode === null && child.signalCode === null) terminateGroup(child);
1105
1574
  }
1106
1575
 
1107
1576
  /**
@@ -1114,8 +1583,10 @@ function killChild(child) {
1114
1583
  * ends the child; the next read starts a new one.
1115
1584
  */
1116
1585
  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"] });
1586
+ // Detached, as its own process group: killChild's group kill also ends anything git started.
1587
+ const child = watchGroup(spawn("git", ["-C", dir, "-c", "gc.auto=0", "cat-file", "--batch"], { cwd: dir, env: gitEnv(), detached: true, stdio: ["pipe", "pipe", "pipe"] }));
1588
+ liveChildren.add(child);
1589
+ child.once("close", () => liveChildren.delete(child));
1119
1590
  const queue = []; // { resolve, reject, budget, size? }
1120
1591
  let chunks = [], length = 0, skip = 0, stderr = "", timer = null, dead = null, exited = null;
1121
1592
  const exitedPromise = new Promise((r) => { exited = r; });
@@ -1162,7 +1633,7 @@ function openBatch(dir, timeoutMs = GIT_TIMEOUT_MS) {
1162
1633
  if (nl < 0) { if (length > 4096) die(Object.assign(new Error("git cat-file --batch: malformed header"), { stderr: Buffer.from(stderr) })); return; }
1163
1634
  const line = take(nl + 1).toString("utf8").slice(0, -1);
1164
1635
  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; }
1636
+ if (type === "missing" || type === undefined) { die(Object.assign(new Error(line), { code: 128, stderr: Buffer.from(`fatal: Not a valid object name ${name}\n`), missing: type === "missing" })); return; }
1166
1637
  const size = Number(sizeText);
1167
1638
  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
1639
  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; }
@@ -1242,6 +1713,7 @@ export async function readRemoteFile(refText, commitArg, path, options = {}) {
1242
1713
  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 });
1243
1714
  if (entry.mode === "120000") throw fail("E_REMOTE_TREE_UNSAFE", `${rel} is a symlink`, { path: rel, why: "symlink", key: ref.key, commit });
1244
1715
  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 });
1716
+ await ensureBlobs(repo, ref, commit, [entry]);
1245
1717
  if (entry.size > FILE_BUDGET) throw oversize(entry.size);
1246
1718
  const batch = catFileBatch(repo);
1247
1719
  if (batch) {
@@ -1290,7 +1762,8 @@ export function browseUrl(key, commit, path = null) {
1290
1762
  }
1291
1763
 
1292
1764
  /**
1293
- * → [{ path, type: "blob"|"tree", size? }] relative to <dir>, depth-bounded (depth 1 = direct children).
1765
+ * → [{ path, type: "blob"|"tree"|"symlink" }] relative to <dir>, depth-bounded (depth 1 = direct children).
1766
+ * A listing never needs a blob, so it never fetches one (and so carries no sizes).
1294
1767
  * Missing dir → []. Symlinks are reported as type "symlink" so callers can skip them.
1295
1768
  */
1296
1769
  export async function listRemoteTree(refText, commitArg, dir, { depth = 2, ...options } = {}) {
@@ -1316,9 +1789,7 @@ export async function listRemoteTree(refText, commitArg, dir, { depth = 2, ...op
1316
1789
  assertSafeEntryPath(e.path, ref, commit, rel ? `${rel}/${e.path}` : e.path);
1317
1790
  if (e.type === "commit") continue; // submodule gitlinks are not part of the observable tree
1318
1791
  const type = e.mode === "120000" ? "symlink" : e.type;
1319
- const row = { path: e.path, type };
1320
- if (e.type === "blob") row.size = e.size;
1321
- result.push(row);
1792
+ result.push({ path: e.path, type });
1322
1793
  }
1323
1794
  result.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
1324
1795
  return result;
@@ -1421,8 +1892,8 @@ export async function fetchRemoteTree(refText, commitArg, dir, destDir, options
1421
1892
  }
1422
1893
  const entries = await lsTree(repo, spec, { flags: ["-r", "-t"], ref, commit });
1423
1894
  if (!entries) throw fail("E_REMOTE_PATH_MISSING", `${rel || "."} is missing in ${ref.key}@${commit.slice(0, 12)}`, { path: rel, key: ref.key, commit });
1424
- // Inspect everything BEFORE writing anything: names, modes, types, sizes, collisions.
1425
- let total = 0;
1895
+ // Inspect everything BEFORE writing anything: names, modes, types, collisions, then (once the blobs
1896
+ // are present) sizes.
1426
1897
  const blobs = [], trees = [], links = [];
1427
1898
  // `allowSymlinks(relPath)` (opt-in, narrow): a symlink whose TARGET is relative
1428
1899
  // and stays inside the fetched subtree may be materialized as a symlink — the
@@ -1439,12 +1910,17 @@ export async function fetchRemoteTree(refText, commitArg, dir, destDir, options
1439
1910
  if (e.type === "commit") throw fail("E_REMOTE_TREE_UNSAFE", `${shown} is a submodule`, { path: shown, why: "device", key: ref.key, commit });
1440
1911
  if (e.type === "tree") { trees.push(e); continue; }
1441
1912
  if (e.type !== "blob" || !/^100(644|755)$/.test(e.mode)) throw fail("E_REMOTE_TREE_UNSAFE", `${shown} has unsupported mode ${e.mode}`, { path: shown, why: "device", key: ref.key, commit });
1442
- total += e.size;
1443
- if (total > TREE_BUDGET) throw fail("E_REMOTE_TREE_UNSAFE", `${rel || "."} exceeds ${TREE_BUDGET} bytes`, { path: rel || ".", why: "oversize", size: total, budget: TREE_BUDGET, key: ref.key, commit });
1444
1913
  blobs.push(e);
1445
1914
  }
1446
- blobs.sort(byPath);
1447
1915
  assertNoCollisions(entries, ref, commit, rel);
1916
+ // Every blob of the subtree (files and link targets) in ONE fetch, then the budget on their real sizes.
1917
+ await ensureBlobs(repo, ref, commit, [...blobs, ...links]);
1918
+ let total = 0;
1919
+ for (const b of blobs) {
1920
+ total += b.size;
1921
+ if (total > TREE_BUDGET) throw fail("E_REMOTE_TREE_UNSAFE", `${rel || "."} exceeds ${TREE_BUDGET} bytes`, { path: rel || ".", why: "oversize", size: total, budget: TREE_BUDGET, key: ref.key, commit });
1922
+ }
1923
+ blobs.sort(byPath);
1448
1924
  mkdirSync(dirname(dest), { recursive: true });
1449
1925
  const staging = join(dirname(dest), `.${dest.split(sep).pop()}.oats-staging-${process.pid}-${Date.now().toString(36)}`);
1450
1926
  try {
@@ -1455,25 +1931,41 @@ export async function fetchRemoteTree(refText, commitArg, dir, destDir, options
1455
1931
  // the git tree listed first.
1456
1932
  const items = [...blobs.map((b) => ({ kind: "blob", e: b })), ...links.map((l) => ({ kind: "link", e: l }))].sort((a, b) => byPath(a.e, b.e));
1457
1933
  const d = createDigest();
1934
+ // Blob bytes (all present now: ensureBlobs above): through the session's `cat-file --batch` reader when
1935
+ // there is one (no process per blob), else one `cat-file blob` each. A reader opened before ensureBlobs
1936
+ // fetched a blob still finds it (git re-reads its packs on a miss); one still missing answers `missing`
1937
+ // (GIT_NO_LAZY_FETCH), never a fetch. A batch read may take what is left of TREE_BUDGET, never
1938
+ // FILE_BUDGET (a single blob over 4 MiB still copies); a symlink target keeps its 64 KiB cap. A blob the
1939
+ // reader answers `missing`, or over its budget, is read once more alone: that read's error is this path's
1940
+ // error without a session, classified as it always was (the reader's own words are not git's). Any other
1941
+ // failure, the reader dying or its session closing included, is E_REMOTE_UNREADABLE, as a per-blob read's is.
1942
+ const LINK_BUDGET = 64 * 1024;
1943
+ let read = 0;
1944
+ const readBlob = async (oid, path, budget, maxBuffer) => {
1945
+ const batch = catFileBatch(repo);
1946
+ try {
1947
+ if (batch) {
1948
+ try { return await batch.read(oid, budget); }
1949
+ catch (error) { if (error?.oversize === undefined && error?.missing !== true) throw error; }
1950
+ }
1951
+ return (await repo.local(["cat-file", "blob", oid], { maxBuffer })).stdout;
1952
+ } catch (error) { throw unreadable(ref, error, { commit, path }); }
1953
+ };
1458
1954
  for (const { kind, e } of items) {
1459
1955
  if (kind === "blob") {
1460
1956
  const b = e;
1461
- let out;
1462
- try { out = await repo.local(["cat-file", "blob", b.oid], { maxBuffer: TREE_BUDGET + 1024 }); }
1463
- catch (error) { throw unreadable(ref, error, { commit, path: b.path }); }
1957
+ const bytes = await readBlob(b.oid, b.path, TREE_BUDGET - read, TREE_BUDGET + 1024);
1958
+ read += bytes.length;
1464
1959
  const mode = b.mode === "100755" ? 0o755 : 0o644;
1465
1960
  const target = join(staging, ...b.path.split("/"));
1466
1961
  mkdirSync(dirname(target), { recursive: true, mode: 0o755 });
1467
1962
  const fd = openSync(target, "wx", mode);
1468
- try { writeSync(fd, out.stdout); } finally { closeSync(fd); }
1469
- d.add(b.path, mode, out.stdout);
1963
+ try { writeSync(fd, bytes); } finally { closeSync(fd); }
1964
+ d.add(b.path, mode, bytes);
1470
1965
  continue;
1471
1966
  }
1472
1967
  const l = e;
1473
- let out;
1474
- try { out = await repo.local(["cat-file", "blob", l.oid], { maxBuffer: 64 * 1024 }); }
1475
- catch (error) { throw unreadable(ref, error, { commit, path: l.path }); }
1476
- const linkTarget = out.stdout.toString("utf8").trim();
1968
+ const linkTarget = (await readBlob(l.oid, l.path, LINK_BUDGET, LINK_BUDGET)).toString("utf8").trim();
1477
1969
  const from = dirname(l.path === "" ? "x" : l.path);
1478
1970
  const resolvedRel = posixNormalize(from === "." ? linkTarget : `${from}/${linkTarget}`);
1479
1971
  if (isAbsolute(linkTarget) || linkTarget.includes("\0") || resolvedRel.startsWith("../") || resolvedRel === "..") {