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.
6.2 kB · 173 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174import { isTransientTransport } from './git-wire/errors'import { type ReceivePackFactory, ReceivePackSession, type RefUpdate, ssh2ReceivePackFactory,} from './git-wire/receive-pack'import { ZERO_SHA } from './git-wire/refs'import { fetchPack } from './git-wire/upload-pack'import { loadSshKeyForInstall } from './ssh-cmd'import { sshEndpointForKnot } from './sync-push-host'
const DEFAULT_MAX_PACK_BYTES = 1024 * 1024 * 1024/** Cap haves so a repo with thousands of refs can't bloat the negotiation. */const MAX_HAVES = 256
/** * In-job retry for the flaky knot SSH endpoint. A burst of connection resets at * the handshake typically clears within a second or two, so retrying a few * times inside the job lands the push now rather than after the queue's * minutes-long backoff (or, worse, after the attempt ceiling gives up and * leaves the mirror stale until the next push). Only transient transport * failures are retried; protocol verdicts re-throw immediately. */const TRANSPORT_RETRIES = 3const TRANSPORT_RETRY_DELAY_MS = 1_500
export async function withTransportRetry<T>(op: () => Promise<T>, delayMs = TRANSPORT_RETRY_DELAY_MS): Promise<T> { let lastErr: unknown for (let attempt = 0; attempt <= TRANSPORT_RETRIES; attempt++) { try { // eslint-disable-next-line no-await-in-loop -- sequential retries by design return await op() } catch (err) { if (!isTransientTransport(err)) throw err lastErr = err if (attempt < TRANSPORT_RETRIES) { // eslint-disable-next-line no-await-in-loop -- deliberate backoff between retries await new Promise(resolve => setTimeout(resolve, delayMs)) } } } throw lastErr}
function maxPackBytes(): number { const raw = process.env.NUXT_MAX_PACK_BYTES if (!raw) return DEFAULT_MAX_PACK_BYTES const n = Number.parseInt(raw, 10) return Number.isNaN(n) || n <= 0 ? DEFAULT_MAX_PACK_BYTES : n}
async function sshFactory(installationId: number, knot: string, repoDid: string): Promise<ReceivePackFactory> { const privateKey = await loadSshKeyForInstall(installationId) const { host, port } = sshEndpointForKnot(knot) // ssh:// path form: leading slash, the knot resolves the repo by DID. return ssh2ReceivePackFactory({ host, port, repoPath: `/${repoDid}`, privateKey })}
export interface SplicePushParams { installationId: number repoFullName: string knot: string repoDid: string /** Fully-qualified ref, e.g. `refs/heads/main`. */ ref: string /** The SHA to land on the knot. */ want: string /** GitHub installation token authorising the fetch. */ token: string}
export interface SplicePushResult { status: 'synced' | 'already-synced' sha: string}
/** * Stream a single ref update from GitHub to the knot without materialising a * repository: * * 1. open receive-pack, read the knot's tips; * 2. if the knot's tip for `ref` already equals `want`, no-op; * 3. fetch a thin pack from GitHub with the knot's tips as haves; * 4. send the compare-and-swap command and pipe the pack straight through; * 5. read report-status. * * Steps 1 and 3 share one ssh session: it sits idle for the duration of the * GitHub round-trip (receive-pack waits indefinitely for commands), which * keeps the knot's advertised tip as the authoritative compare-and-swap base. */export async function splicePush(params: SplicePushParams): Promise<SplicePushResult> { return withTransportRetry(async () => { // Fresh factory (and therefore fresh SSH connection) per attempt: a reset // connection can't be reused. const factory = await sshFactory(params.installationId, params.knot, params.repoDid) return runSplice(factory, params) })}
/** The fetch + push exchange over an open session. Split out for the wire test. */export async function runSplice( factory: ReceivePackFactory, params: { repoFullName: string, ref: string, want: string, token: string },): Promise<SplicePushResult> { const session = await ReceivePackSession.open(factory) let pushStarted = false try { const old = session.tips.get(params.ref) ?? ZERO_SHA if (old === params.want) { await session.close() return { status: 'already-synced', sha: params.want } }
const haves = [...new Set(session.tips.values())] .filter(sha => sha !== ZERO_SHA) .slice(0, MAX_HAVES)
const { pack } = await fetchPack({ repoFullName: params.repoFullName, token: params.token, want: params.want, haves, maxBytes: maxPackBytes(), })
const update: RefUpdate = { ref: params.ref, old, next: params.want } pushStarted = true await session.push([update], pack) return { status: 'synced', sha: params.want } } finally { // `push` tears the session down itself; only close here if we threw before // reaching it (e.g. the byte cap fired inside fetchPack's stream). if (!pushStarted) await session.close() }}
export interface SpliceDeleteResult { status: 'synced' | 'already-absent'}
/** * Delete a ref on the knot. No GitHub leg and no pack: read the knot's * advertisement, and if the ref is absent we're already done (idempotent). * Otherwise send a delete command with the advertised value as the * compare-and-swap base. */export async function spliceDelete(params: { installationId: number knot: string repoDid: string ref: string}): Promise<SpliceDeleteResult> { return withTransportRetry(async () => { const factory = await sshFactory(params.installationId, params.knot, params.repoDid) return runSpliceDelete(factory, params.ref) })}
/** The delete exchange over an open session. Split out for the wire test. */export async function runSpliceDelete(factory: ReceivePackFactory, ref: string): Promise<SpliceDeleteResult> { const session = await ReceivePackSession.open(factory) const old = session.tips.get(ref) if (!old || old === ZERO_SHA) { await session.close() return { status: 'already-absent' } } // push() owns teardown for the success and rejection paths. await session.push([{ ref, old, next: ZERO_SHA }], null) return { status: 'synced' }}