Something went wrong. Try again.
mirror your GitHub repos to tangled.org automatically synchub.to
mirror sync tangled github git
Something went wrong. Try again.
4.9 kB · 134 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135import { Buffer } from 'node:buffer'import { RemoteRejectedError, WireError } from './errors'import { encodePktLine, flushPkt, lineToString, PktLineReader } from './pkt-line'import { type Advertisement, parseAdvertisement } from './refs'
const AGENT = 'synchub.to'const ADVERTISEMENT_TIMEOUT_MS = 30_000
function repoUrl(repoFullName: string): string { return `https://github.com/${repoFullName}.git`}
function authHeader(token: string): string { return `Basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}`}
async function* streamBytes(body: ReadableStream<Uint8Array>): AsyncGenerator<Buffer> { const reader = body.getReader() try { for (;;) { // eslint-disable-next-line no-await-in-loop -- sequential drain of the response body const { value, done } = await reader.read() if (done) return if (value) yield Buffer.from(value) } } finally { reader.releaseLock() }}
/** * Fetch GitHub's `git-upload-pack` ref advertisement over smart HTTP. We need * this both to resolve a ref name to a SHA (create-ref path) and, more * generally, to learn the capability set before negotiating. */export async function fetchAdvertisement(repoFullName: string, token: string): Promise<Advertisement> { const url = `${repoUrl(repoFullName)}/info/refs?service=git-upload-pack` const res = await fetch(url, { headers: { Authorization: authHeader(token), // Pin protocol v0; v2 would frame the advertisement differently. 'Git-Protocol': 'version=0', }, signal: AbortSignal.timeout(ADVERTISEMENT_TIMEOUT_MS), }) if (!res.ok || !res.body) { throw new WireError(`github info/refs failed: ${res.status} ${res.statusText}`) } const reader = new PktLineReader(streamBytes(res.body)) const lines = await reader.readUntilFlush() // The first flush ends the `# service` banner; the advertisement follows. const adv = await reader.readUntilFlush() return parseAdvertisement([...(lines ?? []), ...(adv ?? [])])}
export interface FetchPackOptions { repoFullName: string token: string /** SHA we want fetched. Requires GitHub's allow-reachable-sha1-in-want. */ want: string /** Knot ref tips to advertise as haves so GitHub sends a thin delta. */ haves: string[] /** Abort and throw `too-big` once the pack exceeds this many bytes. */ maxBytes: number}
export interface FetchPackResult { /** Raw packfile bytes. Pipe straight into receive-pack; do not buffer. */ pack: AsyncGenerator<Buffer>}
/** * Negotiate a thin pack from GitHub for `want`, advertising `haves` so the * server deltas against objects the knot already holds. Returns a streaming * generator of the raw packfile bytes; the caller pipes them into * receive-pack and never materialises them. * * Protocol v0, no side-band: after the single NAK/ACK pkt-line the response * body is the raw packfile to EOF, which is exactly what we forward. */export async function fetchPack(opts: FetchPackOptions): Promise<FetchPackResult> { const { repoFullName, token, want, haves, maxBytes } = opts
const wantLine = `want ${want} thin-pack ofs-delta agent=${AGENT}/1\n` const body: Buffer[] = [encodePktLine(wantLine), flushPkt] for (const have of haves) { body.push(encodePktLine(`have ${have}\n`)) } body.push(encodePktLine('done\n'))
const res = await fetch(`${repoUrl(repoFullName)}/git-upload-pack`, { method: 'POST', headers: { Authorization: authHeader(token), 'Content-Type': 'application/x-git-upload-pack-request', 'Accept': 'application/x-git-upload-pack-result', 'Git-Protocol': 'version=0', }, body: Buffer.concat(body), }) if (!res.ok || !res.body) { throw new WireError(`github git-upload-pack failed: ${res.status} ${res.statusText}`) }
const reader = new PktLineReader(streamBytes(res.body)) // Read the negotiation result: one ACK/NAK line, or an ERR line on failure. const ack = await reader.next() if (ack === null || ack.type === 'flush') { throw new WireError('github git-upload-pack: empty negotiation response') } const ackStr = lineToString(ack.data) if (ackStr.startsWith('ERR ')) { // `ERR upload-pack: not our ref` is a propagation race on GitHub's side; // surface as a plain WireError so the queue retries with backoff. throw new WireError(`github git-upload-pack: ${ackStr.slice(4)}`) } if (!ackStr.startsWith('ACK') && !ackStr.startsWith('NAK')) { throw new WireError(`github git-upload-pack: unexpected negotiation line ${JSON.stringify(ackStr)}`) }
async function* capped(): AsyncGenerator<Buffer> { let total = 0 for await (const chunk of reader.remaining()) { total += chunk.length if (total > maxBytes) { throw new RemoteRejectedError(`pack exceeded ${maxBytes} bytes`, 'too-big') } yield chunk } }
return { pack: capped() }}