import { recordArtifactsCall } from "./artifacts-health"; import { env } from "cloudflare:workers"; import { basicAuthHeader, repoTokenSecret } from "./artifacts"; import { concatBytes, FLUSH_PKT, packetText, parseAllPackets, pktLine } from "./pktline"; import { isSha, ZERO_SHA } from "./store"; import type { RefInfo, RepoRefs, RepoRow } from "~/lib/types"; const REFS_CACHE_SECONDS = 300; const HEADS_PREFIX = "refs/heads/"; const TAGS_PREFIX = "refs/tags/"; const PEELED_SUFFIX = "^{}"; const MAX_REFS = 5000; type RefsRepo = Pick; function refsCacheKey(repoId: string): string { return `refs:${repoId}`; } export function parseAdvertisement(body: Uint8Array): RepoRefs { const branches: RefInfo[] = []; const tags = new Map(); let head: string | null = null; for (const packet of parseAllPackets(body)) { const line = packetText(packet); if (line === null || line.startsWith("#")) continue; const [refPart, capabilities] = line.split("\0"); const [sha, name] = refPart.split(" "); if (capabilities) { const symref = capabilities.split(" ").find((cap) => cap.startsWith("symref=HEAD:")); if (symref) head = symref.slice("symref=HEAD:".length).replace(HEADS_PREFIX, ""); } if (!sha || !name || sha === ZERO_SHA) continue; if (branches.length + tags.size >= MAX_REFS) break; if (name.startsWith(HEADS_PREFIX)) { branches.push({ name: name.slice(HEADS_PREFIX.length), sha }); } else if (name.startsWith(TAGS_PREFIX)) { const isPeeled = name.endsWith(PEELED_SUFFIX); const tagName = name.slice(TAGS_PREFIX.length, isPeeled ? -PEELED_SUFFIX.length : undefined); if (isPeeled || !tags.has(tagName)) tags.set(tagName, { name: tagName, sha }); } } branches.sort((left, right) => left.name.localeCompare(right.name)); const sortedTags = [...tags.values()].sort((left, right) => right.name.localeCompare(left.name, undefined, { numeric: true }), ); return { head, branches, tags: sortedTags }; } const DELIM_PKT = new TextEncoder().encode("0001"); /** A protocol v2 `ls-refs` asking only for HEAD, branches and tags (never `refs/pull/*` or `refs/gitfor/*`). */ export function lsRefsRequest(): Uint8Array { return concatBytes([ pktLine("command=ls-refs\n"), pktLine("agent=git/gitfor\n"), DELIM_PKT, pktLine("symrefs\n"), pktLine("peel\n"), pktLine("ref-prefix HEAD\n"), pktLine(`ref-prefix ${HEADS_PREFIX}\n`), pktLine(`ref-prefix ${TAGS_PREFIX}\n`), FLUSH_PKT, ]); } /** Parses an `ls-refs` response: ` [ symref-target:][ peeled:]` per line. */ export function parseLsRefs(body: Uint8Array): RepoRefs { const branches: RefInfo[] = []; const tags: RefInfo[] = []; let head: string | null = null; for (const packet of parseAllPackets(body)) { const line = packetText(packet); if (line === null) continue; if (line.startsWith("ERR ")) throw new Error(`ls-refs failed: ${line.slice(4)}`); const [sha, name, ...attributes] = line.split(" "); if (!sha || !name || !isSha(sha) || sha === ZERO_SHA) continue; const symref = attributes.find((attribute) => attribute.startsWith("symref-target:")); const peeled = attributes.find((attribute) => attribute.startsWith("peeled:"))?.slice("peeled:".length); if (name === "HEAD") { if (symref?.startsWith(`symref-target:${HEADS_PREFIX}`)) { head = symref.slice(`symref-target:${HEADS_PREFIX}`.length); } continue; } if (branches.length + tags.length >= MAX_REFS) break; if (name.startsWith(HEADS_PREFIX)) branches.push({ name: name.slice(HEADS_PREFIX.length), sha }); else if (name.startsWith(TAGS_PREFIX)) { tags.push({ name: name.slice(TAGS_PREFIX.length), sha: peeled && isSha(peeled) ? peeled : sha }); } } branches.sort((left, right) => left.name.localeCompare(right.name)); tags.sort((left, right) => right.name.localeCompare(left.name, undefined, { numeric: true })); return { head, branches, tags }; } async function fetchRefsV2(repo: RefsRepo, secret: string): Promise { recordArtifactsCall("git ls-refs"); const response = await fetch(`${repo.artifacts_remote}/git-upload-pack`, { method: "POST", headers: { authorization: basicAuthHeader(secret), "git-protocol": "version=2", "content-type": "application/x-git-upload-pack-request", accept: "application/x-git-upload-pack-result", "user-agent": "git/gitfor", }, body: lsRefsRequest(), }); if (!response.ok) { await response.body?.cancel(); return null; } try { return parseLsRefs(new Uint8Array(await response.arrayBuffer())); } catch (error) { console.warn("ls-refs failed; falling back to the v0 advertisement", repo.artifacts_name, error); return null; } } /** * Lists refs with a prefix-filtered v2 `ls-refs`, so the response doesn't grow with every pull request's * hidden refs; falls back to the full v0 advertisement if the v2 request is refused. */ async function fetchRefs(repo: RefsRepo): Promise { const secret = await repoTokenSecret(repo, "read"); const v2 = await fetchRefsV2(repo, secret); if (v2) return v2; const response = await fetch(`${repo.artifacts_remote}/info/refs?service=git-upload-pack`, { headers: { authorization: basicAuthHeader(secret), "user-agent": "git/gitfor" }, }); if (!response.ok) { throw new Error(`Failed to list refs (${response.status})`); } return parseAdvertisement(new Uint8Array(await response.arrayBuffer())); } const NO_REFS: RepoRefs = { head: null, branches: [], tags: [] }; export async function listRefs(repo: RefsRepo): Promise { if (!repo.artifacts_remote) return NO_REFS; const cached = await env.KV.get(refsCacheKey(repo.id), "json"); if (cached) return cached; const refs = await fetchRefs(repo); await env.KV.put(refsCacheKey(repo.id), JSON.stringify(refs), { expirationTtl: REFS_CACHE_SECONDS }); return refs; } export async function refreshRefs(repo: RefsRepo): Promise { if (!repo.artifacts_remote) return NO_REFS; const refs = await fetchRefs(repo); await env.KV.put(refsCacheKey(repo.id), JSON.stringify(refs), { expirationTtl: REFS_CACHE_SECONDS }); return refs; } export async function invalidateRefs(repoId: string): Promise { await env.KV.delete(refsCacheKey(repoId)); } export function branchSha(refs: RepoRefs, branch: string): string | null { return refs.branches.find((ref) => ref.name === branch)?.sha ?? null; } export type ResolvedRevision = { kind: "branch" | "tag" | "commit"; name: string; sha: string }; /** Resolves a branch, tag or full commit SHA. Branch names win over tags, mirroring git. */ export function resolveRevision(refs: RepoRefs, revision: string): ResolvedRevision | null { const branch = refs.branches.find((ref) => ref.name === revision); if (branch) return { kind: "branch", name: branch.name, sha: branch.sha }; const tag = refs.tags.find((ref) => ref.name === revision); if (tag) return { kind: "tag", name: tag.name, sha: tag.sha }; if (isSha(revision)) return { kind: "commit", name: revision, sha: revision }; return null; } /** * Splits a splat like `feature/x/src/index.ts` into a revision and path by matching the longest * known branch or tag prefix, because ref names may themselves contain slashes. */ export function splitRevisionPath(refs: RepoRefs, splat: string): { revision: ResolvedRevision; path: string } | null { const parts = splat.split("/").filter(Boolean); for (let length = parts.length; length > 0; length--) { const candidate = parts.slice(0, length).join("/"); const revision = resolveRevision(refs, candidate); if (revision) return { revision, path: parts.slice(length).join("/") }; } return null; }