import { env } from "cloudflare:workers"; import { basicAuthHeader, repoTokenSecret } from "./artifacts"; import { packetText, parseAllPackets } 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 }; } async function fetchRefs(repo: RefsRepo): Promise { const secret = await repoTokenSecret(repo, "read"); const response = await fetch(`${repo.artifacts_remote}/info/refs?service=git-upload-pack`, { headers: { authorization: basicAuthHeader(secret), "user-agent": "git/stig" }, }); 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; }