import { basicAuthHeader, repoTokenSecret } from "./artifacts"; import { invalidateRefs } from "./refs"; import { pushToArtifacts } from "./writer"; import { packStream, parseReportStatus, parseSyncableRefs, receivePackBody, uploadPackRequest, } from "./ref-sync-protocol"; import type { AdvertisedRef } from "./ref-sync-protocol"; import { badRequest } from "../errors"; import type { RepoRow } from "~/lib/types"; const MAX_SYNC_REFS = 1000; const MAX_HAVES = 256; const ADVERTISEMENT_TIMEOUT_MS = 30_000; const USER_AGENT = "git/gitfor"; export type RefSyncResult = { created: number; rejected: string[]; skipped: number }; async function advertisement(url: string, authorization: string | null): Promise { const headers: Record = { "user-agent": USER_AGENT }; if (authorization) headers.authorization = authorization; const response = await fetch(`${url.replace(/\/$/, "")}/info/refs?service=git-upload-pack`, { headers, signal: AbortSignal.timeout(ADVERTISEMENT_TIMEOUT_MS), }); if (!response.ok) throw badRequest(`Could not list refs at ${url} (HTTP ${response.status})`); return parseSyncableRefs(new Uint8Array(await response.arrayBuffer())); } /** * Artifacts imports only copy one branch. This copies every other branch and tag from the source by * relaying a packfile straight from the source's upload-pack into Artifacts' receive-pack, so nothing * is buffered in the Worker. Existing refs are never overwritten. */ export async function syncRefsFromSource(repo: RepoRow, sourceUrl: string): Promise { if (!sourceUrl.startsWith("https://")) throw badRequest("Only https:// sources can be synced"); const readSecret = await repoTokenSecret(repo, "read"); const [source, target] = await Promise.all([ advertisement(sourceUrl, null), advertisement(repo.artifacts_remote, basicAuthHeader(readSecret)), ]); const existingNames = new Set(target.map((ref) => ref.name)); const allMissing = source.filter((ref) => !existingNames.has(ref.name)); const missing = allMissing.slice(0, MAX_SYNC_REFS); const result: RefSyncResult = { created: 0, rejected: [], skipped: allMissing.length - missing.length }; if (missing.length === 0) return result; const present = new Set(target.map((ref) => ref.sha)); const wants = [...new Set(missing.map((ref) => ref.sha))].filter((sha) => !present.has(sha)); if (wants.length === 0) { await pushToArtifacts( repo, missing.map((ref) => ({ ref: ref.name, oldSha: "0".repeat(40), newSha: ref.sha })), [], ); return { ...result, created: missing.length }; } const haves = [...present].slice(0, MAX_HAVES); const upload = await fetch(`${sourceUrl.replace(/\/$/, "")}/git-upload-pack`, { method: "POST", headers: { "content-type": "application/x-git-upload-pack-request", accept: "application/x-git-upload-pack-result", "user-agent": USER_AGENT, }, body: uploadPackRequest(wants, haves), }); if (!upload.ok || !upload.body) throw badRequest(`The source refused to send objects (HTTP ${upload.status})`); const pack = await packStream(upload.body); const writeSecret = await repoTokenSecret(repo, "write"); const push = await fetch(`${repo.artifacts_remote}/git-receive-pack`, { method: "POST", headers: { authorization: basicAuthHeader(writeSecret), "content-type": "application/x-git-receive-pack-request", accept: "application/x-git-receive-pack-result", "user-agent": USER_AGENT, }, body: receivePackBody(missing, pack), }); if (!push.ok) throw new Error(`Artifacts rejected the synced refs (HTTP ${push.status}): ${(await push.text()).slice(0, 300)}`); const report = parseReportStatus(new Uint8Array(await push.arrayBuffer())); if (!report.unpackOk) throw new Error("Artifacts could not unpack the synced objects"); await invalidateRefs(repo.id); return { ...result, created: report.accepted, rejected: report.rejected }; }