@awebai/oats 0.24.12 → 0.25.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 +936 -2822
- package/docs/capabilities.md +136 -323
- package/docs/configuration.md +68 -533
- package/docs/design/2026-09-16-fresh-operator-walkthrough.md +19 -14
- package/docs/design/2026-09-16-portable-migration-evidence.md +2 -0
- package/docs/design/2026-09-16-portable-onboarding.md +4 -2
- package/docs/design/2026-09-20-redesign-program-board.md +1 -1
- package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +2 -0
- package/docs/design/2026-09-23-simplified-workspace-model.md +711 -0
- package/docs/design/2026-09-23-workspace-module-contracts.md +309 -0
- package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +65 -0
- package/docs/design/README.md +20 -8
- package/docs/design/operations-contract.md +1 -0
- package/docs/design/package-engine-contract.md +1 -1
- package/docs/design/package-runtime-api.md +1 -1
- package/docs/desktop-cli-api.md +386 -6
- package/docs/desktop-succession.md +3 -4
- package/docs/first-team.md +107 -224
- package/docs/implementation.md +6 -4
- package/docs/integrations.md +45 -44
- package/docs/knowledge-capability-authoring.md +10 -4
- package/docs/knowledge-migration.md +5 -4
- package/docs/knowledge-reference/package-craft.md +11 -3
- package/docs/knowledge.md +24 -8
- package/docs/layers.md +3 -3
- package/docs/oats-local.schema.json +50 -0
- package/docs/oats-membership.schema.json +23 -0
- package/docs/oats-workspace.schema.json +133 -48
- package/docs/official-marketplace.md +9 -6
- package/docs/packages.md +229 -440
- package/docs/rebuild-to-v2.md +233 -0
- package/docs/release-notes/v0.24.13.md +51 -0
- package/docs/release-notes/v0.25.0.md +99 -0
- package/docs/soul.schema.json +41 -68
- package/docs/souls-and-instances.md +175 -108
- package/docs/workspace-adoption.md +70 -345
- package/docs/workspaces.md +429 -119
- package/lib/core.mjs +419 -55
- package/lib/instance-resolution.mjs +312 -0
- package/lib/materialize.mjs +580 -0
- package/lib/packages.mjs +501 -1273
- package/lib/remote.mjs +639 -0
- package/lib/resolve.mjs +576 -0
- package/lib/schedule.mjs +194 -34
- package/lib/workspace.mjs +635 -0
- package/package.json +1 -1
- package/lib/portable-migration-artifacts.mjs +0 -135
- package/lib/portable-migration-evidence.mjs +0 -305
- package/lib/portable-migration-store.mjs +0 -199
- package/lib/portable-migration.mjs +0 -104
- package/lib/portable-onboarding-acceptance.mjs +0 -66
- package/lib/setup-expert-source.mjs +0 -100
package/lib/remote.mjs
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
/** lib/remote.mjs — observe Git remotes in the operator's own access context
|
|
2
|
+
* (workspace model v2, module contract §1).
|
|
3
|
+
*
|
|
4
|
+
* APPROACH (one approach, used for every remote kind — local bare repos and
|
|
5
|
+
* https/ssh remotes alike):
|
|
6
|
+
* 1. `observeRemote` resolves `at` with `git ls-remote --symref <url> …` —
|
|
7
|
+
* never a fetch when the caller already gave a full OID.
|
|
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.
|
|
14
|
+
* 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.
|
|
18
|
+
*
|
|
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.
|
|
26
|
+
*
|
|
27
|
+
* The cache is invisible plumbing: it may be wiped at any time (a wiped cache
|
|
28
|
+
* simply re-fetches), and nothing outside this module references it.
|
|
29
|
+
*
|
|
30
|
+
* Nothing here ever prompts: GIT_TERMINAL_PROMPT=0, GIT_ASKPASS=/usr/bin/false,
|
|
31
|
+
* 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.
|
|
33
|
+
*
|
|
34
|
+
* TREE SAFETY: every entry name git reports is validated BEFORE anything touches
|
|
35
|
+
* the disk. A component that is empty, `.`, `..`, `.git` (any case) or contains
|
|
36
|
+
* `\` / NUL is E_REMOTE_TREE_UNSAFE { why: "path" } — a crafted tree object can
|
|
37
|
+
* carry such names (the transport does not fsck them), and `join()` would
|
|
38
|
+
* happily normalize `../../x` out of the staging directory. Entries that would
|
|
39
|
+
* collide on a case-/normalization-insensitive filesystem (README.md vs
|
|
40
|
+
* readme.md, NFC vs NFD) are E_REMOTE_TREE_UNSAFE { why: "collision" }.
|
|
41
|
+
*
|
|
42
|
+
* CONCURRENCY: per-key operations on one cache repo are serialized in-process
|
|
43
|
+
* (two observes of the same remote never race `git init` or `fetch`), and a
|
|
44
|
+
* fetch that loses an on-disk `.lock` race to another process is retried.
|
|
45
|
+
*
|
|
46
|
+
* CACHE PIN vs OBJECTS: the pin ref is the fast-path marker, but a wiped or
|
|
47
|
+
* pruned object store is detected (`cat-file -e <oid>^{commit}`) and refetched;
|
|
48
|
+
* the pin never turns a stale cache into a claim about the remote. The object
|
|
49
|
+
* must be a COMMIT: a tag or `at` naming a tree/blob is E_REMOTE_UNREADABLE.
|
|
50
|
+
*
|
|
51
|
+
* CONTENT DIGEST (canonical framing, shared by fetchRemoteTree and contentDigest):
|
|
52
|
+
* sha256 over the concatenation, for every REGULAR FILE sorted by relpath
|
|
53
|
+
* (byte-wise UTF-8 order, "/" separated, relative to the tree root), of
|
|
54
|
+
* "F" NUL relpath NUL mode-octal NUL size-decimal NUL bytes NUL
|
|
55
|
+
* where mode-octal is the GIT-NORMALIZED mode: "755" when the owner-exec bit
|
|
56
|
+
* is set, else "644" (Git stores only 100644/100755, so a local checkout under
|
|
57
|
+
* any umask digests identically to the fetched tree). Empty directories do not
|
|
58
|
+
* enter identity. Result: "sha256-<hex>". Empty tree → sha256 of the empty input.
|
|
59
|
+
*/
|
|
60
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
61
|
+
import { createHash } from "node:crypto";
|
|
62
|
+
import {
|
|
63
|
+
closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync,
|
|
64
|
+
writeFileSync, writeSync, symlinkSync, readlinkSync } from "node:fs";
|
|
65
|
+
import { homedir } from "node:os";
|
|
66
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
67
|
+
import { fileURLToPath } from "node:url";
|
|
68
|
+
import { oatsError } from "./errors.mjs";
|
|
69
|
+
|
|
70
|
+
export const FILE_BUDGET = 4 * 1024 * 1024; // readRemoteFile: 4 MiB per file
|
|
71
|
+
export const TREE_BUDGET = 64 * 1024 * 1024; // fetchRemoteTree: 64 MiB per subtree
|
|
72
|
+
export const GIT_TIMEOUT_MS = 30_000;
|
|
73
|
+
const OID_RE = /^[0-9a-f]{40}$/;
|
|
74
|
+
const SEGMENT_RE = /^[A-Za-z0-9_.-]+$/;
|
|
75
|
+
/** A ref name we are willing to hand to `git ls-remote` as a pattern: never a leading
|
|
76
|
+
* dash (option injection), no whitespace/control chars, no revision syntax (`^{`, `@{`, `~`,
|
|
77
|
+
* `:`), no `..`, no glob chars, no `\`, no `.lock` component, no leading/trailing `/`. */
|
|
78
|
+
const AT_BAD_RE = /^[-/]|\/$|\.\.|[\s~^:?*[\\\x00-\x1f\x7f]|@\{|\/\.|^\.|\.lock(?:\/|$)|\/\//;
|
|
79
|
+
/** Tree entry-name components that must never reach the filesystem. */
|
|
80
|
+
const BAD_COMPONENT_RE = /^(?:|\.|\.\.|\.git)$/i;
|
|
81
|
+
|
|
82
|
+
/** oatsError with the details reachable as BOTH e.provenance (today's field) and e.details. */
|
|
83
|
+
function fail(code, message, details) {
|
|
84
|
+
const e = oatsError(code, message, details);
|
|
85
|
+
if (details) e.details = details;
|
|
86
|
+
return e;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// parseRepoRef
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
function repoPathSegments(rawPath, text) {
|
|
94
|
+
const cleaned = rawPath.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
|
|
95
|
+
const parts = cleaned.split("/");
|
|
96
|
+
if (parts.length < 2 || parts.some((p) => !SEGMENT_RE.test(p) || p === "." || p === "..")) {
|
|
97
|
+
throw fail("E_REPO_REF", `repository reference has an invalid path: ${text}`, { ref: text, path: rawPath });
|
|
98
|
+
}
|
|
99
|
+
return parts.join("/");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function hostedRef(host, rawPath, text) {
|
|
103
|
+
const h = host.toLowerCase();
|
|
104
|
+
if (!/^[a-z0-9.-]+$/.test(h)) throw fail("E_REPO_REF", `repository reference has an invalid host: ${text}`, { ref: text, host });
|
|
105
|
+
const path = repoPathSegments(rawPath, text);
|
|
106
|
+
return Object.freeze({ host: h, path, url: `https://${h}/${path}.git`, key: `${h}/${path}` });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function localRef(absPath) {
|
|
110
|
+
const normalized = resolve(absPath).replace(/[\\/]+$/, "") || sep;
|
|
111
|
+
return Object.freeze({ host: "local", path: normalized, url: normalized, key: `local/${normalized}` });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* "git:github.com/org/repo(.git)" | "https://github.com/org/repo(.git)" | "git@github.com:org/repo(.git)"
|
|
116
|
+
* | "/abs/path/to/bare.git" | "file:///abs/path" → { host, path, url, key } | throws E_REPO_REF.
|
|
117
|
+
* Already-parsed refs (objects with key+url) pass through once re-validated: the
|
|
118
|
+
* object's `url` must itself parse to the same `key` (no smuggled `ext::` urls or `../` keys).
|
|
119
|
+
*/
|
|
120
|
+
export function parseRepoRef(text) {
|
|
121
|
+
if (text && typeof text === "object" && typeof text.key === "string" && typeof text.url === "string") {
|
|
122
|
+
const again = parseRepoRef(text.url);
|
|
123
|
+
if (again.key !== text.key) throw fail("E_REPO_REF", `repository reference object is inconsistent: key ${text.key} does not match url ${text.url}`, { ref: text.key, url: text.url });
|
|
124
|
+
return text;
|
|
125
|
+
}
|
|
126
|
+
if (typeof text !== "string" || !text.trim()) throw fail("E_REPO_REF", "repository reference must be a non-empty string", { ref: text });
|
|
127
|
+
const ref = text.trim();
|
|
128
|
+
let m;
|
|
129
|
+
if ((m = /^git:([^/:@\s]+)\/(.+)$/.exec(ref))) return hostedRef(m[1], m[2], ref);
|
|
130
|
+
if ((m = /^https?:\/\/([^/@\s]+)\/(.+)$/.exec(ref))) return hostedRef(m[1], m[2], ref);
|
|
131
|
+
if ((m = /^git@([^:/\s]+):(.+)$/.exec(ref))) return hostedRef(m[1], m[2], ref);
|
|
132
|
+
if (/^file:\/\//.test(ref)) {
|
|
133
|
+
try { return localRef(fileURLToPath(ref)); } catch { throw fail("E_REPO_REF", `invalid file URL: ${ref}`, { ref }); }
|
|
134
|
+
}
|
|
135
|
+
if (isAbsolute(ref)) return localRef(ref);
|
|
136
|
+
throw fail("E_REPO_REF", `unrecognized repository reference: ${ref}`, { ref });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// git plumbing
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/** The operator's own ssh command (GIT_SSH_COMMAND, else core.sshCommand, else `ssh`),
|
|
144
|
+
* ALWAYS with `-o BatchMode=yes` appended so ssh can never prompt for a passphrase or
|
|
145
|
+
* host key. Resolved once per process; `-o` after the operator's flags is valid for ssh
|
|
146
|
+
* and for every ssh-compatible wrapper that forwards its argv. */
|
|
147
|
+
let sshCommandCache;
|
|
148
|
+
export function sshCommand() {
|
|
149
|
+
if (sshCommandCache !== undefined) return sshCommandCache;
|
|
150
|
+
let base = process.env.GIT_SSH_COMMAND?.trim();
|
|
151
|
+
if (!base) {
|
|
152
|
+
try { base = execFileSync("git", ["config", "--get", "core.sshCommand"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim(); } catch { base = ""; }
|
|
153
|
+
}
|
|
154
|
+
if (!base) base = "ssh";
|
|
155
|
+
sshCommandCache = /(?:^|\s)-o\s*BatchMode=yes(?:\s|$)/i.test(base) ? base : `${base} -o BatchMode=yes`;
|
|
156
|
+
return sshCommandCache;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function gitEnv() {
|
|
160
|
+
return {
|
|
161
|
+
...process.env,
|
|
162
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
163
|
+
GIT_ASKPASS: "/usr/bin/false",
|
|
164
|
+
GIT_SSH_COMMAND: sshCommand(),
|
|
165
|
+
GIT_LITERAL_PATHSPECS: "1",
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Default exec dependency: runs `git <args>`; resolves { stdout, stderr } (Buffers);
|
|
170
|
+
* rejects with { code, signal, killed, stderr, stdout }. Injectable via options.exec. */
|
|
171
|
+
export function runGit(args, { cwd, maxBuffer = 16 * 1024 * 1024, timeout = GIT_TIMEOUT_MS } = {}) {
|
|
172
|
+
return new Promise((resolvePromise, reject) => {
|
|
173
|
+
execFile("git", args, { cwd, env: gitEnv(), timeout, maxBuffer, shell: false, encoding: "buffer", killSignal: "SIGKILL" },
|
|
174
|
+
(error, stdout, stderr) => {
|
|
175
|
+
if (error) {
|
|
176
|
+
error.stdout = stdout; error.stderr = stderr;
|
|
177
|
+
error.timedOut = error.killed === true || error.signal === "SIGKILL";
|
|
178
|
+
reject(error);
|
|
179
|
+
} else resolvePromise({ stdout, stderr });
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function stderrText(error) {
|
|
185
|
+
const s = error?.stderr;
|
|
186
|
+
return Buffer.isBuffer(s) ? s.toString("utf8") : typeof s === "string" ? s : String(error?.message ?? "");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Classify a failed network git call into the contract's four reasons. */
|
|
190
|
+
export function classifyRemoteFailure(error) {
|
|
191
|
+
if (error?.timedOut || error?.signal === "SIGKILL" || error?.signal === "SIGTERM") return "timeout";
|
|
192
|
+
const text = stderrText(error).toLowerCase();
|
|
193
|
+
if (/authentication failed|could not read username|could not read password|permission denied|publickey|403|forbidden|terminal prompts disabled|invalid username or password|access denied|unauthori[sz]ed/.test(text)) return "auth";
|
|
194
|
+
if (/repository not found|repository '.*' not found|not found|does not appear to be a git repository|no such file or directory|not our ref|unadvertised object|couldn't find remote ref|remote ref .* not found|not a valid object name|not a tree object|bad object|is not a valid revision|no such ref/.test(text)) return "not-found";
|
|
195
|
+
if (/could not resolve host|connection refused|connection timed out|network is unreachable|unable to access|early eof|remote end hung up|connection reset|ssl|tls|unable to connect/.test(text)) return "network";
|
|
196
|
+
return "network";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function unreadable(ref, error, extra = {}) {
|
|
200
|
+
const reason = classifyRemoteFailure(error);
|
|
201
|
+
return fail("E_REMOTE_UNREADABLE", `cannot read remote ${ref.url} (${reason})`, { url: ref.url, key: ref.key, reason, ...extra });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// cache
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
function defaultCacheRoot() { return join(homedir(), ".cache", "oats", "remotes"); }
|
|
209
|
+
|
|
210
|
+
/** Per-cache-dir in-process mutex: operations on one bare cache repo run one at a time. */
|
|
211
|
+
const cacheLocks = new Map();
|
|
212
|
+
async function withCacheLock(dir, fn) {
|
|
213
|
+
const previous = cacheLocks.get(dir) ?? Promise.resolve();
|
|
214
|
+
let release;
|
|
215
|
+
const mine = new Promise((r) => { release = r; });
|
|
216
|
+
cacheLocks.set(dir, previous.then(() => mine));
|
|
217
|
+
await previous;
|
|
218
|
+
try { return await fn(); } finally {
|
|
219
|
+
release();
|
|
220
|
+
if (cacheLocks.get(dir) === mine) cacheLocks.delete(dir);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
225
|
+
const isLockRace = (error) => /\.lock': File exists|Unable to create .*\.lock|another git process seems to be running/i.test(stderrText(error));
|
|
226
|
+
|
|
227
|
+
async function cacheRepo(ref, options) {
|
|
228
|
+
const exec = options.exec ?? runGit;
|
|
229
|
+
const root = options.cacheDir ?? defaultCacheRoot();
|
|
230
|
+
const dir = join(root, createHash("sha256").update(ref.key).digest("hex"));
|
|
231
|
+
if (!existsSync(join(dir, "HEAD"))) {
|
|
232
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
233
|
+
try { await exec(["init", "-q", "--bare", dir]); }
|
|
234
|
+
catch (error) {
|
|
235
|
+
// Another process may have won the init race; a usable repo is all we need.
|
|
236
|
+
if (!existsSync(join(dir, "HEAD"))) throw unreadable(ref, error, { cacheDir: dir, stage: "init" });
|
|
237
|
+
}
|
|
238
|
+
try { writeFileSync(join(dir, "oats-remote.json"), JSON.stringify({ key: ref.key, url: ref.url }, null, 2) + "\n", { flag: "wx" }); } catch {}
|
|
239
|
+
}
|
|
240
|
+
const local = (args, opts = {}) => exec(["-C", dir, "-c", "gc.auto=0", ...args], { cwd: dir, ...opts });
|
|
241
|
+
return { dir, exec, local };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function pinRef(oid) { return `refs/oats/commits/${oid}`; }
|
|
245
|
+
|
|
246
|
+
/** Is <oid> present in the cache AND a commit? (`cat-file -e <oid>^{commit}` fails on a
|
|
247
|
+
* missing object, a pruned store, or a tag/tree/blob.) */
|
|
248
|
+
async function hasCommit(repo, oid) {
|
|
249
|
+
return repo.local(["cat-file", "-e", `${oid}^{commit}`]).then(() => true, () => false);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function objectType(repo, oid) {
|
|
253
|
+
try { return (await repo.local(["cat-file", "-t", oid])).stdout.toString("utf8").trim(); } catch { return null; }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Ensure <oid> (a full COMMIT OID) and all its trees/blobs are present in the cache. */
|
|
257
|
+
async function ensureCommit(ref, oid, options) {
|
|
258
|
+
const root = options.cacheDir ?? defaultCacheRoot();
|
|
259
|
+
const dir = join(root, createHash("sha256").update(ref.key).digest("hex"));
|
|
260
|
+
return withCacheLock(dir, async () => {
|
|
261
|
+
const repo = await cacheRepo(ref, options);
|
|
262
|
+
if (await hasCommit(repo, oid)) return repo; // pinned AND present AND a commit
|
|
263
|
+
let lastError;
|
|
264
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
265
|
+
try {
|
|
266
|
+
await repo.local(["fetch", "-q", "--depth", "1", "--no-tags", "--no-recurse-submodules", ref.url, oid]);
|
|
267
|
+
lastError = null;
|
|
268
|
+
break;
|
|
269
|
+
} catch (error) {
|
|
270
|
+
lastError = error;
|
|
271
|
+
if (!isLockRace(error) || error.timedOut) break;
|
|
272
|
+
await sleep(100 * (attempt + 1) ** 2);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (lastError) throw unreadable(ref, lastError, { commit: oid });
|
|
276
|
+
if (!(await hasCommit(repo, oid))) {
|
|
277
|
+
const type = await objectType(repo, oid);
|
|
278
|
+
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 });
|
|
279
|
+
}
|
|
280
|
+
await repo.local(["update-ref", pinRef(oid), oid]);
|
|
281
|
+
return repo;
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// observeRemote
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
function requireCommit(commit) {
|
|
290
|
+
if (typeof commit !== "string" || !OID_RE.test(commit)) throw fail("E_REPO_REF", "commit must be a full 40-hex OID", { commit });
|
|
291
|
+
return commit;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function parseLsRemote(stdout) {
|
|
295
|
+
const lines = stdout.toString("utf8").split("\n").filter(Boolean);
|
|
296
|
+
const symrefs = new Map(), oids = new Map();
|
|
297
|
+
for (const line of lines) {
|
|
298
|
+
const [left, right] = line.split("\t");
|
|
299
|
+
if (left.startsWith("ref: ")) symrefs.set(right, left.slice(5));
|
|
300
|
+
else oids.set(right, left);
|
|
301
|
+
}
|
|
302
|
+
return { symrefs, oids };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function resolveAt(parsed, at) {
|
|
306
|
+
if (at === undefined || at === null || at === "" || at === "HEAD") {
|
|
307
|
+
const oid = parsed.oids.get("HEAD");
|
|
308
|
+
return oid ? { commit: oid, ref: parsed.symrefs.get("HEAD") ?? null } : null;
|
|
309
|
+
}
|
|
310
|
+
const candidates = [`refs/tags/${at}^{}`, `refs/tags/${at}`, `refs/heads/${at}`, at, `${at}^{}`];
|
|
311
|
+
for (const name of candidates) {
|
|
312
|
+
const oid = parsed.oids.get(name);
|
|
313
|
+
if (oid) {
|
|
314
|
+
const bare = name.replace(/\^\{\}$/, "");
|
|
315
|
+
return { commit: oid, ref: bare.startsWith("refs/") ? bare : null };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* at: undefined → remote default branch (ls-remote --symref HEAD); a full OID; or a tag/branch name.
|
|
323
|
+
* → { key, url, commit, ref, observedAt } | throws E_REMOTE_UNREADABLE { url, reason }.
|
|
324
|
+
* Never half-succeeds; never prompts. A full OID already in the cache costs no network call.
|
|
325
|
+
*/
|
|
326
|
+
export async function observeRemote(refText, { at, ...options } = {}) {
|
|
327
|
+
const ref = parseRepoRef(refText);
|
|
328
|
+
const exec = options.exec ?? runGit;
|
|
329
|
+
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 });
|
|
330
|
+
if (typeof at === "string" && OID_RE.test(at)) {
|
|
331
|
+
await ensureCommit(ref, at, options);
|
|
332
|
+
return { key: ref.key, url: ref.url, commit: at, ref: null, observedAt: new Date().toISOString() };
|
|
333
|
+
}
|
|
334
|
+
const wantHead = at === undefined || at === null || at === "" || at === "HEAD";
|
|
335
|
+
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 });
|
|
336
|
+
const args = ["ls-remote", "--symref", ref.url];
|
|
337
|
+
if (wantHead) args.push("HEAD");
|
|
338
|
+
else args.push(`refs/tags/${at}`, `refs/tags/${at}^{}`, `refs/heads/${at}`, at);
|
|
339
|
+
let out;
|
|
340
|
+
try { out = await exec(args, { timeout: GIT_TIMEOUT_MS }); }
|
|
341
|
+
catch (error) { throw unreadable(ref, error, { at: at ?? null }); }
|
|
342
|
+
const parsed = parseLsRemote(out.stdout);
|
|
343
|
+
const hit = resolveAt(parsed, at);
|
|
344
|
+
if (!hit) throw fail("E_REMOTE_UNREADABLE", `remote ${ref.url} has no ref matching ${at ?? "HEAD"}`, { url: ref.url, key: ref.key, reason: "not-found", at: at ?? null });
|
|
345
|
+
if (!OID_RE.test(hit.commit)) throw fail("E_REMOTE_UNREADABLE", `remote ${ref.url} returned a non-OID for ${at ?? "HEAD"}`, { url: ref.url, key: ref.key, reason: "not-found", at: at ?? null });
|
|
346
|
+
await ensureCommit(ref, hit.commit, options);
|
|
347
|
+
return { key: ref.key, url: ref.url, commit: hit.commit, ref: hit.ref, observedAt: new Date().toISOString() };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// tree reading
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
function normalizeTreePath(path, { allowRoot }) {
|
|
355
|
+
if (path === undefined || path === null || path === "" || path === ".") {
|
|
356
|
+
if (allowRoot) return "";
|
|
357
|
+
throw fail("E_REPO_REF", "path must name a file, not the tree root", { path });
|
|
358
|
+
}
|
|
359
|
+
if (typeof path !== "string") throw fail("E_REPO_REF", "path must be a string", { path });
|
|
360
|
+
const parts = path.replace(/\\/g, "/").split("/").filter((p) => p !== "" && p !== ".");
|
|
361
|
+
if (parts.length === 0) { if (allowRoot) return ""; throw fail("E_REPO_REF", "path must name a file", { path }); }
|
|
362
|
+
if (parts.some((p) => p === "..")) throw fail("E_REPO_REF", "path must be relative and must not escape the tree", { path });
|
|
363
|
+
return parts.join("/");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Parse `git ls-tree -l -z` output → [{ mode, type, oid, size, path }]. */
|
|
367
|
+
function parseLsTree(stdout) {
|
|
368
|
+
const entries = [];
|
|
369
|
+
for (const record of stdout.toString("utf8").split("\0")) {
|
|
370
|
+
if (!record) continue;
|
|
371
|
+
const tab = record.indexOf("\t");
|
|
372
|
+
const meta = record.slice(0, tab).trim().split(/\s+/), path = record.slice(tab + 1);
|
|
373
|
+
const [mode, type, oid, size] = meta;
|
|
374
|
+
entries.push({ mode, type, oid, size: size === "-" ? null : Number(size), path });
|
|
375
|
+
}
|
|
376
|
+
return entries;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Refuse any entry whose name could escape or subvert a checkout: a component that is
|
|
380
|
+
* empty, `.`, `..`, `.git` (any case), or a name containing `\` or NUL. Git's transport does
|
|
381
|
+
* not fsck tree entry names, so a hostile remote can serve them. */
|
|
382
|
+
function assertSafeEntryPath(entryPath, ref, commit, shown = entryPath) {
|
|
383
|
+
const bad = /[\\\x00]/.test(entryPath) || entryPath.split("/").some((c) => BAD_COMPONENT_RE.test(c));
|
|
384
|
+
if (bad) throw fail("E_REMOTE_TREE_UNSAFE", `${shown} has an unsafe entry name`, { path: shown, why: "path", key: ref.key, commit });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Entries that would collide on a case- or normalization-insensitive filesystem (APFS, NTFS). */
|
|
388
|
+
function assertNoCollisions(entries, ref, commit, prefix) {
|
|
389
|
+
const seen = new Map();
|
|
390
|
+
for (const e of entries) {
|
|
391
|
+
const folded = e.path.normalize("NFC").toLowerCase();
|
|
392
|
+
const other = seen.get(folded);
|
|
393
|
+
if (other !== undefined && other !== e.path) {
|
|
394
|
+
const shown = prefix ? `${prefix}/${e.path}` : e.path;
|
|
395
|
+
throw fail("E_REMOTE_TREE_UNSAFE", `${shown} collides with ${prefix ? `${prefix}/${other}` : other} on a case-insensitive filesystem`, { path: shown, why: "collision", other: prefix ? `${prefix}/${other}` : other, key: ref.key, commit });
|
|
396
|
+
}
|
|
397
|
+
seen.set(folded, e.path);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** `git ls-tree -l -z [flags] <spec> [-- <path>]`; null when <spec> names no tree. */
|
|
402
|
+
async function lsTree(repo, spec, { flags = [], path } = {}) {
|
|
403
|
+
try {
|
|
404
|
+
const args = ["ls-tree", "-l", "-z", ...flags, spec];
|
|
405
|
+
if (path !== undefined) args.push("--", path);
|
|
406
|
+
const out = await repo.local(args, { maxBuffer: 64 * 1024 * 1024 });
|
|
407
|
+
return parseLsTree(out.stdout);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
const text = stderrText(error).toLowerCase();
|
|
410
|
+
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;
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Return the single ls-tree entry for <commit>:<path>, or null when absent. */
|
|
416
|
+
async function entryAt(repo, commit, path) {
|
|
417
|
+
const entries = await lsTree(repo, commit, { path });
|
|
418
|
+
if (!entries) return null;
|
|
419
|
+
return entries.find((e) => e.path === path) ?? null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* → { bytes, size } | E_REMOTE_UNREADABLE | E_REMOTE_PATH_MISSING { path } | E_REMOTE_FILE_OVERSIZE { path, size, budget }
|
|
424
|
+
* A symlink at <path> is refused (E_REMOTE_TREE_UNSAFE { path, why: "symlink" }); a directory → E_REMOTE_PATH_MISSING.
|
|
425
|
+
*/
|
|
426
|
+
export async function readRemoteFile(refText, commit, path, options = {}) {
|
|
427
|
+
const ref = parseRepoRef(refText);
|
|
428
|
+
requireCommit(commit);
|
|
429
|
+
const rel = normalizeTreePath(path, { allowRoot: false });
|
|
430
|
+
const repo = await ensureCommit(ref, commit, options);
|
|
431
|
+
const entry = await entryAt(repo, commit, rel);
|
|
432
|
+
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 });
|
|
433
|
+
if (entry.mode === "120000") throw fail("E_REMOTE_TREE_UNSAFE", `${rel} is a symlink`, { path: rel, why: "symlink", key: ref.key, commit });
|
|
434
|
+
if (entry.size > FILE_BUDGET) throw fail("E_REMOTE_FILE_OVERSIZE", `${rel} is ${entry.size} bytes (budget ${FILE_BUDGET})`, { path: rel, size: entry.size, budget: FILE_BUDGET, key: ref.key, commit });
|
|
435
|
+
let out;
|
|
436
|
+
try { out = await repo.local(["cat-file", "blob", entry.oid], { maxBuffer: FILE_BUDGET + 1024 }); }
|
|
437
|
+
catch (error) { throw unreadable(ref, error, { commit, path: rel }); }
|
|
438
|
+
return { bytes: out.stdout, size: out.stdout.length };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* → [{ path, type: "blob"|"tree", size? }] relative to <dir>, depth-bounded (depth 1 = direct children).
|
|
443
|
+
* Missing dir → []. Symlinks are reported as type "symlink" so callers can skip them.
|
|
444
|
+
*/
|
|
445
|
+
export async function listRemoteTree(refText, commit, dir, { depth = 2, ...options } = {}) {
|
|
446
|
+
const ref = parseRepoRef(refText);
|
|
447
|
+
requireCommit(commit);
|
|
448
|
+
if (!Number.isInteger(depth) || depth < 1) throw fail("E_REPO_REF", "depth must be a positive integer", { depth });
|
|
449
|
+
const rel = normalizeTreePath(dir, { allowRoot: true });
|
|
450
|
+
const repo = await ensureCommit(ref, commit, options);
|
|
451
|
+
const spec = rel ? `${commit}:${rel}` : commit;
|
|
452
|
+
if (rel) {
|
|
453
|
+
const entry = await entryAt(repo, commit, rel);
|
|
454
|
+
if (!entry || entry.type !== "tree") return [];
|
|
455
|
+
}
|
|
456
|
+
const entries = await lsTree(repo, spec, { flags: ["-r", "-t"] });
|
|
457
|
+
if (!entries) return [];
|
|
458
|
+
const result = [];
|
|
459
|
+
for (const e of entries) {
|
|
460
|
+
assertSafeEntryPath(e.path, ref, commit, rel ? `${rel}/${e.path}` : e.path);
|
|
461
|
+
if (e.path.split("/").length > depth) continue;
|
|
462
|
+
if (e.type === "commit") continue; // submodule gitlinks are not part of the observable tree
|
|
463
|
+
const type = e.mode === "120000" ? "symlink" : e.type;
|
|
464
|
+
const row = { path: e.path, type };
|
|
465
|
+
if (e.type === "blob") row.size = e.size;
|
|
466
|
+
result.push(row);
|
|
467
|
+
}
|
|
468
|
+
result.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
469
|
+
return result;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ---------------------------------------------------------------------------
|
|
473
|
+
// digest
|
|
474
|
+
// ---------------------------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
const NUL = Buffer.from([0]);
|
|
477
|
+
const gitMode = (mode) => ((mode & 0o100) ? "755" : "644");
|
|
478
|
+
const byPath = (a, b) => Buffer.compare(Buffer.from(a.path, "utf8"), Buffer.from(b.path, "utf8"));
|
|
479
|
+
|
|
480
|
+
function createDigest() {
|
|
481
|
+
const hash = createHash("sha256");
|
|
482
|
+
let files = 0, bytes = 0;
|
|
483
|
+
return {
|
|
484
|
+
add(relpath, mode, content) {
|
|
485
|
+
hash.update("F"); hash.update(NUL);
|
|
486
|
+
hash.update(Buffer.from(relpath, "utf8")); hash.update(NUL);
|
|
487
|
+
hash.update(gitMode(mode)); hash.update(NUL);
|
|
488
|
+
hash.update(String(content.length)); hash.update(NUL);
|
|
489
|
+
hash.update(content); hash.update(NUL);
|
|
490
|
+
files += 1; bytes += content.length;
|
|
491
|
+
},
|
|
492
|
+
finish() { return { files, bytes, digest: `sha256-${hash.digest("hex")}` }; },
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Same digest as fetchRemoteTree, computed over a local directory. Symlinks and
|
|
497
|
+
* non-regular entries are refused (E_REMOTE_TREE_UNSAFE); a missing dir → E_REMOTE_PATH_MISSING. */
|
|
498
|
+
function posixNormalize(p) {
|
|
499
|
+
const out = [];
|
|
500
|
+
for (const seg of p.split("/")) { if (!seg || seg === ".") continue; if (seg === "..") { if (out.length && out.at(-1) !== "..") out.pop(); else out.push(".."); } else out.push(seg); }
|
|
501
|
+
return out.join("/");
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* The ONE symlink an OATS tree may carry (decision 13): a `CLAUDE.md` whose target
|
|
505
|
+
* is `AGENTS.md` beside it — the Claude-runtime alias of the canonical
|
|
506
|
+
* instructions. Souls carry it at their root; a capability's agents/<name>/ dirs
|
|
507
|
+
* carry it too. Anything else stays refused. The rule is on the PATH (any depth,
|
|
508
|
+
* basename CLAUDE.md); fetchRemoteTree still enforces the target constraint
|
|
509
|
+
* (relative, non-escaping) and contentDigest digests it as `symlink:<target>`.
|
|
510
|
+
*/
|
|
511
|
+
export const OATS_ALIAS_SYMLINK = (relPath) => typeof relPath === "string" && (relPath === "CLAUDE.md" || relPath.endsWith("/CLAUDE.md"));
|
|
512
|
+
|
|
513
|
+
export function contentDigest(dir, { allowSymlinks = null } = {}) {
|
|
514
|
+
if (typeof dir !== "string" || !isAbsolute(dir)) throw fail("E_REPO_REF", "contentDigest requires an absolute directory path", { path: dir });
|
|
515
|
+
let st;
|
|
516
|
+
try { st = lstatSync(dir); } catch { throw fail("E_REMOTE_PATH_MISSING", `${dir} does not exist`, { path: dir }); }
|
|
517
|
+
if (!st.isDirectory()) throw fail("E_REMOTE_PATH_MISSING", `${dir} is not a directory`, { path: dir });
|
|
518
|
+
const files = [];
|
|
519
|
+
const walk = (abs, rel) => {
|
|
520
|
+
for (const name of readdirSync(abs)) {
|
|
521
|
+
if (!rel && name === ".git") continue; // a local checkout's metadata is not content
|
|
522
|
+
const p = join(abs, name), r = rel ? `${rel}/${name}` : name, s = lstatSync(p);
|
|
523
|
+
if (s.isSymbolicLink()) {
|
|
524
|
+
// Refused by default (contract §1). The same narrow opt-in fetchRemoteTree
|
|
525
|
+
// honours (a relative, non-escaping alias such as CLAUDE.md → AGENTS.md)
|
|
526
|
+
// digests as `symlink:<target>` so a fetched tree and its digest agree.
|
|
527
|
+
if (typeof allowSymlinks !== "function" || !allowSymlinks(r)) throw fail("E_REMOTE_TREE_UNSAFE", `${r} is a symlink`, { path: r, why: "symlink" });
|
|
528
|
+
files.push({ path: r, mode: 0o777, bytes: Buffer.from(`symlink:${readlinkSync(p)}`) });
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
if (s.isDirectory()) walk(p, r);
|
|
532
|
+
else if (s.isFile()) files.push({ path: r, mode: s.mode, abs: p });
|
|
533
|
+
else throw fail("E_REMOTE_TREE_UNSAFE", `${r} is not a regular file`, { path: r, why: "device" });
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
walk(dir, "");
|
|
537
|
+
files.sort(byPath);
|
|
538
|
+
const d = createDigest();
|
|
539
|
+
for (const f of files) d.add(f.path, f.mode, f.bytes ?? readFileSync(f.abs));
|
|
540
|
+
return d.finish().digest;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
// fetchRemoteTree
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Copies the subtree <dir> of <commit> into destDir (created; must not exist).
|
|
549
|
+
* Regular files and dirs only: symlinks/submodules → E_REMOTE_TREE_UNSAFE { path, why }, total > 64 MiB → why "oversize".
|
|
550
|
+
* Missing <dir> → E_REMOTE_PATH_MISSING. → { files, bytes, digest }. Atomic: staging dir + rename, nothing left on failure.
|
|
551
|
+
*/
|
|
552
|
+
export async function fetchRemoteTree(refText, commit, dir, destDir, options = {}) {
|
|
553
|
+
const ref = parseRepoRef(refText);
|
|
554
|
+
requireCommit(commit);
|
|
555
|
+
const rel = normalizeTreePath(dir, { allowRoot: true });
|
|
556
|
+
if (typeof destDir !== "string" || !isAbsolute(destDir)) throw fail("E_REPO_REF", "destDir must be an absolute path", { destDir });
|
|
557
|
+
const dest = resolve(destDir);
|
|
558
|
+
let destStat = null;
|
|
559
|
+
try { destStat = lstatSync(dest); } catch {}
|
|
560
|
+
if (destStat) throw fail("E_REMOTE_TREE_UNSAFE", `${dest} already exists${destStat.isSymbolicLink() ? " (a symlink)" : ""}`, { path: dest, why: "exists" });
|
|
561
|
+
const repo = await ensureCommit(ref, commit, options);
|
|
562
|
+
const spec = rel ? `${commit}:${rel}` : commit;
|
|
563
|
+
if (rel) {
|
|
564
|
+
const entry = await entryAt(repo, commit, rel);
|
|
565
|
+
if (!entry || entry.type !== "tree") throw fail("E_REMOTE_PATH_MISSING", `${rel} is not a directory in ${ref.key}@${commit.slice(0, 12)}`, { path: rel, key: ref.key, commit });
|
|
566
|
+
}
|
|
567
|
+
const entries = await lsTree(repo, spec, { flags: ["-r", "-t"] });
|
|
568
|
+
if (!entries) throw fail("E_REMOTE_PATH_MISSING", `${rel || "."} is missing in ${ref.key}@${commit.slice(0, 12)}`, { path: rel, key: ref.key, commit });
|
|
569
|
+
// Inspect everything BEFORE writing anything: names, modes, types, sizes, collisions.
|
|
570
|
+
let total = 0;
|
|
571
|
+
const blobs = [], trees = [], links = [];
|
|
572
|
+
// `allowSymlinks(relPath)` (opt-in, narrow): a symlink whose TARGET is relative
|
|
573
|
+
// and stays inside the fetched subtree may be materialized as a symlink — the
|
|
574
|
+
// one legitimate case is a soul's `CLAUDE.md → AGENTS.md` alias. Anything else
|
|
575
|
+
// (absolute, escaping, or not allowed by the predicate) is refused as before.
|
|
576
|
+
const allowSymlinks = typeof options.allowSymlinks === "function" ? options.allowSymlinks : null;
|
|
577
|
+
for (const e of entries) {
|
|
578
|
+
const shown = rel ? `${rel}/${e.path}` : e.path;
|
|
579
|
+
assertSafeEntryPath(e.path, ref, commit, shown);
|
|
580
|
+
if (e.mode === "120000") {
|
|
581
|
+
if (!allowSymlinks || !allowSymlinks(e.path)) throw fail("E_REMOTE_TREE_UNSAFE", `${shown} is a symlink`, { path: shown, why: "symlink", key: ref.key, commit });
|
|
582
|
+
links.push(e); continue;
|
|
583
|
+
}
|
|
584
|
+
if (e.type === "commit") throw fail("E_REMOTE_TREE_UNSAFE", `${shown} is a submodule`, { path: shown, why: "device", key: ref.key, commit });
|
|
585
|
+
if (e.type === "tree") { trees.push(e); continue; }
|
|
586
|
+
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 });
|
|
587
|
+
total += e.size;
|
|
588
|
+
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 });
|
|
589
|
+
blobs.push(e);
|
|
590
|
+
}
|
|
591
|
+
blobs.sort(byPath);
|
|
592
|
+
assertNoCollisions(entries, ref, commit, rel);
|
|
593
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
594
|
+
const staging = join(dirname(dest), `.${dest.split(sep).pop()}.oats-staging-${process.pid}-${Date.now().toString(36)}`);
|
|
595
|
+
try {
|
|
596
|
+
mkdirSync(staging, { mode: 0o755 });
|
|
597
|
+
for (const t of trees) mkdirSync(join(staging, ...t.path.split("/")), { recursive: true, mode: 0o755 });
|
|
598
|
+
// One canonical pass over blobs AND links in byte order of path: contentDigest
|
|
599
|
+
// walks the written tree in the same order, so the two digests agree whatever
|
|
600
|
+
// the git tree listed first.
|
|
601
|
+
const items = [...blobs.map((b) => ({ kind: "blob", e: b })), ...links.map((l) => ({ kind: "link", e: l }))].sort((a, b) => byPath(a.e, b.e));
|
|
602
|
+
const d = createDigest();
|
|
603
|
+
for (const { kind, e } of items) {
|
|
604
|
+
if (kind === "blob") {
|
|
605
|
+
const b = e;
|
|
606
|
+
let out;
|
|
607
|
+
try { out = await repo.local(["cat-file", "blob", b.oid], { maxBuffer: TREE_BUDGET + 1024 }); }
|
|
608
|
+
catch (error) { throw unreadable(ref, error, { commit, path: b.path }); }
|
|
609
|
+
const mode = b.mode === "100755" ? 0o755 : 0o644;
|
|
610
|
+
const target = join(staging, ...b.path.split("/"));
|
|
611
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o755 });
|
|
612
|
+
const fd = openSync(target, "wx", mode);
|
|
613
|
+
try { writeSync(fd, out.stdout); } finally { closeSync(fd); }
|
|
614
|
+
d.add(b.path, mode, out.stdout);
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
const l = e;
|
|
618
|
+
let out;
|
|
619
|
+
try { out = await repo.local(["cat-file", "blob", l.oid], { maxBuffer: 64 * 1024 }); }
|
|
620
|
+
catch (error) { throw unreadable(ref, error, { commit, path: l.path }); }
|
|
621
|
+
const linkTarget = out.stdout.toString("utf8").trim();
|
|
622
|
+
const from = dirname(l.path === "" ? "x" : l.path);
|
|
623
|
+
const resolvedRel = posixNormalize(from === "." ? linkTarget : `${from}/${linkTarget}`);
|
|
624
|
+
if (isAbsolute(linkTarget) || linkTarget.includes("\0") || resolvedRel.startsWith("../") || resolvedRel === "..") {
|
|
625
|
+
throw fail("E_REMOTE_TREE_UNSAFE", `${rel ? `${rel}/` : ""}${l.path} is a symlink escaping the fetched tree (${linkTarget})`, { path: l.path, why: "symlink", key: ref.key, commit });
|
|
626
|
+
}
|
|
627
|
+
const target = join(staging, ...l.path.split("/"));
|
|
628
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o755 });
|
|
629
|
+
symlinkSync(linkTarget, target);
|
|
630
|
+
d.add(l.path, 0o777, Buffer.from(`symlink:${linkTarget}`));
|
|
631
|
+
}
|
|
632
|
+
const summary = d.finish();
|
|
633
|
+
renameSync(staging, dest);
|
|
634
|
+
return summary;
|
|
635
|
+
} catch (error) {
|
|
636
|
+
rmSync(staging, { recursive: true, force: true });
|
|
637
|
+
throw error;
|
|
638
|
+
}
|
|
639
|
+
}
|