import 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 ceiling
export 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 binary
const 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 sniff
export 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 here
const 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 first
export 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 head2
export 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 empty
export 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 line
export 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
}))
};
};