import { MAX_INLINE_SIZE } from "#lib/api/blob.ts";
import { blob, knotMirrorTarget, type GitServiceConfig } from "#lib/api/gitclient.ts";
import {
diffFileKind,
diffFileStat,
diffSideExists,
getDiff,
getInterdiff,
listLog,
type MirrorFileDiff,
type MirrorFileKind
} from "#lib/api/knotmirror.ts";
import type { FileContents } from "@pierre/diffs";
import type * as ShTangledGitDefs from "#lib/api/lexicons/types/sh/tangled/git/defs.ts";
import type { Did } from "@atcute/lexicons/syntax";
import type { BobbinContext } from "#lib/api/client.ts";
import type { GitTarget } from "#lib/api/gitclient.ts";
import type { DiffStat } from "#lib/api/diff.ts";
import type { Revspec } from "#lib/api/pullRoute.ts";
import type { CommitSummary } from "#lib/api/repo.ts";
import type { Fetch } from "#lib/fetch.ts";
type DiffSrc = ShTangledGitDefs.DiffSrc;
// pierre renders roughly one row per line pair, plus a little chrome per hunk. only
// used to reserve height under a pending card, so an estimate is enough
const HUNK_CHROME_LINES = 2;
// what the page asks for: one version's diff, or the interdiff between two of
// them. `repo` is the source repo -- knotmirror unions both dids' object dirs
// into one scratch repo, so a fork's head and its base share one did here
export interface DiffSpec {
kind: "diff";
repo: Did;
version: Revspec;
}
export interface InterdiffSpec {
kind: "interdiff";
repo: Did;
oldVersion: Revspec;
version: Revspec;
// narrows the interdiff to the one commit carrying this change id
changeId?: string;
}
export type RepoDiffSpec = DiffSpec | InterdiffSpec;
export interface RepoDiffDeps {
ctx: BobbinContext;
// where the blob text comes from; the diff payload carries line numbers only
git: GitTarget;
}
export interface RepoDiffFile {
// stable across sides, used for anchors
key: string;
name: string;
oldName?: string;
kind: MirrorFileKind;
stat: { insertions: number; deletions: number };
// rough rendered height, so a card can reserve space before its text lands
lines: number;
// Explains unrenderable files (binary, submodule, oversized); suppresses sides fetch.
note?: string;
sides?: { lhs: DiffSrc; rhs: DiffSrc };
}
// where the two sides' text comes from. an interdiff's lhs is a tree knotmirror
// rebased in memory for that one request, reachable by no ref, so its text can
// only arrive inlined on the payload
export type ContentsSource =
{ inlineOld?: false; oldRef: string; newRef: string } | { inlineOld: true; newRef: string };
export interface RepoDiff {
files: RepoDiffFile[];
stat: DiffStat;
contents: ContentsSource;
}
// one file's two sides, ready for pierre to diff
export interface FilePair {
oldFile: FileContents;
newFile: FileContents;
}
export type FileContentsResult = FilePair | { note: string };
export const DIFF_TOO_LARGE_MESSAGE = "diff payload exceeds the ssr byte cap";
export class DiffTooLargeError extends Error {
constructor() {
super(DIFF_TOO_LARGE_MESSAGE);
this.name = "DiffTooLargeError";
}
}
export const isDiffTooLarge = (cause: unknown): boolean =>
cause instanceof DiffTooLargeError ||
(cause instanceof Error && cause.message === DIFF_TOO_LARGE_MESSAGE);
// sveltekit base64s a read of `body` but not `text()` on a clone
export const cappedFetch =
(fetch: Fetch, maxBytes: number): Fetch =>
async (input, init) => {
const abort = new AbortController();
const signal = init?.signal ? AbortSignal.any([init.signal, abort.signal]) : abort.signal;
const response = await fetch(input, { ...init, signal });
if (!response.ok) return response;
const reader = response.clone().body?.getReader();
if (!reader) return response;
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) return response;
total += value.byteLength;
if (total > maxBytes) {
abort.abort();
throw new DiffTooLargeError();
}
}
};
export const loadDiff = (deps: RepoDiffDeps, spec: RepoDiffSpec): Promise<RepoDiff> => {
if (spec.kind === "diff") return plainDiff(deps, spec.repo, spec.version);
if (!spec.changeId) return interdiff(deps, spec.repo, spec.oldVersion, spec.version);
return oneChange(deps, spec);
};
const plainDiff = async (deps: RepoDiffDeps, repo: Did, version: Revspec): Promise<RepoDiff> => {
const { diffs } = await getDiff(deps.ctx, {
baseRepo: repo,
baseCommit: version.base,
headRepo: (version.repo ?? repo) as Did,
headCommit: version.head
});
// the left side is read at `base`, while knotmirror diffs from
// merge_base(base, head). identical when base is the merge base, which is
// what a pull version stores
return buildDiff(diffs, { oldRef: version.base, newRef: version.head });
};
// an interdiff's right side is the new version's head tree, so it reads like any
// other diff -- its left side comes inlined, see `ContentsSource`
const interdiff = async (
deps: RepoDiffDeps,
repo: Did,
oldVersion: Revspec,
version: Revspec
): Promise<RepoDiff> => {
const { diffs } = await getInterdiff(deps.ctx, {
baseRepo: (oldVersion.repo ?? repo) as Did,
baseCommit1: oldVersion.base,
baseCommit2: oldVersion.head,
headRepo: (version.repo ?? repo) as Did,
headCommit1: version.base,
headCommit2: version.head
});
return buildDiff(diffs, { inlineOld: true, newRef: version.head });
};
// counts for the og card, which leaves them out rather than wait on a slow or oversized diff
export const loadDiffStat = (
config: GitServiceConfig & { ssrDiffMaxBytes: number },
repo: Did,
version: Revspec,
fetch: Fetch,
timeoutMs: number
): Promise<DiffStat | undefined> => {
const signal = AbortSignal.timeout(timeoutMs);
const git = knotMirrorTarget(config, { repoDid: repo }, (input, init) =>
fetch(input, { ...init, signal })
);
const ctx =
config.ssrDiffMaxBytes > 0
? { ...git.ctx, fetch: cappedFetch(git.ctx.fetch, config.ssrDiffMaxBytes) }
: git.ctx;
return plainDiff({ ctx, git }, repo, version).then(
({ stat }) => stat,
() => undefined
);
};
// a change id names the same logical commit in both versions, so narrowing to it
// means interdiffing that one commit's span on each side
const oneChange = async (deps: RepoDiffDeps, spec: InterdiffSpec): Promise<RepoDiff> => {
const [oldCommits, newCommits] = await Promise.all([
listLog(deps.ctx, spec.repo, spec.oldVersion),
listLog(deps.ctx, spec.repo, spec.version)
]);
const target = resolveChange(spec.changeId ?? "", oldCommits, newCommits, spec);
switch (target.kind) {
case "missing":
throw new Error("Can't find a commit with that change-id in this version.");
case "diff":
return plainDiff(deps, spec.repo, target.version);
case "interdiff":
return interdiff(deps, spec.repo, target.oldVersion, target.version);
}
};
export type ChangeTarget =
// the change is missing in the newer version, so there is nothing to interdiff
| { kind: "missing" }
// the change is new in the newer version, so we just diff since its parent
| { kind: "diff"; version: Revspec }
| { kind: "interdiff"; oldVersion: Revspec; version: Revspec };
// a root commit has no parent to diff against, so it falls back to the base of
// the version it belongs to
const single = (commit: CommitSummary, version: Revspec): Revspec => ({
base: commit.parent ?? version.base,
head: commit.hash,
repo: version.repo
});
export const resolveChange = (
changeId: string,
oldCommits: readonly CommitSummary[],
newCommits: readonly CommitSummary[],
bases: { oldVersion: Revspec; version: Revspec }
): ChangeTarget => {
const find = (commits: readonly CommitSummary[]) =>
commits.find((commit) => commit.changeId === changeId);
const to = find(newCommits);
if (!to) return { kind: "missing" };
const from = find(oldCommits);
if (!from) return { kind: "diff", version: single(to, bases.version) };
return {
kind: "interdiff",
oldVersion: single(from, bases.oldVersion),
version: single(to, bases.version)
};
};
// the diff endpoint answers metadata only -- `diffSrc` is path/oid/size/flags and
// a hunk is aligned line numbers -- so the text comes from the blob endpoint and
// pierre diffs the two sides itself
const buildDiff = (diffs: MirrorFileDiff[], contents: ContentsSource): RepoDiff => {
const stat: DiffStat = { insertions: 0, deletions: 0, files_changed: diffs.length };
const files = diffs.map((file): RepoDiffFile => {
const fileStat = diffFileStat(file);
stat.insertions += fileStat.insertions;
stat.deletions += fileStat.deletions;
const kind = diffFileKind(file);
const name = file.rhsSrc.path || file.lhsSrc.path;
const base: RepoDiffFile = {
key: `${file.lhsSrc.path}→${file.rhsSrc.path}`,
name,
oldName: kind === "renamed" ? file.lhsSrc.path : undefined,
kind,
stat: fileStat,
lines: renderedLines(file)
};
if (file.lhsSrc.isSubmodule || file.rhsSrc.isSubmodule) {
return { ...base, note: "Submodule pointer, not shown." };
}
if (file.lhsSrc.isBinary || file.rhsSrc.isBinary) {
return { ...base, note: "This is a binary file and will not be displayed." };
}
// the payload carries each side's size, so a file the blob endpoint would
// refuse never costs a request
if (tooLarge(file.lhsSrc) || tooLarge(file.rhsSrc)) {
return { ...base, note: "This file is too large to display." };
}
return { ...base, sides: { lhs: file.lhsSrc, rhs: file.rhsSrc } };
});
return { files, stat, contents };
};
const tooLarge = (src: DiffSrc): boolean => src.size > MAX_INLINE_SIZE;
const renderedLines = (file: MirrorFileDiff): number =>
file.hunks.reduce((total, hunk) => total + hunk.lines.length + HUNK_CHROME_LINES, 0);
// 429 burst limits, 5xx, and network drops are retryable; 404 is permanent.
const isTransient = (cause: unknown): boolean => {
const status = (cause as { status?: unknown })?.status;
if (typeof status === "number") return status === 429 || status >= 500;
// a network failure carries no status at all
return cause instanceof TypeError;
};
const sideContents = async (
git: GitTarget,
ref: string | undefined,
src: DiffSrc
): Promise<string | null> => {
// a side that doesn't exist at this ref is an empty side, not a failed fetch
if (!diffSideExists(src)) return "";
if (src.content !== undefined) return src.content;
// the rebased tree an interdiff diffs against is reachable by no ref, so a
// missing inline side there can never be recovered
if (ref === undefined) return null;
// a rate-limited or briefly failing mirror is worth retrying; a blob that is
// really not there is not, so only the transient shape escapes
const output = await blob(git, { ref, path: src.path }, MAX_INLINE_SIZE).catch(
(cause: unknown) => {
if (isTransient(cause)) throw cause;
return null;
}
);
if (!output || output.encoding !== "utf-8") return null;
return output.content ?? "";
};
export const fetchSides = async (
deps: RepoDiffDeps,
contents: ContentsSource,
file: RepoDiffFile
): Promise<FileContentsResult> => {
const sides = file.sides;
if (!sides) return { note: file.note ?? "Contents could not be loaded." };
const [oldContents, newContents] = await Promise.all([
sideContents(deps.git, contents.inlineOld ? undefined : contents.oldRef, sides.lhs),
sideContents(deps.git, contents.newRef, sides.rhs)
]);
if (oldContents === null || newContents === null) {
return { note: "Contents could not be loaded." };
}
return {
// pierre infers the language from the name, same as the blob page
oldFile: { name: sides.lhs.path || file.name, contents: oldContents },
newFile: { name: file.name, contents: newContents }
};
};