/** * Read-only browsing of a hosted git repository for the account page: refs, * directory listings, and file contents, served straight from the bundle * chain in blob storage via the pack object reader. Workers-clean; all PDS * access goes through an injected fetch, typically the server's own fetch. * * A chain whose bundles all carry an object index is read lazily: the * reader downloads the small index blobs, then fetches single objects with * ranged blob reads. A chain without indexes downloads whole and unpacks in * memory, which is also the fallback when a server ignores Range requests' * semantics entirely (a 200 answer still resolves, sliced locally). */ import { parseBundle } from './bundle.js'; import { diffLines } from './diff.js'; import { GIT_REPO_COLLECTION } from './lexicon.js'; import { commitTree, indexPacks, parseCommit, parseTree } from './objects.js'; import { concatChunks, indexPartsOf, parseRepoRecord } from './record.js'; import { createLazyStore } from './store.js'; // A page that shows a file from a repository needs to know a pointer from a // picture, and this is the module such a page already reads. export { lfsPointer } from './lfs.js'; /** @typedef {import('./lfs.js').LfsPointer} LfsPointer */ /** @typedef {import('./store.js').ObjectStore} ObjectStore */ /** Text preview cap; larger files report size only. */ const MAX_PREVIEW_BYTES = 512 * 1024; /** * How far a whole-tree listing walks. The walk runs over the pack index * rather than file bodies, so this is a guard against a pathological * repository rather than a budget. */ const MAX_LISTED_FILES = 50000; /** * The widest look-ahead a path walk grows to. A run this size resolves the * path for every commit in it, so the cap bounds what one round trip reads. */ const MAX_RUN = 4096; /** How far back a path's history is walked before it is called truncated. */ const PATH_WALK_LIMIT = 4000; /** * Image media types the browser serves inline, by lowercase extension. SVG is * absent on purpose: it can carry script, and the account page serves these * bytes from its own origin. */ const IMAGE_MEDIA_TYPES = { apng: 'image/apng', avif: 'image/avif', bmp: 'image/bmp', gif: 'image/gif', ico: 'image/x-icon', jpeg: 'image/jpeg', jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp', }; /** * Direct storage access for repositories held in a permissioned space. The * account page is the owner's own surface, so the platform reads space * records and blobs from storage rather than through the authenticated * space endpoints. * @typedef {Object} SpaceReader * @property {(space: string, rkey: string) => Promise<{cid: string, value: unknown}|null>} getRecord * @property {(cid: string) => Promise} getBlob * @property {(cid: string, start: number, end: number) => Promise} [getBlobRange] - * Optional: one byte range of a blob, end exclusive. A reader that * answers ranges lets a space repository read lazily, as a public one * does. Absent, the chain is read whole. */ /** * @typedef {Object} GitBrowserContext * @property {(request: Request) => Promise} xrpc * @property {SpaceReader} [spaceReader] * @property {string} [collection] * @property {(progress: {loaded: number, total: number}) => void} [onProgress] - * called as an open's downloads arrive, so a caller can show a repository * being read. The total comes from the record and is known before the * first byte: the whole chain for an eager open, the index blobs for a * lazy one. * @property {number} [cacheSize] - how many opened repositories to keep (default 1). A server answering for many repositories keeps a few. * @property {number} [maxEagerBytes] - the largest bundle chain to download whole. A repository without indexes past it throws an error with code RepositoryTooLarge rather than opening. Absent, any size opens. */ /** * @param {Uint8Array} bytes * @returns {boolean} whether the content looks binary (NUL in the first 8KB) */ function looksBinary(bytes) { const probe = bytes.subarray(0, 8192); return probe.includes(0); } /** * @param {Map} objects * @returns {ObjectStore} */ function createEagerStore(objects) { return { meta(sha) { const object = objects.get(sha); return object ? { type: object.type, size: object.data.length } : null; }, peek(sha) { return objects.get(sha) ?? null; }, async get(sha) { return objects.get(sha) ?? null; }, async getMany() {}, async prefetchCommits() { /** @type {string[]} */ const shas = []; for (const [sha, object] of objects) { if (object.type === 'commit' || object.type === 'tag') shas.push(sha); } return shas; }, }; } /** * A tag chain peeled down to its commit id. * @param {ObjectStore} store * @param {string} sha * @returns {Promise} */ async function peelToCommit(store, sha) { let current = sha; for (let hops = 0; hops < 10; hops++) { const meta = store.meta(current); if (!meta) throw new Error(`object ${current} not found in packs`); if (meta.type === 'commit') return current; if (meta.type !== 'tag') { throw new Error(`ref points at a ${meta.type}, not a commit`); } const tag = await store.get(current); if (!tag) throw new Error(`object ${current} not found in packs`); const text = new TextDecoder().decode(tag.data); const match = /^object ([0-9a-f]{40})/m.exec(text); if (!match) throw new Error('tag has no object line'); current = match[1]; } throw new Error('tag chain too deep'); } /** * A ref target peeled down to the tree it names. * @param {ObjectStore} store * @param {string} sha * @returns {Promise} */ async function peelToTree(store, sha) { if (store.meta(sha)?.type === 'tree') return sha; const commitSha = await peelToCommit(store, sha); const commit = await store.get(commitSha); if (!commit) throw new Error(`object ${commitSha} not found in packs`); return commitTree(commit.data); } /** * One side of a file change, read out of the object store. An absent entry * reads as empty text with no size, which is how an added or deleted file * gets a diff against nothing. A submodule has no blob in the pack, so its * text is null and it reports no hunks. A blob past the preview cap keeps * its size and skips its bytes; without them the binary probe answers * false, unless the bytes are already in memory. * * @typedef {Object} BlobSide * @property {string|null} text * @property {number|null} size * @property {boolean} binary * @property {Uint8Array|null} bytes * * @param {ObjectStore} store * @param {import('./objects.js').TreeEntry|null} entry * @returns {BlobSide} */ function blobContent(store, entry) { if (!entry) return { text: '', size: null, binary: false, bytes: null }; if (entry.type === 'submodule') { return { text: null, size: null, binary: false, bytes: null }; } const meta = store.meta(entry.sha); if (meta?.type !== 'blob') { return { text: null, size: null, binary: false, bytes: null }; } const object = store.peek(entry.sha); if (!object) { return { text: null, size: meta.size, binary: false, bytes: null }; } const binary = looksBinary(object.data); return { text: binary ? null : new TextDecoder().decode(object.data), size: object.data.length, binary, bytes: object.data, }; } /** * The entries of a tree by name. A missing or non-tree id reads as empty, * which is how the root commit compares against nothing. * @param {ObjectStore} store * @param {string|null} treeSha * @returns {Promise>} */ async function entriesByName(store, treeSha) { /** @type {Map} */ const entries = new Map(); if (!treeSha) return entries; const tree = await store.get(treeSha); if (tree?.type !== 'tree') return entries; for (const entry of parseTree(tree.data)) entries.set(entry.name, entry); return entries; } /** * Record one side of a path's change, keeping whatever the other side put * there. * @param {Map} changes * @param {string} path * @param {'from'|'to'} side * @param {import('./objects.js').TreeEntry} entry */ function recordSide(changes, path, side, entry) { const change = changes.get(path) ?? { from: null, to: null }; change[side] = entry; changes.set(path, change); } /** * Collect the paths that differ between two trees. Two subtrees with the * same object id hold the same bytes, so the walk skips them without * reading either one. The walk goes level by level, each level's trees * fetched in one batch. A pair with one side null lists everything under * the other side, which serves added and deleted directories. * @param {ObjectStore} store * @param {string|null} oldTree * @param {string|null} newTree * @param {Map} changes */ async function walkTreeDiff(store, oldTree, newTree, changes) { let level = [{ from: oldTree, to: newTree, prefix: '' }]; while (level.length > 0) { /** @type {string[]} */ const treeShas = []; for (const pair of level) { if (pair.from) treeShas.push(pair.from); if (pair.to) treeShas.push(pair.to); } await store.getMany(treeShas); /** @type {typeof level} */ const next = []; for (const { from: oldSha, to: newSha, prefix } of level) { if (oldSha === newSha) continue; const before = await entriesByName(store, oldSha); const after = await entriesByName(store, newSha); for (const name of new Set([...before.keys(), ...after.keys()])) { const from = before.get(name) ?? null; const to = after.get(name) ?? null; const path = prefix ? `${prefix}/${name}` : name; if (from && to && from.sha === to.sha && from.mode === to.mode) { continue; } const fromDir = from?.type === 'dir'; const toDir = to?.type === 'dir'; if (fromDir || toDir) { // A name that is a directory on one side and a file on the other // becomes every file under the directory plus the file itself. next.push({ from: fromDir ? /** @type {string} */ (from?.sha) : null, to: toDir ? /** @type {string} */ (to?.sha) : null, prefix: path, }); } if (from && !fromDir) recordSide(changes, path, 'from', from); if (to && !toDir) recordSide(changes, path, 'to', to); } } level = next; } } /** * @param {string} path * @returns {string|null} the image media type for the file extension, or null * for any other file */ function imageMediaType(path) { const name = path.slice(path.lastIndexOf('/') + 1).toLowerCase(); const dot = name.lastIndexOf('.'); if (dot <= 0) return null; return ( /** @type {Record} */ (IMAGE_MEDIA_TYPES)[ name.slice(dot + 1) ] ?? null ); } /** * Turn a tree diff into the file list a diff view renders: statuses, modes, * sizes and unified hunks. A file past the preview cap, a binary file and a * submodule report null hunks rather than their contents. * @param {ObjectStore} store * @param {Map} changes */ async function buildFiles(store, changes) { // Every blob the diff shows, in one batch. A side past the preview cap // diffs as null hunks, so its bytes stay unfetched. /** @type {string[]} */ const blobShas = []; for (const { from, to } of changes.values()) { for (const entry of [from, to]) { if (!entry || entry.type === 'submodule') continue; const meta = store.meta(entry.sha); if (meta?.type === 'blob' && meta.size <= MAX_PREVIEW_BYTES) { blobShas.push(entry.sha); } } } await store.getMany(blobShas); return [...changes.entries()] .sort((a, b) => a[0].localeCompare(b[0])) .map(([path, { from, to }]) => { const before = blobContent(store, from); const after = blobContent(store, to); const oversized = (before.size ?? 0) > MAX_PREVIEW_BYTES || (after.size ?? 0) > MAX_PREVIEW_BYTES; const hunks = before.text === null || after.text === null || oversized ? null : diffLines(before.text, after.text); let additions = 0; let deletions = 0; for (const hunk of hunks ?? []) { for (const line of hunk.lines) { if (line.type === 'add') additions++; else if (line.type === 'delete') deletions++; } } const mediaType = imageMediaType(path); return { path, status: /** @type {'added'|'deleted'|'modified'} */ ( !from ? 'added' : !to ? 'deleted' : 'modified' ), oldMode: from?.mode ?? null, newMode: to?.mode ?? null, oldSize: before.size, newSize: after.size, binary: before.binary || after.binary, mediaType, oldImage: mediaType ? before.bytes : null, newImage: mediaType ? after.bytes : null, hunks, // The page colours each side whole. A hunk is a fragment, and a // grammar that reads ahead, as highlight.js does for JSX, prints // a tag it cannot see the end of plain. oldText: hunks ? before.text : null, newText: hunks ? after.text : null, additions, deletions, }; }); } /** * How far back a base's ancestry is marked before a range walk gives up on * excluding it. A change deeper than this is outside personal scale, and the * walk then reports what it reached rather than nothing. */ const MAX_EXCLUDE = 10_000; /** How many of a change's commits a range read counts before it stops. */ const MAX_RANGE_COMMITS = 1_000; /** * The commits one change carries: reachable from the tip, not from the base. * * Both walks run over commits already in memory, so the cost is the prefetch * and nothing after it. * @param {ObjectStore} store * @param {string|null} base * @param {string} tip * @param {number} limit */ async function walkRange(store, base, tip, limit) { await store.prefetchCommits(); /** @param {string} sha */ const info = (sha) => { const object = store.peek(sha); if (object?.type !== 'commit') return null; return parseCommit(object.data); }; // Everything the base reaches, which the change by definition does not. const excluded = new Set(); if (base) { const frontier = [base]; while (frontier.length > 0 && excluded.size < MAX_EXCLUDE) { const sha = /** @type {string} */ (frontier.pop()); if (excluded.has(sha)) continue; excluded.add(sha); for (const parent of info(sha)?.parents ?? []) frontier.push(parent); } } /** @type {string[]} */ const frontier = [tip]; const seen = new Set(frontier); const commits = []; while (frontier.length > 0 && commits.length < limit) { // Newest first, the way a log reads. let next = 0; for (let i = 1; i < frontier.length; i++) { const here = info(frontier[i]); const best = info(frontier[next]); if ((here?.committedAt ?? 0) > (best?.committedAt ?? 0)) next = i; } const sha = frontier.splice(next, 1)[0]; if (excluded.has(sha)) continue; const commit = info(sha); if (!commit) continue; commits.push({ sha, parents: commit.parents, author: commit.author, authoredAt: commit.authoredAt, committedAt: commit.committedAt, message: commit.message, }); for (const parent of commit.parents) { if (!seen.has(parent)) { seen.add(parent); frontier.push(parent); } } } return { commits, truncated: frontier.some((sha) => !excluded.has(sha)) }; } /** * The tree diff between two commits. A null base compares against the empty * tree, which lists everything the tip holds. * @param {ObjectStore} store * @param {string|null} base * @param {string} tip */ async function rangeChanges(store, base, tip) { /** @param {string} sha */ const treeOf = async (sha) => { const object = await store.get(sha); if (object?.type !== 'commit') throw new Error(`no such commit: ${sha}`); return commitTree(object.data); }; /** @type {Map} */ const changes = new Map(); await walkTreeDiff( store, base ? await treeOf(base) : null, await treeOf(tip), changes, ); return changes; } /** * The commits that changed one path, walking back from a commit, newest * first. A commit changed the path when what stands there differs from what * stood there in every one of its parents. A commit that matches one of them * changed nothing, and the walk carries on down that parent, which is what * credits a merge's change to the branch it came from. After a change the * walk carries on down the first parent, for the change before it. * * The walk reads trees rather than diffs, and remembers what it finds: a * path untouched for a hundred commits resolves to the same object in all of * them, so a change costs a handful of reads however far back it was made. * @param {import('./store.js').ObjectStore} store * @param {string} start - the commit the walk begins at * @param {string} path * @param {number} limit - commits to walk before giving up * @param {number} want - changes to collect before stopping * @returns {Promise<{commits: Array, truncated: boolean}>} * truncated where the walk stopped with history still to read */ async function pathHistory(store, start, path, limit, want) { const segments = path.split('/').filter(Boolean); /** What stands at the path in one commit, by the commit's own id. */ /** @type {Map} */ const standing = new Map(); /** Entries by tree, so a tree shared by many commits is read once. */ /** @type {Map} */ const parsed = new Map(); const entriesOf = (/** @type {string} */ treeSha) => { let entries = parsed.get(treeSha); if (!entries) { const object = store.peek(treeSha); entries = object?.type === 'tree' ? parseTree(object.data) : []; parsed.set(treeSha, entries); } return entries; }; /** * Resolve the path for a run of commits at once, a level of the path at a * time. Every commit's root tree is read in one coalesced batch, then every * directory below it, so the walk costs a read per level rather than a * read per commit per level. * @param {string[]} commitShas */ const resolve = async (commitShas) => { const wanted = commitShas.filter((sha) => !standing.has(sha)); if (wanted.length === 0) return; await store.getMany(wanted); /** Where each commit has got to, as the walk descends. */ /** @type {Map} */ let here = new Map(); for (const sha of wanted) { const object = store.peek(sha); if (object?.type === 'commit') here.set(sha, commitTree(object.data)); else standing.set(sha, null); } for (const segment of segments) { await store.getMany(new Set(here.values())); /** @type {Map} */ const next = new Map(); for (const [sha, treeSha] of here) { const found = entriesOf(treeSha).find((e) => e.name === segment); if (found) next.set(sha, found.sha); else standing.set(sha, null); } here = next; } for (const [sha, found] of here) standing.set(sha, found); }; /** @type {Array} */ const found = []; const done = (/** @type {boolean} */ truncated) => ({ commits: found, truncated, }); /** * How many commits to look ahead down the first parents at a time. Each * run costs one round trip per level of the path, so the run doubles as * the walk goes deeper. A path changed near the tip pays one small run; a * path changed at the root costs a round trip per doubling rather than one * per 64 commits. */ let runSize = 64; let current = start; let walked = 0; while (current && walked < limit) { // The commits are all in memory, so the run costs no reads to collect; // only resolving the path for it does. /** @type {string[]} */ const run = []; let cursor = current; while (cursor && run.length < runSize) { run.push(cursor); const object = store.peek(cursor) ?? (await store.get(cursor)); if (object?.type !== 'commit') break; cursor = parseCommit(object.data).parents[0] ?? ''; } await resolve(cursor ? [...run, cursor] : run); let redirected = false; for (let index = 0; index < run.length && walked < limit; index++) { const sha = run[index]; walked++; const object = store.peek(sha); if (object?.type !== 'commit') return done(false); const commit = parseCommit(object.data); const here = standing.get(sha) ?? null; // The path is absent here, so nothing older can have changed it. if (here === null) return done(false); // A commit that leaves the path as one of its parents left it did not // change it, and the walk carries on down that parent. Only a commit // that differs from all of them made the change. That is what credits // a merge's change to the commit on the branch it came from rather // than to the merge. let unchanged = null; for (const parent of commit.parents) { if (!standing.has(parent)) await resolve([parent]); if (standing.get(parent) === here) { unchanged = parent; break; } } if (unchanged === null) { found.push({ ...commit, sha }); const before = commit.parents[0] ?? ''; if (found.length >= want) return done(Boolean(before)); if (!before) return done(false); current = before; } else { current = unchanged; } // A merge sent the walk down a parent this run does not hold, so the // next run starts from there. if (current !== run[index + 1]) { redirected = true; break; } } if (!redirected && run.length < runSize) return done(false); runSize = Math.min(runSize * 2, MAX_RUN); } return done(walked >= limit); } /** * @param {GitBrowserContext} ctx */ export function createGitBrowser(ctx) { if (!ctx || typeof ctx.xrpc !== 'function') { throw new Error('createGitBrowser requires an xrpc fetch function'); } const collection = ctx.collection ?? GIT_REPO_COLLECTION; /** @param {string} path */ const xrpcGet = (path) => ctx.xrpc(new Request(`https://pds.internal${path}`)); /** * @typedef {{cid: string, repo: import('./record.js').GitRepoRecord, store: ObjectStore}} Opened */ // Opened repositories by name and space, each keyed by record cid so a // push invalidates it. Insertion order is the eviction order: a hit // re-inserts, so the oldest untouched entry leaves first. /** @type {Map} */ const cache = new Map(); const cacheSize = Math.max(1, ctx.cacheSize ?? 1); /** * @param {string} did * @param {string} cid * @returns {Promise} */ async function fetchBlob(did, cid) { const params = new URLSearchParams({ did, cid }); const res = await xrpcGet(`/xrpc/com.atproto.sync.getBlob?${params}`); if (!res.ok) throw new Error(`bundle blob ${cid} unavailable`); return new Uint8Array(await res.arrayBuffer()); } /** * One byte range of a blob. A 206 answers the range itself; a server * that ignores Range answers 200 with the whole blob, sliced here so * both behave the same. * @param {string} did * @param {string} cid * @param {number} start * @param {number} end - exclusive * @returns {Promise} */ async function fetchBlobRange(did, cid, start, end) { const params = new URLSearchParams({ did, cid }); const res = await ctx.xrpc( new Request( `https://pds.internal/xrpc/com.atproto.sync.getBlob?${params}`, { headers: { range: `bytes=${start}-${end - 1}` }, }, ), ); if (!res.ok) throw new Error(`bundle blob ${cid} unavailable`); const bytes = new Uint8Array(await res.arrayBuffer()); if (res.status === 206) return bytes; return bytes.subarray(start, end); } /** * Blob reads for one open. A public repository reads through the sync * endpoints; a space repository reads its own storage through the space * reader, which answers ranges only when the platform gave it a * getBlobRange. * @param {string} did * @param {string|undefined} space * @returns {{read: (cid: string) => Promise, readRange: ((cid: string, start: number, end: number) => Promise)|null}} */ function blobReaders(did, space) { const reader = ctx.spaceReader; if (!space || !reader) { return { read: (cid) => fetchBlob(did, cid), readRange: (cid, start, end) => fetchBlobRange(did, cid, start, end), }; } const getBlobRange = reader.getBlobRange; return { read: async (cid) => { const bytes = await reader.getBlob(cid); if (!bytes) throw new Error(`bundle blob ${cid} unavailable`); return bytes; }, readRange: getBlobRange ? async (cid, start, end) => { const bytes = await getBlobRange(cid, start, end); if (!bytes) throw new Error(`bundle blob ${cid} unavailable`); return bytes; } : null, }; } /** * Download the whole chain and unpack it in memory. * @param {import('./record.js').GitRepoRecord} repo * @param {(cid: string) => Promise} fetchPart * @returns {Promise} */ async function openEager(repo, fetchPart) { // Every chunk of every bundle at once. They are independent reads, and // asking for them one after another spends a round trip on each. const total = repo.bundles.reduce( (sum, bundle) => sum + bundle.parts.reduce((n, part) => n + (part.size ?? 0), 0), 0, ); let loaded = 0; ctx.onProgress?.({ loaded, total }); const fetched = await Promise.all( repo.bundles.map((bundle) => Promise.all( bundle.parts.map(async (part) => { const bytes = await fetchPart(part.ref.$link); loaded += bytes.length; ctx.onProgress?.({ loaded, total }); return bytes; }), ), ), ); const packs = fetched.map( (chunks) => parseBundle(concatChunks(chunks)).pack, ); return createEagerStore(await indexPacks(packs)); } /** * @param {string} did * @param {string} rkey * @param {string} [space] - space AT-URI; the record and blobs then come * from the space reader rather than the public endpoints * @returns {Promise<{repo: import('./record.js').GitRepoRecord, store: ObjectStore}|null>} */ async function open(did, rkey, space) { /** @type {{cid: string, value: unknown}|null} */ let body; if (space) { if (!ctx.spaceReader) return null; body = await ctx.spaceReader.getRecord(space, rkey); } else { const params = new URLSearchParams({ repo: did, collection, rkey: rkey, }); const res = await xrpcGet(`/xrpc/com.atproto.repo.getRecord?${params}`); body = res.ok ? /** @type {{cid: string, value: unknown}} */ (await res.json()) : null; } if (!body) return null; const repo = parseRepoRecord(body.value); const key = `${space ?? ''}\u0000${rkey}`; const held = cache.get(key); if (held && held.cid === body.cid) { cache.delete(key); cache.set(key, held); return held; } const { read: readBlob, readRange } = blobReaders(did, space); // Ranged reads need an index for every bundle, and a reader that // answers ranges. const lazy = readRange !== null && repo.bundles.length > 0 && repo.bundles.every((bundle) => indexPartsOf(bundle).length > 0); /** @type {ObjectStore|null} */ let store = null; if (lazy) { try { const total = repo.bundles.reduce( (sum, bundle) => sum + indexPartsOf(bundle).reduce((bytes, part) => bytes + part.size, 0), 0, ); let loaded = 0; ctx.onProgress?.({ loaded, total }); store = await createLazyStore( repo, async (cid) => { const bytes = await readBlob(cid); loaded += bytes.length; ctx.onProgress?.({ loaded, total }); return bytes; }, readRange, ); } catch { // An index blob that is missing or unreadable costs laziness, not // the repository. store = null; } } if (!store) { const chainBytes = repo.bundles.reduce( (sum, bundle) => sum + bundle.parts.reduce((n, part) => n + (part.size ?? 0), 0), 0, ); if (ctx.maxEagerBytes !== undefined && chainBytes > ctx.maxEagerBytes) { throw Object.assign( new Error( `repository ${repo.name} has no object indexes and its ${chainBytes} bytes of bundles exceed the ${ctx.maxEagerBytes} this reader downloads whole`, ), { code: 'RepositoryTooLarge' }, ); } store = await openEager(repo, readBlob); } /** @type {Opened} */ const opened = { cid: body.cid, repo, store }; cache.delete(key); cache.set(key, opened); while (cache.size > cacheSize) { const oldest = cache.keys().next().value; if (oldest === undefined) break; cache.delete(oldest); } return opened; } /** * @param {import('./record.js').GitRepoRecord} repo * @param {string|undefined} ref - short or full ref name; default branch * when omitted * @returns {{name: string, sha: string}|null} */ function findRef(repo, ref) { const wanted = ref || repo.defaultBranch || 'main'; return ( repo.refs.find((r) => r.name === wanted) ?? repo.refs.find((r) => r.name === `refs/heads/${wanted}`) ?? repo.refs.find((r) => r.name === `refs/tags/${wanted}`) ?? // An object id names a commit no ref may point at, the way git // checks one out. (/^[0-9a-f]{40}$/.test(wanted) ? { name: wanted, sha: wanted } : null) ); } /** * Walk from the ref's root tree down the given directory path. * @param {ObjectStore} store * @param {string} rootTree * @param {string} path - slash-separated, '' for the root * @returns {Promise<{sha: string, entry: import('./objects.js').TreeEntry|null}>} the * object at the path plus its tree entry (null at the root) */ async function walkPath(store, rootTree, path) { let sha = rootTree; /** @type {import('./objects.js').TreeEntry|null} */ let entry = null; for (const segment of path.split('/').filter(Boolean)) { const tree = await store.get(sha); if (tree?.type !== 'tree') { throw new Error(`not a directory: ${segment}`); } const found = parseTree(tree.data).find((e) => e.name === segment); if (!found) throw new Error(`no such path: ${path}`); entry = found; sha = found.sha; } return { sha, entry }; } /** * Resolve a path to its blob object at a ref. * @param {{repo: import('./record.js').GitRepoRecord, store: ObjectStore}} opened * @param {string|undefined} ref * @param {string} path */ async function blobAt(opened, ref, path) { const refEntry = findRef(opened.repo, ref); if (!refEntry) throw new Error(`no such ref: ${ref}`); const rootTree = await peelToTree(opened.store, refEntry.sha); const { sha, entry } = await walkPath(opened.store, rootTree, path); const object = await opened.store.get(sha); if (object?.type !== 'blob') { throw new Error(`not a file: ${path}`); } return { refEntry, entry, object }; } return { /** * List a directory. Directories sort first, then files, both by name. * @param {string} did * @param {string} rkey * @param {string} [ref] * @param {string} [path] * @param {string} [space] */ async listTree(did, rkey, ref, path = '', space) { const opened = await open(did, rkey, space); if (!opened) return null; const refEntry = findRef(opened.repo, ref); if (!refEntry) throw new Error(`no such ref: ${ref}`); const rootTree = await peelToTree(opened.store, refEntry.sha); const { sha } = await walkPath(opened.store, rootTree, path); const tree = await opened.store.get(sha); if (tree?.type !== 'tree') { throw new Error(`not a directory: ${path}`); } const entries = parseTree(tree.data) .map((entry) => ({ ...entry, size: entry.type === 'file' ? (opened.store.meta(entry.sha)?.size ?? null) : null, })) .sort((a, b) => { if ((a.type === 'dir') !== (b.type === 'dir')) { return a.type === 'dir' ? -1 : 1; } return a.name.localeCompare(b.name); }); return { ref: refEntry.name, sha: refEntry.sha, path, entries }; }, /** * Every file at a ref, with the size of each. The walk reads trees only: * a blob's size comes from the pack index, so no file body is fetched. * One depth of directories is read per batch, which costs one set of * coalesced range reads per level rather than one per directory. * @param {string} did * @param {string} rkey * @param {string} [ref] * @param {string} [space] * @returns {Promise<{ref: string, files: Array<{path: string, size: number}>, truncated: boolean}|null>} */ async listFiles(did, rkey, ref, space) { const opened = await open(did, rkey, space); if (!opened) return null; const refEntry = findRef(opened.repo, ref); if (!refEntry) throw new Error(`no such ref: ${ref}`); const rootTree = await peelToTree(opened.store, refEntry.sha); /** @type {Array<{path: string, size: number}>} */ const files = []; /** @type {Array<{sha: string, path: string}>} */ let level = [{ sha: rootTree, path: '' }]; let truncated = false; while (level.length > 0 && !truncated) { await opened.store.getMany(level.map((dir) => dir.sha)); /** @type {Array<{sha: string, path: string}>} */ const next = []; for (const dir of level) { const tree = opened.store.peek(dir.sha); if (tree?.type !== 'tree') continue; for (const entry of parseTree(tree.data)) { const path = dir.path ? `${dir.path}/${entry.name}` : entry.name; if (entry.type === 'dir') { next.push({ sha: entry.sha, path }); continue; } // A symlink names a path and a submodule names another // repository. Neither holds bytes of this one. if (entry.type !== 'file') continue; const size = opened.store.meta(entry.sha)?.size; if (size === undefined) continue; files.push({ path, size }); if (files.length >= MAX_LISTED_FILES) { truncated = true; break; } } if (truncated) break; } level = next; } return { ref: refEntry.name, files, truncated }; }, /** * Read a file at a path. Text comes back inline up to the preview cap; * binary or oversized files report metadata only. An image reports its * media type, which the caller fetches as bytes from readImage. * @param {string} did * @param {string} rkey * @param {string} [ref] * @param {string} [path] * @param {string} [space] */ async readFile(did, rkey, ref, path = '', space) { const opened = await open(did, rkey, space); if (!opened) return null; const { refEntry, entry, object } = await blobAt(opened, ref, path); const binary = looksBinary(object.data); const truncated = object.data.length > MAX_PREVIEW_BYTES; return { ref: refEntry.name, path, size: object.data.length, mode: entry?.mode ?? '100644', mediaType: imageMediaType(path), binary, truncated, text: binary || truncated ? null : new TextDecoder().decode(object.data), }; }, /** * The commit history reachable from a ref, newest first the way git log * orders it: among the commits whose children are all listed, the one * with the latest committer date comes next. * @param {string} did * @param {string} rkey * @param {string} [ref] * @param {number} [limit] * @param {string} [space] * @param {string} [path] - a path, for the commits that changed it */ async listCommits(did, rkey, ref, limit = 50, space, path = '') { const opened = await open(did, rkey, space); if (!opened) return null; // A repository nothing has been pushed to holds no refs, and a ref a // record does not name is answered the same as the list of a repo that // has none: empty rather than an error. if (opened.repo.refs.length === 0) { return { ref, commits: [], truncated: false }; } const refEntry = findRef(opened.repo, ref); if (!refEntry) throw new Error(`no such ref: ${ref}`); // One batched fetch for the whole graph; the walk below then reads // from memory in both modes. await opened.store.prefetchCommits(); // The history of one path is the commits that changed it, which is a // different walk from the graph's, one that reads trees. if (path) { const start = await peelToCommit(opened.store, refEntry.sha); const { commits, truncated } = await pathHistory( opened.store, start, path, PATH_WALK_LIMIT, limit, ); return { ref: refEntry.name, commits: commits.map(({ sha, ...commit }) => ({ sha, parents: commit.parents, author: commit.author, authoredAt: commit.authoredAt, committedAt: commit.committedAt, message: commit.message, })), truncated, }; } /** @type {Map} */ const parsed = new Map(); const info = (/** @type {string} */ sha) => { let commit = parsed.get(sha); if (!commit) { const object = opened.store.peek(sha); if (object?.type !== 'commit') { throw new Error(`object ${sha} is not a commit`); } commit = parseCommit(object.data); parsed.set(sha, commit); } return commit; }; /** @type {string[]} */ const frontier = [await peelToCommit(opened.store, refEntry.sha)]; const seen = new Set(frontier); const commits = []; while (frontier.length > 0 && commits.length < limit) { let next = 0; for (let i = 1; i < frontier.length; i++) { if ( info(frontier[i]).committedAt > info(frontier[next]).committedAt ) { next = i; } } const sha = frontier.splice(next, 1)[0]; const commit = info(sha); commits.push({ sha, parents: commit.parents, author: commit.author, authoredAt: commit.authoredAt, committedAt: commit.committedAt, message: commit.message, }); for (const parent of commit.parents) { if (!seen.has(parent)) { seen.add(parent); frontier.push(parent); } } } return { ref: refEntry.name, commits, truncated: frontier.length > 0 }; }, /** * The last commit to change one path, walking back from a ref. See * pathHistory for what counts as a change. * @param {string} did * @param {string} rkey * @param {string} [ref] * @param {string} [path] * @param {number} [limit] - commits to walk before giving up * @param {string} [space] * @returns {Promise} * null where nothing in the walk touched the path */ async lastCommit(did, rkey, ref, path = '', limit = 4000, space) { const opened = await open(did, rkey, space); if (!opened) return null; // An empty repository holds no commit, which is the same answer as one // whose walk found nothing touching the path. if (opened.repo.refs.length === 0) return null; const refEntry = findRef(opened.repo, ref); if (!refEntry) throw new Error(`no such ref: ${ref}`); await opened.store.prefetchCommits(); const start = await peelToCommit(opened.store, refEntry.sha); const { commits } = await pathHistory( opened.store, start, path, limit, 1, ); return commits[0] ?? null; }, /** * One commit: its metadata and the files it changed, each with unified * diff hunks. The comparison is against the first parent, which is how * `git show` presents a merge. A root commit compares against nothing, * so every file in it reads as added. * * A binary file, a submodule, or a change too large to walk reports its * status with null hunks. An image also names its media type and carries * each side's bytes, for the page to show where hunks would go. * @param {string} did * @param {string} rkey * @param {string} sha - full object id of the commit * @param {string} [space] */ async readCommit(did, rkey, sha, space) { const opened = await open(did, rkey, space); if (!opened) return null; const object = await opened.store.get(sha); if (object?.type !== 'commit') throw new Error(`no such commit: ${sha}`); const commit = parseCommit(object.data); /** @type {string|null} */ let parentTree = null; if (commit.parents.length > 0) { const parent = await opened.store.get(commit.parents[0]); if (parent?.type !== 'commit') { throw new Error(`parent ${commit.parents[0]} is not in the bundles`); } parentTree = commitTree(parent.data); } /** @type {Map} */ const changes = new Map(); await walkTreeDiff(opened.store, parentTree, commit.tree, changes); const files = await buildFiles(opened.store, changes); return { sha, parents: commit.parents, author: commit.author, authoredAt: commit.authoredAt, committedAt: commit.committedAt, message: commit.message, files, }; }, /** * The paths that differ between two commits, with nothing about their * contents. Two subtrees holding the same object id are skipped whole, so * the walk reads the trees along the changed paths and no others. What a * caller placing one branch against another needs, where readCommit would * fetch every blob on both sides to build hunks it never shows. * @param {string} did * @param {string} rkey * @param {string} base - full object id of the commit to compare against * @param {string} tip - full object id of the commit * @param {string} [space] * @returns {Promise} the paths, sorted */ async changedPaths(did, rkey, base, tip, space) { const opened = await open(did, rkey, space); if (!opened) return null; const changes = await rangeChanges(opened.store, base, tip); return [...changes.keys()].sort(); }, /** * Everything that differs between two commits, as a diff view renders it. * A change written over a dozen commits is one thing to read, and reading * it a commit at a time shows a file as many times as it was touched. * @param {string} did * @param {string} rkey * @param {string} base - full object id of the commit to compare against * @param {string} tip - full object id of the commit * @param {string} [space] */ async readRange(did, rkey, base, tip, space) { const opened = await open(did, rkey, space); if (!opened) return null; const object = await opened.store.get(tip); if (object?.type !== 'commit') throw new Error(`no such commit: ${tip}`); const commit = parseCommit(object.data); const changes = await rangeChanges(opened.store, base, tip); const log = await walkRange(opened.store, base, tip, MAX_RANGE_COMMITS); return { base, sha: tip, author: commit.author, authoredAt: commit.authoredAt, committedAt: commit.committedAt, message: commit.message, commits: log.commits.length, files: await buildFiles(opened.store, changes), }; }, /** * The commits one change carries, newest first. Its own log, rather than * the branch's: everything the tip reaches that the base does not. * @param {string} did * @param {string} rkey * @param {string|null} base * @param {string} tip * @param {number} [limit] * @param {string} [space] */ async listRange(did, rkey, base, tip, limit = 100, space) { const opened = await open(did, rkey, space); if (!opened) return null; if (opened.repo.refs.length === 0) { return { commits: [], truncated: false }; } return walkRange(opened.store, base, tip, limit); }, /** * Read an image file as bytes, for inline display. Only the extensions in * IMAGE_MEDIA_TYPES resolve; any other path is rejected. * @param {string} did * @param {string} rkey * @param {string} [ref] * @param {string} [path] * @param {string} [space] */ async readImage(did, rkey, ref, path = '', space) { const mediaType = imageMediaType(path); if (!mediaType) throw new Error(`not an image: ${path}`); const opened = await open(did, rkey, space); if (!opened) return null; const { refEntry, object } = await blobAt(opened, ref, path); return { ref: refEntry.name, path, size: object.data.length, mediaType, bytes: object.data, }; }, /** * Read a file as bytes, whole and unaltered. readFile caps and decodes * for display; this is the file itself, for a caller that serves or * stores it. An image reports its media type, so a caller can serve it * as one. * @param {string} did * @param {string} rkey * @param {string} [ref] * @param {string} [path] * @param {string} [space] */ async readBytes(did, rkey, ref, path = '', space) { const opened = await open(did, rkey, space); if (!opened) return null; const { refEntry, entry, object } = await blobAt(opened, ref, path); return { ref: refEntry.name, path, size: object.data.length, mode: entry?.mode ?? '100644', mediaType: imageMediaType(path), binary: looksBinary(object.data), bytes: object.data, }; }, /** * The refs a repository carries, for a caller that has to tell a ref name * from the path after it in a URL. Both may hold slashes, so the only * reliable split is against these names. * @param {string} did * @param {string} rkey * @param {string} [space] * @returns {Promise} full ref names, longest first */ async refNames(did, rkey, space) { const opened = await open(did, rkey, space); if (!opened) return null; return opened.repo.refs .map((entry) => entry.name) .sort((a, b) => b.length - a.length); }, }; }