import 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; 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(); 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 { 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 { 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 { 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 { 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(); 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 = { '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; /** Base paths to delete. Applied before `files`. */ removed?: Iterable; 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); /** 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(); 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/` first, then `cloneUrl` once if the gateway did not answer. */ async function fetchReadTip(repo: { cloneUrl: string; repoDid?: Did }, ref: string | undefined, maxBytes: number): Promise { 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 { 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> { const items = new Map(); 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): Promise { type Dir = { files: Map; dirs: Map }; 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 => { 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); }