import { recordArtifactsCall } from "./artifacts-health"; import { basicAuthHeader, repoTokenSecret } from "./artifacts"; import { concatBytes, FLUSH_PKT, packetText, parsePacket, pktLine } from "./pktline"; import { parsePack } from "./pack"; import type { RepoRow } from "~/lib/types"; const DELIM_PKT = new TextEncoder().encode("0001"); const SIDEBAND_DATA = 1; const SIDEBAND_ERROR = 3; const MAX_FETCH_BYTES = 4 * 1024 * 1024; const MAX_COMMIT_BYTES = 256 * 1024; const FETCH_TIMEOUT_MS = 15_000; export const MAX_COMMITS_PER_FETCH = 50; type FetchRepo = Pick; /** Builds a protocol v2 fetch asking only for the wanted commits, without trees or blobs where supported. */ export function commitFetchRequest(shas: string[], filter: boolean): Uint8Array { return concatBytes([ pktLine("command=fetch\n"), pktLine("agent=git/gitfor\n"), DELIM_PKT, ...shas.map((sha) => pktLine(`want ${sha}\n`)), pktLine("deepen 1\n"), ...(filter ? [pktLine("filter tree:0\n")] : []), pktLine("no-progress\n"), pktLine("done\n"), FLUSH_PKT, ]); } /** Pulls packfile bytes out of a v2 fetch response, tolerating a truncated tail. */ export function packFromFetchResponse(body: Uint8Array): Uint8Array { const chunks: Uint8Array[] = []; let inPack = false; let offset = 0; while (offset < body.byteLength) { let parsed: ReturnType; try { parsed = parsePacket(body, offset); } catch { break; } if (!parsed) break; offset = parsed.next; const packet = parsed.packet; if (packet.kind !== "data") continue; if (!inPack) { const line = packetText(packet); if (line?.startsWith("ERR ")) throw new Error(line.slice(4)); if (line === "packfile") inPack = true; continue; } const band = packet.data[0]; if (band === SIDEBAND_DATA) chunks.push(packet.data.subarray(1)); else if (band === SIDEBAND_ERROR) throw new Error(new TextDecoder().decode(packet.data.subarray(1))); } return concatBytes(chunks); } async function readCapped(response: Response, limit: number): Promise> { if (!response.body) return new Uint8Array(0); const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let total = 0; while (total < limit) { const { value, done } = await reader.read(); if (done) break; chunks.push(value); total += value.byteLength; } await reader.cancel().catch(() => undefined); return concatBytes(chunks); } async function fetchPack(repo: FetchRepo, shas: string[], filter: boolean): Promise> { const secret = await repoTokenSecret(repo, "read"); // Internal git fetches are billed as pulls; count them next to the binding calls. recordArtifactsCall("git fetch: raw commits"); const response = await fetch(`${repo.artifacts_remote}/git-upload-pack`, { method: "POST", headers: { authorization: basicAuthHeader(secret), "content-type": "application/x-git-upload-pack-request", accept: "application/x-git-upload-pack-result", "git-protocol": "version=2", "user-agent": "git/gitfor", }, body: commitFetchRequest(shas, filter), signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!response.ok) throw new Error(`upload-pack failed (${response.status})`); return packFromFetchResponse(await readCapped(response, MAX_FETCH_BYTES)); } /** * Fetches raw commit objects (with their gpgsig headers intact) through upload-pack, since the * Artifacts commit API returns parsed metadata without signatures. */ export async function fetchRawCommits(repo: FetchRepo, shas: string[]): Promise> { const wanted = [...new Set(shas)].slice(0, MAX_COMMITS_PER_FETCH); const out = new Map(); if (wanted.length === 0) return out; let pack: Uint8Array; try { pack = await fetchPack(repo, wanted, true); } catch { pack = await fetchPack(repo, wanted, false); } if (pack.byteLength === 0) return out; const parsed = await parsePack(pack, { maxObjects: wanted.length * 4 + 16, maxObjectBytes: MAX_FETCH_BYTES, maxTotalBytes: MAX_FETCH_BYTES * 2, }); const wantedSet = new Set(wanted); for (const object of parsed.objects) { if (object.type === "commit" && wantedSet.has(object.sha) && object.content.byteLength <= MAX_COMMIT_BYTES) { out.set(object.sha, object.content); } } return out; }