Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423import type { BobbinContext, XrpcRequestInit } from "#lib/api/client.ts";import { buildUrl, jsonGet, rawGet } from "#lib/api/_request.ts";import { nsidOf } from "#lib/api/syntax.ts";import type * as GitDefs from "#lib/api/lexicons/types/org/tangled/git/defs.ts";import type * as MirrorGetRef from "#lib/api/lexicons/types/org/tangled/temp/git/getRef.ts";import type * as MirrorEntry from "#lib/api/lexicons/types/org/tangled/temp/git/getEntry.ts";import type * as MirrorListBranches from "#lib/api/lexicons/types/org/tangled/temp/git/listBranches.ts";import type * as MirrorListTags from "#lib/api/lexicons/types/org/tangled/temp/git/listTags.ts";import type * as MirrorTree from "#lib/api/lexicons/types/org/tangled/temp/git/getTree.ts";import { coAuthorsFrom, didFromSignature, FULL_HASH_RE, splitMessage, treeEntryKind, type CommitDetail, type CommitSummary} from "#lib/api/repo.ts";import type { Did } from "@atcute/lexicons/syntax";import type { Revspec } from "#lib/api/pullRoute.ts";import type * as ShTangledGitDefs from "#lib/api/lexicons/types/sh/tangled/git/defs.ts";import type * as GetBlame from "#lib/api/lexicons/types/sh/tangled/git/temp2/getBlame.ts";import type * as GetDiff from "#lib/api/lexicons/types/sh/tangled/git/temp2/getDiff.ts";import type * as GetInterdiff from "#lib/api/lexicons/types/sh/tangled/git/temp2/getInterdiff.ts";import type * as ListCommits from "#lib/api/lexicons/types/org/tangled/temp/git/listCommits.ts";import type * as MergeCheck from "#lib/api/lexicons/types/sh/tangled/git/temp2/mergeCheck.ts";
const TREE_NSID = nsidOf("org.tangled.temp.git.getTree");const ENTRY_NSID = nsidOf("org.tangled.temp.git.getEntry");const MERGE_BASE_NSID = nsidOf("org.tangled.temp.git.getMergeBase");const BRANCHES_NSID = nsidOf("org.tangled.temp.git.listBranches");const TAGS_NSID = nsidOf("org.tangled.temp.git.listTags");const GET_REF_NSID = nsidOf("org.tangled.temp.git.getRef");
const BLOB_NSID = nsidOf("org.tangled.temp.git.getBlob");const FORMAT_PATCH_NSID = nsidOf("org.tangled.temp.git.formatPatch");const LANGUAGES_NSID = nsidOf("sh.tangled.git.temp.listLanguages");
const MAX_README_BYTES = 1 << 20;
export interface KnotMirrorLanguagesResponse { languages?: { name: string; size: number }[]; total?: number; ref?: string;}
export const entry = ( bobbin: BobbinContext, params: { repo: string; ref: string; path: string }, init?: XrpcRequestInit) => jsonGet<MirrorEntry.$output>(bobbin, ENTRY_NSID, params, init);
export const blobRawUrl = ( serviceUrl: string, params: { repo: string; ref: string; path: string; download?: boolean }): string => buildUrl(serviceUrl, BLOB_NSID, params).toString();
// the mirror streams `git format-patch --stdout` for a range, which is the only// way to get a large diff in one response: the knot builds the whole thing as// json first and refuses anything past its 5MB response ceilingexport const formatPatchUrl = ( serviceUrl: string, params: { repo: string; from: string; to: string }): string => buildUrl(serviceUrl, FORMAT_PATCH_NSID, params).toString();
export const formatPatch = ( ctx: BobbinContext, params: { repo: string; from: string; to: string }, init?: XrpcRequestInit) => rawGet(ctx, FORMAT_PATCH_NSID, params, init);
type LastCommit = { hash: string; message: string; when: string; author?: { name: string; email: string; when: string };};
export interface RepoTreeEntry { name: string; mode: string; size: number; lastCommit?: LastCommit;}
export interface RepoTree { ref: string; lastCommit?: LastCommit; files: RepoTreeEntry[]; readme?: { filename: string; contents: string };}
export interface RepoBlob { path: string; ref: string; size?: number; isBinary?: boolean; encoding?: "utf-8"; content?: string; fileTooLarge?: boolean; lastCommit?: LastCommit; submodule?: { name: string; url: string };}
const normalizeCommit = (commit: GitDefs.Commit | undefined): LastCommit | undefined => commit ? { hash: commit.hash, message: commit.message, when: commit.committer?.when ?? commit.author?.when ?? "", author: commit.author ? { name: commit.author.name, email: commit.author.email, when: commit.author.when } : undefined } : undefined;
const normalizeTree = (tree: MirrorTree.$output, ref: string): RepoTree => ({ ref, lastCommit: normalizeCommit(tree.lastCommit), files: tree.entries.map((entry) => ({ name: entry.name, mode: entry.mode, size: entry.size, lastCommit: normalizeCommit(entry.lastCommit) }))});
// same heuristic as git: a null byte in the first chunk means binaryconst looksBinary = (bytes: Uint8Array): boolean => bytes.subarray(0, 8000).includes(0);
// temp.getBlob answers raw bytes only, metadata comes from getEntry and// the binary flag from a content sniffexport const blob = async ( ctx: BobbinContext, params: { repo: string; ref: string; path: string }, maxBytes: number, init?: XrpcRequestInit): Promise<RepoBlob> => { const meta = await entry(ctx, params, init); const lastCommit = normalizeCommit(meta.lastCommit);
// a gitlink is a submodule pointer, not a file if (treeEntryKind(meta.mode) === "submodule") { return { path: params.path, ref: params.ref, size: meta.size, submodule: { name: meta.name, url: "" } }; }
if (meta.size !== undefined && meta.size > maxBytes) { return { path: params.path, ref: params.ref, size: meta.size, fileTooLarge: true, lastCommit }; }
const bytes = new Uint8Array(await (await rawGet(ctx, BLOB_NSID, params, init)).arrayBuffer()); if (looksBinary(bytes)) { return { path: params.path, ref: params.ref, size: meta.size ?? bytes.length, isBinary: true, lastCommit }; }
return { path: params.path, ref: params.ref, size: meta.size ?? bytes.length, isBinary: false, encoding: "utf-8", content: new TextDecoder().decode(bytes), lastCommit };};
const isReadmeFile = (name: string, mode: string): boolean => /^readme(?:\.[^.]+)?$/i.test(name) && treeEntryKind(mode) === "file";
const pathInTree = (path: string | undefined, name: string): string => path ? `${path}/${name}` : name;
export const tree = async ( ctx: BobbinContext, params: { repo: string; ref: string; path?: string }, init?: XrpcRequestInit): Promise<RepoTree> => { const response = await jsonGet<MirrorTree.$output>(ctx, TREE_NSID, params, init); const result = normalizeTree(response, params.ref); const readmes = response.entries.filter((entry) => isReadmeFile(entry.name, entry.mode)); const readme = readmes.find((entry) => entry.name.toLowerCase() === "readme.md") ?? readmes[0]; if (!readme) return result; if (readme.size !== undefined && readme.size > MAX_README_BYTES) return result;
try { const blob = await rawGet( ctx, BLOB_NSID, { repo: params.repo, ref: params.ref, path: pathInTree(params.path, readme.name) }, init ); return { ...result, readme: { filename: readme.name, contents: await blob.text() } }; } catch { return result; }};
export const branches = ( ctx: BobbinContext, params: { repo: string; limit: number; cursor?: string }, init?: XrpcRequestInit) => jsonGet<MirrorListBranches.$output>(ctx, BRANCHES_NSID, params, init);
export const tags = ( ctx: BobbinContext, params: { repo: string; limit: number; cursor?: string }, init?: XrpcRequestInit) => jsonGet<MirrorListTags.$output>(ctx, TAGS_NSID, params, init);
export const getRef = ( ctx: BobbinContext, params: { repo: string; ref: string }, init?: XrpcRequestInit) => jsonGet<MirrorGetRef.$output>(ctx, GET_REF_NSID, params, init);
export const languages = ( ctx: BobbinContext, params: { repo: string; ref: string }, init?: XrpcRequestInit) => jsonGet<KnotMirrorLanguagesResponse>(ctx, LANGUAGES_NSID, params, init);
export const getMergeBase = ( bobbin: BobbinContext, params: { repos: string[]; base: string; head: string }, init?: XrpcRequestInit) => jsonGet<{ commit: string }>(bobbin, MERGE_BASE_NSID, params, init);
// knotmirror's own xrpc surface, proxied through bobbin — its grpc service is// internal and streaming, so the read paths the frontend needs live hereconst LIST_COMMITS_NSID = "org.tangled.temp.git.listCommits";const LIST_COMMITS_BY_REF_NSID = "org.tangled.temp.git.listCommitsByRef";const GET_DIFF_NSID = "sh.tangled.git.temp2.getDiff";const GET_INTERDIFF_NSID = "sh.tangled.git.temp2.getInterdiff";const GET_BLAME_NSID = "sh.tangled.git.temp2.getBlame";const MERGE_CHECK_NSID = "sh.tangled.git.temp2.mergeCheck";
export const getBlame = (ctx: BobbinContext, params: GetBlame.$params, init?: XrpcRequestInit) => jsonGet<GetBlame.$output>(ctx, GET_BLAME_NSID, params, init);
export type MirrorCommit = ListCommits.Commit;
export const listCommits = ( ctx: BobbinContext, params: ListCommits.$params, init?: XrpcRequestInit) => jsonGet<ListCommits.$output>(ctx, LIST_COMMITS_NSID, params, init);
export const listCommitsByRef = ( ctx: BobbinContext, params: Omit<ListCommits.$params, "repos"> & { repo: Did }, init?: XrpcRequestInit) => jsonGet<ListCommits.$output>(ctx, LIST_COMMITS_BY_REF_NSID, params, init);
export const commitAtRef = async ( ctx: BobbinContext, repo: Did, ref: string, init?: XrpcRequestInit): Promise<MirrorCommit | undefined> => { const page = await listCommitsByRef(ctx, { repo, ranges: [ref], limit: 1 }, init); const first = page.commits[0]; if (!first || !FULL_HASH_RE.test(ref) || first.oid.toLowerCase() === ref.toLowerCase()) { return first; } const exact = await listCommitsByRef( ctx, { repo, ranges: [`${ref}^..${ref}`], limit: 100 }, init ); return exact.commits.find((commit) => commit.oid.toLowerCase() === ref.toLowerCase());};
const LOG_LIMIT = 100;
// every commit a pull version brought, newest firstexport const listLog = async ( ctx: BobbinContext, repo: Did, range: Revspec, includeStats = false, init?: XrpcRequestInit): Promise<CommitSummary[]> => { const page = await listCommits( ctx, { repos: range.repo && range.repo !== repo ? [repo, range.repo as Did] : [repo], ranges: [`${range.base}..${range.head}`], limit: LOG_LIMIT, includeStats }, init ); return page.commits.map(toMirrorCommitSummary);};
export type MirrorFileDiff = ShTangledGitDefs.FileDiff;
export const getDiff = (ctx: BobbinContext, params: GetDiff.$params, init?: XrpcRequestInit) => jsonGet<GetDiff.$output>(ctx, GET_DIFF_NSID, params, init);
// `base*` is the older version's span, `head*` the newer one's: knotmirror// rebases base1..base2 onto head1 and diffs the result against head2export const getInterdiff = ( ctx: BobbinContext, params: GetInterdiff.$params, init?: XrpcRequestInit) => jsonGet<GetInterdiff.$output>(ctx, GET_INTERDIFF_NSID, params, init);
export type MirrorConflict = MergeCheck.Conflict;
export const mergeCheck = ( ctx: BobbinContext, params: MergeCheck.$params, init?: XrpcRequestInit) => jsonGet<MergeCheck.$output>(ctx, MERGE_CHECK_NSID, params, init);
export type MirrorFileKind = "new" | "deleted" | "renamed" | "changed";
// an add or delete carries the same path on both sides — gix names both resources// after the change's location — so the side that doesn't exist is the one whose oid// is null, not the one whose path is emptyexport const diffSideExists = (src: ShTangledGitDefs.DiffSrc): boolean => src.path !== "" && !/^0+$/.test(src.oid);
const EMPTY_TREE_SHA1 = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";const EMPTY_TREE_SHA256 = "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321";
export const emptyTreeOid = (like: string): string => like.length === 64 ? EMPTY_TREE_SHA256 : EMPTY_TREE_SHA1;
export const diffFileName = (file: MirrorFileDiff): string => file.rhsSrc.path || file.lhsSrc.path;
export const diffFileKind = (file: MirrorFileDiff): MirrorFileKind => { if (!diffSideExists(file.lhsSrc)) return "new"; if (!diffSideExists(file.rhsSrc)) return "deleted"; return file.lhsSrc.path === file.rhsSrc.path ? "changed" : "renamed";};
// the payload has no line text, only aligned line numbers: a pair with one side// missing is a pure insertion or deletion, a pair with both is a replaced lineexport const diffFileStat = (file: MirrorFileDiff): { insertions: number; deletions: number } => { let insertions = 0; let deletions = 0; for (const hunk of file.hunks) { for (const line of hunk.lines) { if (line.rhs !== undefined) insertions++; if (line.lhs !== undefined) deletions++; } } return { insertions, deletions };};
export const mirrorChangeId = (commit: MirrorCommit): string | undefined => commit.extraHeaders.find((header) => header.key === "change-id")?.value;
export const toMirrorCommitSummary = (commit: MirrorCommit): CommitSummary => { const [subject, body] = splitMessage(commit.message); return { hash: commit.oid, shortHash: commit.oid.slice(0, 8), subject, body, authorName: commit.author.name, authorEmail: commit.author.email, authorDid: didFromSignature(commit.author.email), committerEmail: commit.committer.email, signatureFingerprint: commit.signature?.fingerprint, when: commit.committer.when ?? commit.author.when, // the pull page links a commit row as `firstParent..commit` parent: commit.parents[0], changeId: mirrorChangeId(commit), stat: commit.stat && { insertions: commit.stat.insertions, deletions: commit.stat.deletions, files_changed: commit.stat.filesChanged } };};
export const toMirrorCommitDetail = (commit: MirrorCommit): CommitDetail => { const [subject, body] = splitMessage(commit.message); const committerWhen = commit.committer.when ?? ""; return { hash: commit.oid, shortHash: commit.oid.slice(0, 8), subject, body, authorName: commit.author.name, authorEmail: commit.author.email, authorDid: didFromSignature(commit.author.email), authorWhen: commit.author.when ?? "", committerName: commit.committer.name, committerEmail: commit.committer.email, committerWhen, signatureFingerprint: commit.signature?.fingerprint, coAuthors: coAuthorsFrom(commit.message, committerWhen).map(({ Name, Email }) => ({ name: Name, email: Email })) };};