Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423import type { Did } from '@atcute/lexicons';import { Buffer } from 'buffer';import * as git from 'isomorphic-git';import type { GitHttpRequest, HttpClient, PromiseFsClient, TreeEntry } from 'isomorphic-git';import http from 'isomorphic-git/http/web';import { assertSecureKnot, TangledError } from './errors.ts';import { createMemoryFs } from './memfs.ts';import { normalizePath } from '../projects/paths.ts';import { withUserAgent } from '../user-agent.ts';import { config } from '../identity.ts';
// isomorphic-git 1.44 reads a global Buffer and throws "Missing Buffer dependency" in browsers, which have none.globalThis.Buffer ??= Buffer;
const dir = '/repo';const gitdir = '/repo/.git';
type GitRepo = { fs: PromiseFsClient; cache: object };type TreeItem = { mode: string; oid: string };
/** A tree entry importRepo left out, by its name in git. */export interface SkippedEntry { path: string; reason: 'symlink' | 'submodule' | 'path' | 'duplicate'; /** For `path`, which D18 rule the name breaks. For `duplicate`, the NFC path it collides with. */ detail?: string;}
export interface ImportedRepo { commit: string; /** Branch name, e.g. `main`. */ ref: string; /** Regular files by NFC path. */ files: Map<string, Uint8Array>; skipped: SkippedEntry[];}
export interface ImportOptions { ref?: string; /** Most tree entries to accept. Default 10,000. */ maxFiles?: number; /** Most bytes to download, and most file bytes to keep. Default 100 MB. */ maxBytes?: number; /** A `RepoTree.reuse` token: opens that exact clone instead of fetching `ref` again. */ reuse?: unknown;}
const MAX_FILES = 10_000;const MAX_BYTES = 100_000_000;
/** A branch, and which one is the remote's HEAD. */export interface RepoRefs { /** Branch name the remote's HEAD points at, e.g. `main`. */ head: string; /** Branch names, sorted. */ branches: string[];}
/** One clone of a commit. */type ClonedTip = { local: GitRepo; commit: string; ref: string };
/** * Clones not consumed by `importRepo` within a few picks, by an opaque token, so the import picker's * tree browse and the open that follows it share one fetch without every `importRepo` call (pushChanges * included) silently reusing a clone that may no longer be the remote's tip. A size cap, not a timer: * the picker holds at most one `RepoTree` live at a time. */const reusable = new Map<object, ClonedTip>();const REUSABLE_LIMIT = 4;
function remember(tip: ClonedTip): object { const token = {}; reusable.set(token, tip); if (reusable.size > REUSABLE_LIMIT) reusable.delete(reusable.keys().next().value!); return token;}
/** Fetches `ref` (default: remote HEAD) fresh, resolving which branch and commit it named. */async function cloneTip(repo: { cloneUrl: string; repoDid?: Did }, ref: string | undefined, maxBytes: number): Promise<ClonedTip> { const local = await fetchReadTip(repo, ref, maxBytes); const { fs } = local; const resolvedRef = ref ?? (await git.currentBranch({ fs, gitdir })); if (!resolvedRef) throw new TangledError('bad-ref', `${repo.cloneUrl} has no default branch`); const commit = await git.resolveRef({ fs, gitdir, ref: `refs/remotes/origin/${resolvedRef}` }); return { local, commit, ref: resolvedRef };}
/** * The remote's default branch and its branch list, from one `info/refs` request — no clone. Verified * CORS-open and symref-bearing against knot1.tangled.sh (2026-11-06): protocol v2's `symrefs` returns * `HEAD`'s target alongside every `refs/heads/*`, so this is the cheapest way to show "main (default)" * and a branch picker before committing to a fetch. */export async function listBranches(repo: { cloneUrl: string; repoDid?: Did }): Promise<RepoRefs> { const gatewayUrl = config.gateway && repo.repoDid ? `${config.gateway.replace(/\/$/, '')}/git/${repo.repoDid}` : undefined; let refs: git.ServerRef[]; try { refs = await git.listServerRefs({ http: namedHttp, url: gatewayUrl ?? repo.cloneUrl, symrefs: true }); } catch (error) { if (!gatewayUrl || !gatewayFailed(error)) throw toTangledError(error); refs = await git.listServerRefs({ http: namedHttp, url: repo.cloneUrl, symrefs: true }); } const head = refs.find((r) => r.ref === 'HEAD')?.target?.replace(/^refs\/heads\//, ''); if (!head) throw new TangledError('bad-ref', `${repo.cloneUrl} has no default branch`); const branches = refs.filter((r) => r.ref.startsWith('refs/heads/')).map((r) => r.ref.slice('refs/heads/'.length)).sort(); return { head, branches };}
export interface RepoTree { ref: string; commit: string; /** Directory paths under NFC, root included as `""`. Files are left out: the import picker only roots a project at a directory. */ dirs: string[]; /** Pass as `importRepo`'s `reuse` to open this exact clone without fetching `ref` again. */ reuse: unknown;}
/** The directories of one commit of `ref` (default: remote HEAD). Its `reuse` token lets `importRepo` skip refetching. */export async function listTree(repo: { cloneUrl: string; repoDid?: Did }, options: { ref?: string; maxBytes?: number } = {}): Promise<RepoTree> { const maxBytes = options.maxBytes ?? MAX_BYTES; const tip = await cloneTip(repo, options.ref, maxBytes); const { local, commit, ref } = tip; const dirs = ['']; const walk = async (oid: string, prefix: string) => { for (const entry of await readRawTree(local, oid)) { if (entry.mode !== '40000') continue; let path: string; try { path = normalizePath(prefix + entry.name); } catch { continue; // Not a name a project path could hold either; importRepo will report it under files. } dirs.push(path); await walk(entry.oid, `${path}/`); } }; const { commit: parsed } = await git.readCommit({ fs: local.fs, gitdir, oid: commit, cache: local.cache }); await walk(parsed.tree, ''); return { ref, commit, dirs, reuse: remember(tip) };}
/** * Fetches one commit of `ref` (default: the remote HEAD) and returns its regular files under NFC paths. * Symlinks, submodules, and names a project path cannot hold are skipped and listed in `skipped`. * With a gateway configured and a `repoDid`, the fetch goes through the gateway, and straight to * `cloneUrl` only when the gateway fails. With `reuse` (from a prior `listTree`), opens that clone * instead of fetching again; any other value for `reuse` (expired, or from a different repo) just * falls back to a fresh fetch. */export async function importRepo(repo: { cloneUrl: string; repoDid?: Did }, options: ImportOptions = {}): Promise<ImportedRepo> { const maxFiles = options.maxFiles ?? MAX_FILES; const maxBytes = options.maxBytes ?? MAX_BYTES; const held = typeof options.reuse === 'object' && options.reuse !== null ? reusable.get(options.reuse) : undefined; if (options.reuse) reusable.delete(options.reuse as object); const { local, commit, ref } = held ?? (await cloneTip(repo, options.ref, maxBytes)); const { fs, cache } = local; const files = new Map<string, Uint8Array>(); const skipped: SkippedEntry[] = []; let total = 0; for (const [name, item] of await readTreeItems(local, commit, maxFiles)) { const special = SPECIAL_MODES[item.mode]; if (special) { skipped.push({ path: name, reason: special }); continue; } let path: string; try { path = normalizePath(name); } catch (error) { skipped.push({ path: name, reason: 'path', detail: (error as Error).message }); continue; } if (files.has(path)) { skipped.push({ path: name, reason: 'duplicate', detail: path }); continue; } const { blob } = await git.readBlob({ fs, gitdir, oid: item.oid, cache }); total += blob.byteLength; if (total > maxBytes) throw new TangledError('too-large', `${repo.cloneUrl} has over ${maxBytes} bytes of files`); files.set(path, blob); } return { commit, ref, files, skipped };}
const SPECIAL_MODES: Record<string, 'symlink' | 'submodule'> = { '120000': 'symlink', '160000': 'submodule' };
export interface PushChangesInput { repo: { cloneUrl: string }; /** The commit the changes were made against; becomes the new commit's parent. */ base: { commit: string; ref: string }; /** Overlay on the base tree: added or replaced files. */ files: Map<string, Uint8Array>; /** Base paths to delete. Applied before `files`. */ removed?: Iterable<string>; message: string; author: { name: string; email: string; timestamp?: number; timezoneOffset?: number }; /** A service-auth JWT, or a function that mints one; called only when the knot asks for auth. */ token: string | (() => Promise<string>); /** Branch to update. Defaults to `base.ref`. */ ref?: string;}
/** Commits an overlay on top of `base` and pushes it. Returns the new commit oid. */export async function pushChanges(input: PushChangesInput): Promise<{ commit: string; ref: string }> { assertSecureKnot(input.repo.cloneUrl); const ref = input.ref ?? input.base.ref; const local = await fetchTip(input.repo.cloneUrl, input.base.ref); const { fs, cache } = local; const tip = await git.resolveRef({ fs, gitdir, ref: `refs/remotes/origin/${input.base.ref}` }); if (tip !== input.base.commit) { throw new TangledError('remote-moved', `${input.base.ref} is at ${tip}, not ${input.base.commit}`); }
const items = await readTreeItems(local, input.base.commit); // Overlay paths are NFC, as importRepo returned them; edit the entry git names, so a change keeps its spelling and mode. const names = new Map<string, string>(); for (const name of items.keys()) names.set(name.normalize('NFC'), name); const nameOf = (path: string) => names.get(path) ?? path; for (const path of input.removed ?? []) items.delete(nameOf(path)); for (const [path, bytes] of input.files) { const name = nameOf(path); const oid = await git.writeBlob({ fs, gitdir, blob: bytes }); const mode = items.get(name)?.mode; items.set(name, { mode: mode === '100755' ? mode : '100644', oid }); } const tree = await writeTreeItems(local, items);
const timestamp = input.author.timestamp ?? Math.floor(Date.now() / 1000); const signature = { name: input.author.name, email: input.author.email, timestamp, timezoneOffset: input.author.timezoneOffset ?? 0 }; const commit = await git.writeCommit({ fs, gitdir, commit: { tree, parent: [input.base.commit], author: signature, committer: signature, message: input.message }, }); await git.writeRef({ fs, gitdir, ref: `refs/heads/${ref}`, value: commit, force: true });
let result: git.PushResult; try { result = await git.push({ fs, http: namedHttp, gitdir, cache, url: input.repo.cloneUrl, ref, remoteRef: ref, onAuth: async () => ({ username: 'x-access-token', password: typeof input.token === 'string' ? input.token : await input.token(), }), onAuthFailure: () => ({ cancel: true }), }); } catch (error) { throw toTangledError(error); } const status = result.refs[`refs/heads/${ref}`]; if (!result.ok || (status && !status.ok)) { throw new TangledError('push-rejected', `push to ${ref} rejected: ${status?.error ?? result.error}`); } return { commit, ref };}
/** A read-only fetch: the gateway's `/git/<repo DID>` first, then `cloneUrl` once if the gateway did not answer. */async function fetchReadTip(repo: { cloneUrl: string; repoDid?: Did }, ref: string | undefined, maxBytes: number): Promise<GitRepo> { if (!config.gateway || !repo.repoDid) return fetchTip(repo.cloneUrl, ref, maxBytes); try { return await fetchTip(`${config.gateway.replace(/\/$/, '')}/git/${repo.repoDid}`, ref, maxBytes, config.timeoutMs); } catch (error) { if (!gatewayFailed(error)) throw error; return fetchTip(repo.cloneUrl, ref, maxBytes); }}
/** A failure the direct path might not share: no answer, no headers in time, or a 5xx. */function gatewayFailed(error: unknown): boolean { if (error instanceof TangledError) return false; const { code, name, data } = error as { code?: string; name?: string; data?: { statusCode?: number } }; if (code === 'HttpError') return (data?.statusCode ?? 0) >= 500; return name === 'TypeError' || name === 'AbortError' || name === 'TimeoutError';}
async function fetchTip(url: string, ref: string | undefined, maxBytes = MAX_BYTES, headersTimeoutMs?: number): Promise<GitRepo> { const repo = { fs: createMemoryFs(), cache: {} }; const capped = cappedHttp(maxBytes, headersTimeoutMs); try { await git.clone({ ...repo, http: capped, dir, url, ref, depth: 1, singleBranch: true, noCheckout: true, noTags: true }); } catch (error) { // isomorphic-git reports some body errors as its own (an empty response), so check the cap first. if (capped.over) throw new TangledError('too-large', `fetching ${url} passed ${maxBytes} bytes`, { cause: error }); throw toTangledError(error); } return repo;}
/** isomorphic-git's web client, sending our User-Agent outside a browser. */const namedHttp: HttpClient = { request: (request) => http.request({ ...request, headers: Object.fromEntries(withUserAgent(request.headers)) }),};
/** * `namedHttp`, cutting off the body once the responses for one fetch pass `maxBytes`. With * `headersTimeoutMs`, a response whose headers take longer is aborted; a body may still stream for as * long as it needs. */function cappedHttp(maxBytes: number, headersTimeoutMs?: number): HttpClient & { over: boolean } { let received = 0; const client = { over: false, async request(request: GitHttpRequest) { const response = await withHeadersTimeout(request, headersTimeoutMs); const body = response.body; if (!body) return response; return { ...response, body: (async function* () { for await (const chunk of body) { received += chunk.byteLength; if (received > maxBytes) { client.over = true; throw new TangledError('too-large', `the response passed ${maxBytes} bytes`); } yield chunk; } })(), }; }, }; return client;}
async function withHeadersTimeout(request: GitHttpRequest, timeoutMs: number | undefined) { if (timeoutMs === undefined) return namedHttp.request(request); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(new DOMException('no response headers in time', 'TimeoutError')), timeoutMs); try { return await namedHttp.request({ ...request, signal: controller.signal }); } finally { clearTimeout(timer); }}
function toTangledError(error: unknown): unknown { if (error instanceof TangledError) return error; const code = (error as { code?: string }).code; if (code === 'UserCanceledError') return new TangledError('auth-failed', 'the knot rejected the push token', { cause: error }); if (code === 'HttpError' && (error as { data?: { statusCode?: number } }).data?.statusCode === 401) { return new TangledError('auth-failed', 'the knot requires a push token', { cause: error }); } if (code === 'PushRejectedError') return new TangledError('push-rejected', (error as Error).message, { cause: error }); if (code === 'UnsafeFilepathError') { const name = (error as { data?: { filepath?: string } }).data?.filepath; return new TangledError('bad-change', `cannot push a tree holding ${JSON.stringify(name)}, a name isomorphic-git refuses`, { cause: error }); } if (code === 'NotFoundError') return new TangledError('bad-ref', (error as Error).message, { cause: error }); return error;}
async function readTreeItems({ fs, cache }: GitRepo, commit: string, maxFiles = Infinity): Promise<Map<string, TreeItem>> { const items = new Map<string, TreeItem>(); const walk = async (oid: string, prefix: string) => { for (const entry of await readRawTree({ fs, cache }, oid)) { const path = prefix + entry.name; if (entry.mode === '40000') await walk(entry.oid, path + '/'); else items.set(path, { mode: entry.mode, oid: entry.oid }); if (items.size > maxFiles) throw new TangledError('too-large', `the tree has over ${maxFiles} files`); } }; const { commit: parsed } = await git.readCommit({ fs, gitdir, oid: commit, cache }); await walk(parsed.tree, ''); return items;}
/** * A tree's entries, parsed here because isomorphic-git's readTree throws on any name it deems unsafe, such * as one with a backslash, and loses the whole directory. importRepo decides per name instead. */async function readRawTree({ fs, cache }: GitRepo, oid: string): Promise<{ mode: string; name: string; oid: string }[]> { const { object } = await git.readObject({ fs, gitdir, oid, format: 'content', cache }); const bytes = object as Uint8Array; const entries = []; let at = 0; while (at < bytes.length) { const space = bytes.indexOf(0x20, at); const nul = bytes.indexOf(0, space); if (space < 0 || nul < 0 || nul + 21 > bytes.length) throw new TangledError('bad-ref', `tree ${oid} is malformed`); entries.push({ mode: decoder.decode(bytes.subarray(at, space)), name: decoder.decode(bytes.subarray(space + 1, nul)), oid: hex(bytes.subarray(nul + 1, nul + 21)), }); at = nul + 21; } return entries;}
const decoder = new TextDecoder();const hex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
async function writeTreeItems({ fs }: GitRepo, items: Map<string, TreeItem>): Promise<string> { type Dir = { files: Map<string, TreeItem>; dirs: Map<string, Dir> }; const root: Dir = { files: new Map(), dirs: new Map() }; for (const [path, item] of items) { const segments = path.split('/'); const name = segments.pop()!; let node = root; for (const segment of segments) { if (node.files.has(segment)) throw new TangledError('bad-change', `${path} is inside the file ${segment}`); let child = node.dirs.get(segment); if (!child) node.dirs.set(segment, (child = { files: new Map(), dirs: new Map() })); node = child; } if (node.dirs.has(name)) throw new TangledError('bad-change', `${path} is a directory`); node.files.set(name, item); } const write = async (node: Dir): Promise<string> => { const tree: TreeEntry[] = []; for (const [path, item] of node.files) tree.push({ path, mode: item.mode, oid: item.oid, type: item.mode === '160000' ? 'commit' : 'blob' }); for (const [path, child] of node.dirs) tree.push({ path, mode: '040000', oid: await write(child), type: 'tree' }); return git.writeTree({ fs, gitdir, tree }); }; return write(root);}