Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

478 lines · 22.5 KBRaw History
1/**
2 * Import mode: copy a repository's Git data from GitHub into Diffinery.
3 *
4 * Workers can't run git and a push request is capped (Diffinery refuses pushes
5 * over 100 MB, and a repository's Durable Object has 128 MB of memory), so this
6 * runs in a container: `git clone --mirror` from GitHub, then push history in
7 * chunks that stay well under the cap (first-parent walks to temporary refs,
8 * merged side branches first, and object batches for single huge commits), then
9 * every branch and tag, pull request heads to `refs/diffinery-import/pull/N`
10 * (the server creates `refs/pull/N/head` from the imported pull request and
11 * then drops these), then Git LFS objects.
12 *
13 * Tokens never appear in argv or logs: git reads them from GIT_CONFIG_* environment
14 * entries scoped to one host each. Pushes carry `-o diffinery-import`, which the
15 * server honours only for this import's own token, so they trigger no workflows,
16 * webhooks or notifications.
17 */
18import { mkdir, readdir, rm, stat } from "node:fs/promises";
19import path from "node:path";
20import type { ImportGitResult, ImportProgress, ImportSecretFinding, ImportSpec } from "@diffinery/shared";
21import { RunnerApi } from "./api.js";
22import { IMPORT_REF_PREFIX, batchBySize, furthestWithin, importDestination, importRefKind, isSecretRejection, isSizeRejection, parsePorcelain, parseSecretFindings, sortRefsForImport } from "./import-plan.js";
23import { exec } from "./util.js";
24
25export interface ImportAgentOptions {
26 apiUrl: string;
27 importId: string;
28 token: string;
29 workRoot: string;
30 /** Overrides the spec's push size budget (tests use a small one to exercise chunking). */
31 chunkBytes?: number;
32 log?: (line: string) => void;
33}
34
35/** Chunk refs; only these are deleted at the end (pull request heads stay until the server has created refs/pull/N/head). */
36const TEMP_PREFIX = `${IMPORT_REF_PREFIX}tmp/`;
37const MIN_BUDGET = 64 * 1024;
38const REF_BATCH = 200;
39
40export class ImportError extends Error {}
41
42const errMsg = (e: unknown) => (e instanceof Error ? e.message : String(e));
43
44/** `http.<origin>/.extraHeader` entry: the credential goes to that host only. */
45function authEntry(url: string, user: string, password: string): [string, string] {
46 return [`http.${new URL(url).origin}/.extraHeader`, `Authorization: Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`];
47}
48
49function withConfig(env: NodeJS.ProcessEnv, entries: Array<[string, string]>): NodeJS.ProcessEnv {
50 const out = { ...env };
51 let n = Number(out.GIT_CONFIG_COUNT ?? 0);
52 if (!Number.isInteger(n) || n < 0) n = 0;
53 for (const [k, v] of entries) {
54 out[`GIT_CONFIG_KEY_${n}`] = k;
55 out[`GIT_CONFIG_VALUE_${n}`] = v;
56 n++;
57 }
58 out.GIT_CONFIG_COUNT = String(n);
59 return out;
60}
61
62/** Removes anything that looks like a credential from text we report. */
63function redact(s: string, secrets: string[]): string {
64 let out = s;
65 for (const x of secrets) if (x) out = out.split(x).join("***");
66 return out.replace(/(Authorization:\s*\w+\s+)\S+/gi, "$1***");
67}
68
69class GitRunner {
70 constructor(
71 readonly dir: string,
72 private readonly env: NodeJS.ProcessEnv,
73 private readonly secrets: string[],
74 ) {}
75
76 async run(args: string[], o: { input?: string; allowFail?: boolean; cwd?: string; maxCapture?: number } = {}): Promise<{ code: number; stdout: string; stderr: string }> {
77 const r = await exec("git", args, { cwd: o.cwd ?? this.dir, env: this.env, input: o.input, capture: true, maxCapture: o.maxCapture ?? 256 * 1024 * 1024 });
78 const res = { code: r.exitCode, stdout: r.stdout, stderr: redact(r.stderr, this.secrets) };
79 if (r.exitCode !== 0 && !o.allowFail) throw new ImportError(`git ${args[0]} failed: ${res.stderr.trim().split("\n").slice(-3).join(" ").slice(0, 500)}`);
80 return res;
81 }
82
83 async lines(args: string[], input?: string): Promise<string[]> {
84 return (await this.run(args, { input })).stdout.split("\n").filter(Boolean);
85 }
86}
87
88/** Fetch the spec, transfer everything, and report the result. Returns the result it reported. */
89export async function runImportAgent(o: ImportAgentOptions): Promise<ImportGitResult> {
90 const log = o.log ?? ((l: string) => process.stdout.write(`[diffinery-import] ${l}\n`));
91 const api = new RunnerApi(`${o.apiUrl.replace(/\/$/, "")}/api/v3/diffinery/imports/${encodeURIComponent(o.importId)}/agent`, o.token);
92 const spec = (await (await api.request("/spec", { retries: 8 })).json()) as ImportSpec;
93 let lastProgress = 0;
94 const progress = async (p: ImportProgress, force = false) => {
95 log(p.message);
96 if (!force && Date.now() - lastProgress < 2000) return;
97 lastProgress = Date.now();
98 await api.request("/progress", { method: "POST", json: p, retries: 2 }).catch((e) => log(`progress report failed: ${errMsg(e)}`));
99 };
100 let result: ImportGitResult;
101 try {
102 result = await transfer(spec, { workRoot: o.workRoot, chunkBytes: o.chunkBytes, progress, log });
103 } catch (e) {
104 result = emptyResult();
105 result.ok = false;
106 result.error = redact(errMsg(e), [spec.githubToken ?? "", spec.pushToken]);
107 if (e instanceof SecretsFound) result.secretFindings = e.findings;
108 }
109 await api.request("/complete", { method: "POST", json: result, retries: 10 });
110 return result;
111}
112
113class SecretsFound extends ImportError {
114 constructor(
115 message: string,
116 readonly findings: ImportSecretFinding[],
117 ) {
118 super(message);
119 }
120}
121
122function emptyResult(): ImportGitResult {
123 return { ok: true, branches: 0, tags: 0, pullRefs: 0, chunks: 0, bytes: 0, lfsObjects: 0, lfsBytes: 0, defaultBranch: null, secretFindings: [], warnings: [] };
124}
125
126export interface TransferOptions {
127 workRoot: string;
128 chunkBytes?: number;
129 progress: (p: ImportProgress, force?: boolean) => Promise<void>;
130 log: (line: string) => void;
131}
132
133/** The whole Git transfer for one spec. Exported for tests. */
134export async function transfer(spec: ImportSpec, o: TransferOptions): Promise<ImportGitResult> {
135 const result = emptyResult();
136 const secrets = [spec.githubToken ?? "", spec.pushToken];
137 const root = path.join(o.workRoot, `import-${spec.importId.replace(/[^\w-]/g, "")}`);
138 await rm(root, { recursive: true, force: true });
139 await mkdir(root, { recursive: true });
140 const mirror = path.join(root, "mirror.git");
141 // The clone URL must be https (a loopback host or a file:// path is allowed for local test
142 // doubles), so the token never travels in the clear and never reaches a non-GitHub host. In
143 // production the orchestrator always supplies GitHub's https clone URL.
144 const localDouble = spec.source.cloneUrl.startsWith("file://");
145 const cloneHost = localDouble ? "" : new URL(spec.source.cloneUrl).hostname.toLowerCase();
146 const loopback = cloneHost === "localhost" || cloneHost === "127.0.0.1" || cloneHost === "::1";
147 if (!spec.source.cloneUrl.startsWith("https://") && !loopback && !localDouble) throw new Error("the clone URL must use https");
148 // Pin the LFS endpoint to each operation's own host so a repository's .lfsconfig cannot redirect
149 // LFS traffic (and a token) to another host: the source host for the fetch, the target host for
150 // the push. A command-line `-c lfs.url=` overrides .lfsconfig, so we set it per command rather
151 // than globally (a global lfs.url would also misdirect the push to Diffinery back at the source).
152 const sourceLfsUrl = `${spec.source.cloneUrl.replace(/\.git$/, "")}.git/info/lfs`;
153 const targetLfsUrl = `${spec.target.pushUrl.replace(/\.git$/, "")}.git/info/lfs`;
154 const entries: Array<[string, string]> = [
155 ["credential.helper", ""],
156 ["protocol.version", "2"],
157 // Never follow an HTTP redirect: a redirect could carry the credential to another host.
158 ["http.followRedirects", "false"],
159 ["lfs.locksverify", "false"],
160 ["lfs.concurrenttransfers", "4"],
161 ["core.askPass", "true"],
162 ];
163 if (spec.githubToken) entries.push(authEntry(spec.source.cloneUrl, "x-access-token", spec.githubToken));
164 entries.push(authEntry(spec.target.pushUrl, "x-token", spec.pushToken));
165 const env = withConfig(
166 {
167 ...process.env,
168 GIT_TERMINAL_PROMPT: "0",
169 GIT_LFS_SKIP_SMUDGE: "1",
170 GIT_AUTHOR_NAME: "Diffinery import",
171 GIT_AUTHOR_EMAIL: "import@diffinery.invalid",
172 GIT_COMMITTER_NAME: "Diffinery import",
173 GIT_COMMITTER_EMAIL: "import@diffinery.invalid",
174 },
175 entries,
176 );
177 const git = new GitRunner(mirror, env, secrets);
178 try {
179 // ------------------------------------------------------------ clone
180 await o.progress({ phase: "clone", message: `Cloning ${spec.source.fullName}` }, true);
181 await git.run(["clone", "--mirror", "--quiet", spec.source.cloneUrl, mirror], { cwd: root });
182 const refLines = await git.lines(["for-each-ref", "--format=%(objectname) %(objecttype) %(*objectname) %(*objecttype) %(refname)"]);
183 const refs = new Map<string, { oid: string; commit: string | null }>();
184 for (const line of refLines) {
185 const [oid, type, peeled, peeledType, ...rest] = line.split(" ");
186 const name = rest.join(" ");
187 if (!importRefKind(name)) continue;
188 const commit = type === "commit" ? oid! : peeledType === "commit" ? peeled! : null;
189 refs.set(name, { oid: oid!, commit });
190 }
191 const ordered = sortRefsForImport([...refs.keys()], spec.source.defaultBranch);
192 if (!ordered.length) {
193 await o.progress({ phase: "done", message: "The GitHub repository is empty" }, true);
194 return result;
195 }
196
197 // ------------------------------------------------------------ LFS objects from GitHub
198 if (spec.lfs) {
199 await o.progress({ phase: "lfs-fetch", message: "Fetching Git LFS objects" }, true);
200 const f = await git.run(["-c", `lfs.url=${sourceLfsUrl}`, "lfs", "fetch", "--all", "origin"], { allowFail: true });
201 if (f.code !== 0) result.warnings.push(`Some Git LFS objects could not be fetched from GitHub: ${lastLine(f.stderr)}`);
202 }
203
204 // ------------------------------------------------------------ history, in chunks
205 const pushOpts = ["-o", "diffinery-import", ...(spec.secrets === "bypass" ? ["-o", `allow-secrets=${spec.bypassReason}`] : [])];
206 const pusher = new ChunkPusher(git, spec, pushOpts, o, result);
207 await pusher.loadRemoteState();
208 for (const name of ordered) {
209 const ref = refs.get(name)!;
210 if (ref.commit) await pusher.ensurePushed(ref.commit, name);
211 }
212
213 // ------------------------------------------------------------ the refs themselves
214 await o.progress({ phase: "refs", message: `Creating ${ordered.length} branches, tags and pull request refs`, chunks: result.chunks, bytes: result.bytes }, true);
215 for (let i = 0; i < ordered.length; i += REF_BATCH) {
216 const batch = ordered.slice(i, i + REF_BATCH);
217 const out = await pusher.push(batch.map((r) => `+${r}:${importDestination(r)}`));
218 const status = new Map(parsePorcelain(out.stdout).map((s) => [s.ref, s]));
219 for (const r of batch) {
220 const s = status.get(importDestination(r));
221 if (out.code !== 0 && !s) throw new ImportError(`pushing refs failed: ${lastLine(out.stderr)}`);
222 if (s && !s.ok) {
223 result.warnings.push(`${r} was not imported: ${s.reason}`);
224 continue;
225 }
226 const kind = importRefKind(r);
227 if (kind === "branch") result.branches++;
228 else if (kind === "tag") result.tags++;
229 else if (kind === "pull") result.pullRefs++;
230 }
231 }
232 if (refs.has(`refs/heads/${spec.source.defaultBranch}`)) result.defaultBranch = spec.source.defaultBranch;
233
234 // ------------------------------------------------------------ LFS objects to Diffinery
235 if (spec.lfs) {
236 const { count, bytes } = await lfsStore(mirror);
237 if (count > 0) {
238 await o.progress({ phase: "lfs-push", message: `Uploading ${count} Git LFS objects` }, true);
239 const p = await git.run(["-c", `lfs.url=${targetLfsUrl}`, "lfs", "push", "--all", spec.target.pushUrl], { allowFail: true });
240 if (p.code !== 0) result.warnings.push(`Some Git LFS objects could not be uploaded: ${lastLine(p.stderr)}`);
241 result.lfsObjects = count;
242 result.lfsBytes = bytes;
243 }
244 }
245
246 // ------------------------------------------------------------ cleanup
247 await o.progress({ phase: "cleanup", message: "Removing temporary refs" }, true);
248 await pusher.deleteTempRefs();
249 result.secretFindings = await pusher.findingsWithPaths();
250 await o.progress({ phase: "done", message: `Imported ${result.branches} branches, ${result.tags} tags and ${result.pullRefs} pull request refs`, chunks: result.chunks, bytes: result.bytes }, true);
251 return result;
252 } finally {
253 await rm(root, { recursive: true, force: true }).catch(() => {});
254 }
255}
256
257const lastLine = (s: string) =>
258 s
259 .trim()
260 .split("\n")
261 .filter((l) => l.trim())
262 .slice(-1)[0]
263 ?.slice(0, 300) ?? "";
264
265/** Count and size of the LFS objects in a bare repository's local store. */
266async function lfsStore(gitDir: string): Promise<{ count: number; bytes: number }> {
267 let count = 0;
268 let bytes = 0;
269 const walk = async (dir: string): Promise<void> => {
270 let entries;
271 try {
272 entries = await readdir(dir, { withFileTypes: true });
273 } catch {
274 return;
275 }
276 for (const e of entries) {
277 const p = path.join(dir, e.name);
278 if (e.isDirectory()) await walk(p);
279 else if (/^[0-9a-f]{64}$/.test(e.name)) {
280 count++;
281 bytes += (await stat(p)).size;
282 }
283 }
284 };
285 await walk(path.join(gitDir, "lfs", "objects"));
286 return { count, bytes };
287}
288
289/**
290 * Pushes history so that every request stays under the budget. The server keeps
291 * what it has under temporary refs, so later pushes (and a restarted import) only
292 * send what is missing.
293 */
294class ChunkPusher {
295 private haves: string[] = [];
296 private haveSet = new Set<string>();
297 private budget: number;
298 private temp = 0;
299 private tempRefs: string[] = [];
300 private findings = new Map<string, ImportSecretFinding>();
301
302 constructor(
303 private readonly git: GitRunner,
304 private readonly spec: ImportSpec,
305 private readonly pushOpts: string[],
306 private readonly o: TransferOptions,
307 private readonly result: ImportGitResult,
308 ) {
309 this.budget = Math.max(MIN_BUDGET, o.chunkBytes ?? spec.chunkBytes);
310 }
311
312 /** Objects the server already has (a resumed import) count as pushed. */
313 async loadRemoteState(): Promise<void> {
314 const r = await this.git.run(["ls-remote", this.spec.target.pushUrl], { allowFail: true });
315 if (r.code !== 0) throw new ImportError(`cannot reach the Diffinery repository: ${lastLine(r.stderr)}`);
316 const oids = [...new Set(r.stdout.split("\n").map((l) => l.split("\t")[0]!).filter((x) => /^[0-9a-f]{40}$/.test(x)))];
317 if (!oids.length) return;
318 const present = await this.git.run(["cat-file", "--batch-check=%(objectname) %(objecttype)"], { input: `${oids.join("\n")}\n` });
319 for (const line of present.stdout.split("\n")) {
320 const [oid, type] = line.split(" ");
321 if (type === "commit") this.addHave(oid!);
322 }
323 for (const line of r.stdout.split("\n")) {
324 const ref = line.split("\t")[1];
325 if (ref?.startsWith(TEMP_PREFIX)) this.tempRefs.push(ref);
326 }
327 this.temp = this.tempRefs.length;
328 }
329
330 private addHave(oid: string): void {
331 if (this.haveSet.has(oid)) return;
332 this.haveSet.add(oid);
333 this.haves.push(oid);
334 }
335
336 private notHaves(): string {
337 return this.haves.map((h) => `^${h}\n`).join("");
338 }
339
340 /** Bytes on disk of everything reachable from `tip` that the server doesn't have. */
341 async sizeOf(tip: string): Promise<number> {
342 const r = await this.git.run(["rev-list", "--objects", "--disk-usage", "--stdin"], { input: `${tip}\n${this.notHaves()}` });
343 return Number(r.stdout.trim()) || 0;
344 }
345
346 /** Make sure the server has `tip` and everything it needs. */
347 async ensurePushed(tip: string, label: string): Promise<void> {
348 if (this.haveSet.has(tip)) return;
349 let chain = await this.git.lines(["rev-list", "--first-parent", "--reverse", "--stdin"], `${tip}\n${this.notHaves()}`);
350 while (chain.length) {
351 const sizes = new Map<number, number>();
352 const best = await furthestWithin(
353 chain.length,
354 async (i) => {
355 const s = await this.sizeOf(chain[i]!);
356 sizes.set(i, s);
357 return s;
358 },
359 this.budget,
360 );
361 if (best) {
362 if (!(await this.pushTemp(chain[best.index]!, best.size, label))) continue;
363 chain = chain.slice(best.index + 1);
364 continue;
365 }
366 // One first-parent step is over budget: push the side branches it merges first,
367 // then, if it still is, its new objects in batches.
368 const commit = chain[0]!;
369 const parents = (await this.git.lines(["rev-list", "--parents", "-n", "1", commit]))[0]!.split(" ").slice(2);
370 for (const p of parents) await this.ensurePushed(p, label);
371 if ((await this.sizeOf(commit)) > this.budget) await this.pushObjectBatches(commit, label);
372 if (!(await this.pushTemp(commit, await this.sizeOf(commit), label))) continue;
373 chain = chain.slice(1);
374 }
375 }
376
377 /** Upload the blobs `commit` adds through throwaway commits of flat trees, so the commit itself stays small. */
378 private async pushObjectBatches(commit: string, label: string): Promise<void> {
379 const listed = await this.git.lines(["rev-list", "--objects", "--stdin"], `${commit}\n${this.notHaves()}`);
380 const oids = listed.map((l) => l.split(" ")[0]!);
381 const check = await this.git.lines(["cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize:disk)"], `${oids.join("\n")}\n`);
382 const blobs = check
383 .map((l) => l.split(" "))
384 .filter((p) => p[1] === "blob")
385 .map((p) => ({ oid: p[0]!, size: Number(p[2]) }));
386 const batches = batchBySize(blobs, this.budget);
387 for (const [i, batch] of batches.entries()) {
388 const tree = (await this.git.run(["mktree"], { input: batch.map((b, j) => `100644 blob ${b.oid}\tobject-${j}\n`).join("") })).stdout.trim();
389 const carrier = (await this.git.run(["commit-tree", tree, "-m", `Diffinery import: objects for ${commit.slice(0, 12)} (${i + 1}/${batches.length})`])).stdout.trim();
390 const size = batch.reduce((s, b) => s + b.size, 0);
391 while (!(await this.pushTemp(carrier, size, label))) {
392 /* budget shrank; the carrier is retried as is (a lone object can't be split further) */
393 }
394 }
395 }
396
397 /** Push one chunk to a temporary ref. Returns false when the budget was lowered and the caller should re-plan. */
398 private async pushTemp(oid: string, size: number, label: string): Promise<boolean> {
399 if (this.haveSet.has(oid)) return true;
400 const ref = `${TEMP_PREFIX}c${++this.temp}`;
401 const n = this.result.chunks + 1;
402 await this.o.progress({ phase: "push", message: `Pushing history of ${label.replace(/^refs\/(heads|tags)\//, "")}: chunk ${n} (${fmtBytes(size)})`, chunks: this.result.chunks, bytes: this.result.bytes });
403 const out = await this.push([`${oid}:${ref}`]);
404 if (out.code === 0 && !parsePorcelain(out.stdout).some((s) => !s.ok)) {
405 this.tempRefs.push(ref);
406 this.addHave(oid);
407 this.result.chunks++;
408 this.result.bytes += size;
409 return true;
410 }
411 const text = `${out.stdout}\n${out.stderr}`;
412 if (isSecretRejection(text)) {
413 for (const f of parseSecretFindings(out.stderr)) this.findings.set(`${f.kind}:${f.blob}`, f);
414 const found = await this.findingsWithPaths();
415 const what = found.map((f) => `${f.kind} in ${f.path ?? `blob ${f.blob}`}`).join("; ");
416 throw new SecretsFound(
417 this.spec.secrets === "bypass"
418 ? `Push protection found possible secrets in the repository's history (${what}), and only a human admin of the target repository can import them with a bypass.`
419 : `Push protection found possible secrets in the repository's history (${what}). The import was stopped as requested; rotate the secrets, or import again with the bypass option.`,
420 found,
421 );
422 }
423 if (isSizeRejection(text)) {
424 if (this.budget / 2 >= MIN_BUDGET) {
425 this.budget = Math.floor(this.budget / 2);
426 this.o.log(`push of ${fmtBytes(size)} was rejected (${lastLine(out.stderr)}); retrying with chunks of ${fmtBytes(this.budget)}`);
427 this.temp--;
428 return false;
429 }
430 // Chunking can no longer help. Say what the server answered: the object may be under the
431 // 100 MB push limit and still be refused (or the server may be failing for another reason).
432 const said = lastLine(out.stderr) || lastLine(out.stdout);
433 if (size > this.budget) {
434 throw new ImportError(`a single Git object of ${fmtBytes(size)} could not be pushed even on its own (${said}). Remove the large file from the repository's history (e.g. with git filter-repo) and import again.`);
435 }
436 throw new ImportError(`pushes kept failing even in chunks of ${fmtBytes(this.budget)} (${said})`);
437 }
438 throw new ImportError(`push failed: ${lastLine(out.stderr) || lastLine(out.stdout)}`);
439 }
440
441 /** `git push` to Diffinery in import mode; collects push protection findings from the output. */
442 async push(refspecs: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
443 const out = await this.git.run(["push", "--porcelain", "--no-verify", ...this.pushOpts, this.spec.target.pushUrl, ...refspecs], { allowFail: true });
444 if (/Push protection bypassed/i.test(out.stderr)) for (const f of parseSecretFindings(out.stderr)) this.findings.set(`${f.kind}:${f.blob}`, f);
445 return out;
446 }
447
448 async deleteTempRefs(): Promise<void> {
449 for (let i = 0; i < this.tempRefs.length; i += REF_BATCH) {
450 const batch = this.tempRefs.slice(i, i + REF_BATCH);
451 const out = await this.push(batch.map((r) => `:${r}`));
452 if (out.code !== 0) this.result.warnings.push(`Some temporary import refs could not be removed: ${lastLine(out.stderr)}`);
453 }
454 this.tempRefs = [];
455 }
456
457 /** Findings with a path where each blob appears, so people know what to rotate. */
458 async findingsWithPaths(): Promise<ImportSecretFinding[]> {
459 const out: ImportSecretFinding[] = [];
460 for (const f of [...this.findings.values()].slice(0, 100)) {
461 const full = (await this.git.run(["rev-parse", "--verify", "--quiet", `${f.blob}^{blob}`], { allowFail: true })).stdout.trim() || f.blob;
462 let where: string | undefined = f.path;
463 if (!where && /^[0-9a-f]{40}$/.test(full)) {
464 const raw = await this.git.run(["log", "--all", "-n", "1", `--find-object=${full}`, "--raw", "--no-abbrev", "--format="], { allowFail: true });
465 const line = raw.stdout.split("\n").find((l) => l.includes(full));
466 where = line?.split("\t").slice(-1)[0];
467 }
468 out.push({ kind: f.kind, blob: full, path: where });
469 }
470 return out;
471 }
472}
473
474function fmtBytes(n: number): string {
475 if (n < 1024) return `${n} B`;
476 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
477 return `${(n / 1024 / 1024).toFixed(1)} MB`;
478}