···11+/**
22+ * Typed failures from the git wire splice. `reason` drives the worker's
33+ * retry-vs-give-up decision in `sync-push.ts` / `sync-ref.ts`.
44+ *
55+ * - repo-gone the knot no longer has the repo (or our key was revoked
66+ * such that it reports "not found"); terminal, mark error.
77+ * - auth-rejected ssh public-key auth refused; terminal, mark error.
88+ * - stale-old-sha our compare-and-swap lost: the knot's ref moved between
99+ * reading its advertisement and sending the command, or a
1010+ * concurrent worker won. Transient; retry re-reads the tip.
1111+ * - too-big the pack exceeded the configured byte cap; terminal, it
1212+ * will never fit.
1313+ * - other anything unclassified; transient, let the queue retry.
1414+ */
1515+export type WireFailureReason
1616+ = | 'repo-gone'
1717+ | 'auth-rejected'
1818+ | 'stale-old-sha'
1919+ | 'too-big'
2020+ | 'other'
2121+2222+export class WireError extends Error {
2323+ constructor(message: string) {
2424+ super(message)
2525+ this.name = 'WireError'
2626+ }
2727+}
2828+2929+export class RemoteRejectedError extends WireError {
3030+ constructor(message: string, public readonly reason: WireFailureReason) {
3131+ super(message)
3232+ this.name = 'RemoteRejectedError'
3333+ }
3434+}
3535+3636+/**
3737+ * Classify ssh / sshd / knot stderr (the child process's stderr band, since
3838+ * we deliberately do not request side-band multiplexing). Returns null when
3939+ * nothing matches so the caller can fall back to a generic transient error.
4040+ */
4141+export function classifySshStderr(stderr: string): RemoteRejectedError | null {
4242+ const lc = stderr.toLowerCase()
4343+ if (lc.includes('repository not found') || lc.includes('does not exist') || lc.includes('does not appear to be a git repository')) {
4444+ return new RemoteRejectedError(stderr.trim(), 'repo-gone')
4545+ }
4646+ if (lc.includes('permission denied') || (lc.includes('publickey') && lc.includes('denied'))) {
4747+ return new RemoteRejectedError(stderr.trim(), 'auth-rejected')
4848+ }
4949+ return null
5050+}
5151+5252+/**
5353+ * Classify a receive-pack `ng <ref> <reason>` rejection. Any rejection that
5454+ * means "the ref's current value is not what you said" maps to stale-old-sha
5555+ * so the worker retries against a fresh advertisement. git phrases this two
5656+ * ways: `non-fast-forward` / `stale info` when updating a moved ref, and
5757+ * `failed to update ref` (stderr: "reference already exists") when our command
5858+ * claimed a create but the ref already exists.
5959+ */
6060+export function classifyNgReason(reason: string): RemoteRejectedError {
6161+ const lc = reason.toLowerCase()
6262+ if (
6363+ lc.includes('non-fast-forward')
6464+ || lc.includes('fetch first')
6565+ || lc.includes('stale info')
6666+ || lc.includes('not a fast forward')
6767+ || lc.includes('failed to update ref')
6868+ || lc.includes('reference already exists')
6969+ ) {
7070+ return new RemoteRejectedError(reason.trim(), 'stale-old-sha')
7171+ }
7272+ if (lc.includes('not found') || lc.includes('does not exist')) {
7373+ return new RemoteRejectedError(reason.trim(), 'repo-gone')
7474+ }
7575+ return new RemoteRejectedError(reason.trim(), 'other')
7676+}
+158
server/utils/git-wire/pkt-line.ts
···11+/**
22+ * Git pkt-line framing (protocol v0). A pkt-line is a 4-hex-digit length
33+ * prefix (counting the 4 prefix bytes themselves) followed by that many bytes
44+ * of payload. `0000` is the flush-pkt: a section delimiter carrying no
55+ * payload. Lengths `0001`-`0003` are reserved and invalid in v0.
66+ *
77+ * See `Documentation/gitprotocol-common.txt` in git.git.
88+ */
99+1010+const FLUSH = '0000'
1111+const MAX_DATA = 65516
1212+1313+export const flushPkt: Buffer = Buffer.from(FLUSH, 'ascii')
1414+1515+/**
1616+ * Frame a payload as a pkt-line. Accepts a string (encoded UTF-8) or raw
1717+ * bytes. Does NOT append a trailing newline; callers that want the
1818+ * conventional `\n` (command and capability lines) must include it.
1919+ */
2020+export function encodePktLine(data: string | Buffer): Buffer {
2121+ const payload = typeof data === 'string' ? Buffer.from(data, 'utf8') : data
2222+ if (payload.length > MAX_DATA) {
2323+ throw new RangeError(`pkt-line payload too large: ${payload.length} > ${MAX_DATA}`)
2424+ }
2525+ const len = payload.length + 4
2626+ const prefix = len.toString(16).padStart(4, '0')
2727+ return Buffer.concat([Buffer.from(prefix, 'ascii'), payload])
2828+}
2929+3030+export type PktLine =
3131+ | { type: 'line', data: Buffer }
3232+ | { type: 'flush' }
3333+3434+/**
3535+ * Incrementally decode pkt-lines from a byte source, then hand back whatever
3636+ * raw bytes follow the section we consumed.
3737+ *
3838+ * The git smart protocol switches from pkt-line framing to a raw packfile
3939+ * stream mid-response (after the NAK/ACK line on a fetch). A naive reader that
4040+ * buffers ahead would swallow the first chunk of the pack, so this reader
4141+ * tracks exactly how much it has consumed and exposes the remainder via
4242+ * `remaining()`.
4343+ */
4444+export class PktLineReader {
4545+ private buf: Buffer = Buffer.alloc(0)
4646+ private done = false
4747+ private readonly iter: AsyncIterator<Buffer>
4848+4949+ constructor(source: AsyncIterable<Buffer | Uint8Array>) {
5050+ this.iter = (async function* normalise() {
5151+ for await (const chunk of source) {
5252+ yield Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
5353+ }
5454+ })()
5555+ }
5656+5757+ /**
5858+ * Read the next pkt-line, or `null` at end of stream. A flush-pkt is
5959+ * returned as `{ type: 'flush' }` rather than ending iteration; the wire
6060+ * protocol uses multiple flush-delimited sections per stream.
6161+ */
6262+ async next(): Promise<PktLine | null> {
6363+ while (this.buf.length < 4) {
6464+ // eslint-disable-next-line no-await-in-loop -- each fill extends a shared buffer the next iteration inspects; the reads are inherently sequential
6565+ if (!(await this.fill())) {
6666+ if (this.buf.length === 0) return null
6767+ throw new Error('unexpected end of stream: truncated pkt-line length')
6868+ }
6969+ }
7070+7171+ const len = Number.parseInt(this.buf.toString('ascii', 0, 4), 16)
7272+ if (Number.isNaN(len)) {
7373+ throw new Error(`invalid pkt-line length: ${JSON.stringify(this.buf.toString('ascii', 0, 4))}`)
7474+ }
7575+ if (len === 0) {
7676+ this.buf = this.buf.subarray(4)
7777+ return { type: 'flush' }
7878+ }
7979+ if (len < 4) {
8080+ throw new Error(`reserved pkt-line length ${len} is invalid in protocol v0`)
8181+ }
8282+8383+ while (this.buf.length < len) {
8484+ // eslint-disable-next-line no-await-in-loop -- sequential read; see next()
8585+ if (!(await this.fill())) {
8686+ throw new Error(`unexpected end of stream: pkt-line wanted ${len} bytes, had ${this.buf.length}`)
8787+ }
8888+ }
8989+9090+ const data = this.buf.subarray(4, len)
9191+ this.buf = this.buf.subarray(len)
9292+ return { type: 'line', data }
9393+ }
9494+9595+ /**
9696+ * Read pkt-lines up to and including the next flush-pkt, returning the line
9797+ * payloads (flush excluded). Returns `null` if the stream ends before any
9898+ * line is read.
9999+ */
100100+ async readUntilFlush(): Promise<Buffer[] | null> {
101101+ const lines: Buffer[] = []
102102+ for (;;) {
103103+ // eslint-disable-next-line no-await-in-loop -- sequential read; see next()
104104+ const pkt = await this.next()
105105+ if (pkt === null) return lines.length > 0 ? lines : null
106106+ if (pkt.type === 'flush') return lines
107107+ lines.push(pkt.data)
108108+ }
109109+ }
110110+111111+ /**
112112+ * The bytes already buffered past the last consumed pkt-line. Used to seed
113113+ * the raw packfile stream once negotiation framing ends.
114114+ */
115115+ buffered(): Buffer {
116116+ return this.buf
117117+ }
118118+119119+ /**
120120+ * Yield the remainder of the source as a raw byte stream: first any bytes
121121+ * already buffered, then the rest of the underlying iterator verbatim. After
122122+ * calling this, do not call `next()` again.
123123+ */
124124+ async *remaining(): AsyncGenerator<Buffer> {
125125+ if (this.buf.length > 0) {
126126+ yield this.buf
127127+ this.buf = Buffer.alloc(0)
128128+ }
129129+ if (this.done) return
130130+ for (;;) {
131131+ // eslint-disable-next-line no-await-in-loop -- sequential drain of the source iterator
132132+ const { value, done } = await this.iter.next()
133133+ if (done) {
134134+ this.done = true
135135+ return
136136+ }
137137+ yield value
138138+ }
139139+ }
140140+141141+ private async fill(): Promise<boolean> {
142142+ if (this.done) return false
143143+ const { value, done } = await this.iter.next()
144144+ if (done) {
145145+ this.done = true
146146+ return false
147147+ }
148148+ this.buf = this.buf.length === 0 ? value : Buffer.concat([this.buf, value])
149149+ return true
150150+ }
151151+}
152152+153153+/** Decode a single line's payload as a UTF-8 string with any trailing `\n` removed. */
154154+export function lineToString(data: Buffer): string {
155155+ return data.length > 0 && data[data.length - 1] === 0x0A
156156+ ? data.toString('utf8', 0, data.length - 1)
157157+ : data.toString('utf8')
158158+}
+210
server/utils/git-wire/receive-pack.ts
···11+import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'
22+import { Readable } from 'node:stream'
33+import { classifyNgReason, classifySshStderr, RemoteRejectedError, WireError } from './errors'
44+import { encodePktLine, flushPkt, lineToString, PktLineReader } from './pkt-line'
55+import { type Advertisement, parseAdvertisement, ZERO_SHA } from './refs'
66+77+const AGENT = 'synchub.to'
88+const STDERR_CAP = 16 * 1024
99+/**
1010+ * Whole-session budget. receive-pack blocks indefinitely waiting for commands,
1111+ * so without this a knot that accepts the connection but stalls mid-protocol
1212+ * would hang a worker until its job lease expires. On expiry we SIGKILL the
1313+ * child; the in-flight read sees the stream end and throws, surfacing as a
1414+ * transient failure the queue retries.
1515+ */
1616+const SESSION_TIMEOUT_MS = 120_000
1717+1818+export interface RefUpdate {
1919+ ref: string
2020+ /** Current value on the knot, or the zero SHA to create. The compare-and-swap. */
2121+ old: string
2222+ /** New value, or the zero SHA to delete. */
2323+ next: string
2424+}
2525+2626+/**
2727+ * A spawned process exposing `git-receive-pack`'s stdio. The default factory
2828+ * runs ssh to the knot; tests inject a factory that spawns the binary against
2929+ * a local bare repo, so the stdio protocol is identical either way.
3030+ */
3131+export interface ReceivePackProcess {
3232+ stdin: NodeJS.WritableStream
3333+ stdout: AsyncIterable<Buffer>
3434+ /** Last bytes of stderr, for diagnostics (we don't request side-band). */
3535+ stderr(): string
3636+ kill(): void
3737+ /** Resolves with the exit code once the process ends. */
3838+ done: Promise<number | null>
3939+}
4040+4141+export type ReceivePackFactory = () => ReceivePackProcess
4242+4343+export interface SshTarget {
4444+ host: string
4545+ port?: number
4646+ repoPath: string
4747+ sshArgs: string[]
4848+}
4949+5050+/** Default transport: ssh to the knot and invoke its `git-receive-pack`. */
5151+export function sshReceivePackFactory(target: SshTarget): ReceivePackFactory {
5252+ return () => {
5353+ const portArgs = target.port ? ['-p', String(target.port)] : []
5454+ // ssh:// transports invoke the remote command with the path including its
5555+ // leading slash, single-quoted. The knot resolves repos by that path.
5656+ const remoteCmd = `git-receive-pack '${target.repoPath}'`
5757+ const child = spawn('ssh', [...target.sshArgs, ...portArgs, `git@${target.host}`, remoteCmd], {
5858+ stdio: ['pipe', 'pipe', 'pipe'],
5959+ })
6060+ return wrapChild(child)
6161+ }
6262+}
6363+6464+function wrapChild(child: ChildProcessWithoutNullStreams): ReceivePackProcess {
6565+ let stderrBuf = Buffer.alloc(0)
6666+ child.stderr.on('data', (chunk: Buffer) => {
6767+ stderrBuf = Buffer.concat([stderrBuf, chunk]).subarray(-STDERR_CAP)
6868+ })
6969+ const done = new Promise<number | null>(resolve => child.on('close', resolve))
7070+ return {
7171+ stdin: child.stdin,
7272+ stdout: child.stdout,
7373+ stderr: () => stderrBuf.toString('utf8'),
7474+ kill: () => child.kill('SIGKILL'),
7575+ done,
7676+ }
7777+}
7878+7979+/**
8080+ * An open receive-pack session. Read `tips` after construction to learn the
8181+ * knot's current refs (needed as haves and as the compare-and-swap base),
8282+ * then call `push` once with the commands and packfile.
8383+ */
8484+export class ReceivePackSession {
8585+ readonly tips: Map<string, string>
8686+ readonly capabilities: Set<string>
8787+8888+ private readonly watchdog: NodeJS.Timeout
8989+9090+ private constructor(
9191+ private readonly proc: ReceivePackProcess,
9292+ private readonly reader: PktLineReader,
9393+ adv: Advertisement,
9494+ watchdog: NodeJS.Timeout,
9595+ ) {
9696+ this.tips = adv.refs
9797+ this.capabilities = adv.capabilities
9898+ this.watchdog = watchdog
9999+ }
100100+101101+ /** Open the session and read the advertisement. */
102102+ static async open(factory: ReceivePackFactory, timeoutMs = SESSION_TIMEOUT_MS): Promise<ReceivePackSession> {
103103+ const proc = factory()
104104+ const watchdog = setTimeout(() => proc.kill(), timeoutMs)
105105+ try {
106106+ const reader = new PktLineReader(proc.stdout)
107107+ const advLines = await reader.readUntilFlush()
108108+ if (advLines === null) {
109109+ const err = classifySshStderr(proc.stderr())
110110+ throw err ?? new WireError(`receive-pack: no advertisement (stderr: ${proc.stderr().trim() || 'empty'})`)
111111+ }
112112+ const adv = parseAdvertisement(advLines)
113113+ assertCapabilities(adv)
114114+ return new ReceivePackSession(proc, reader, adv, watchdog)
115115+ }
116116+ catch (err) {
117117+ clearTimeout(watchdog)
118118+ proc.kill()
119119+ await proc.done.catch(() => null)
120120+ throw err
121121+ }
122122+ }
123123+124124+ /**
125125+ * Send the ref update commands plus (for non-deletions) the packfile, then
126126+ * read and validate report-status. The packfile is streamed straight from
127127+ * `packStream` into stdin; nothing is buffered. Pass `null` for pure
128128+ * deletions.
129129+ */
130130+ async push(updates: RefUpdate[], packStream: AsyncIterable<Buffer> | null): Promise<void> {
131131+ if (updates.length === 0) throw new WireError('receive-pack: no updates')
132132+ try {
133133+ await writeAll(this.proc.stdin, buildCommandList(updates))
134134+ if (packStream) await pipePack(this.proc.stdin, packStream)
135135+ else this.proc.stdin.end()
136136+137137+ const report = await this.reader.readUntilFlush()
138138+ parseReportStatus(report ?? [], updates, this.proc.stderr())
139139+ }
140140+ finally {
141141+ clearTimeout(this.watchdog)
142142+ this.proc.kill()
143143+ await this.proc.done.catch(() => null)
144144+ }
145145+ }
146146+147147+ /** Close the session without pushing (advertisement-only use). */
148148+ async close(): Promise<void> {
149149+ clearTimeout(this.watchdog)
150150+ this.proc.stdin.end()
151151+ this.proc.kill()
152152+ await this.proc.done.catch(() => null)
153153+ }
154154+}
155155+156156+function assertCapabilities(adv: Advertisement): void {
157157+ if (!adv.capabilities.has('report-status')) {
158158+ throw new WireError('knot receive-pack does not advertise report-status')
159159+ }
160160+}
161161+162162+function buildCommandList(updates: RefUpdate[]): Buffer {
163163+ const parts: Buffer[] = []
164164+ updates.forEach((u, i) => {
165165+ const caps = i === 0 ? `\0report-status agent=${AGENT}/1` : ''
166166+ parts.push(encodePktLine(`${u.old} ${u.next} ${u.ref}${caps}\n`))
167167+ })
168168+ parts.push(flushPkt)
169169+ return Buffer.concat(parts)
170170+}
171171+172172+function parseReportStatus(lines: Buffer[], updates: RefUpdate[], stderr: string): void {
173173+ if (lines.length === 0) {
174174+ const err = classifySshStderr(stderr)
175175+ throw err ?? new WireError(`receive-pack: empty report-status (stderr: ${stderr.trim() || 'empty'})`)
176176+ }
177177+ const unpack = lineToString(lines[0]!)
178178+ if (unpack !== 'unpack ok') {
179179+ throw new WireError(`receive-pack: ${unpack}`)
180180+ }
181181+ for (const raw of lines.slice(1)) {
182182+ const line = lineToString(raw)
183183+ if (line.startsWith('ng ')) {
184184+ // `ng <ref> <reason>`
185185+ const rest = line.slice(3)
186186+ const sp = rest.indexOf(' ')
187187+ const reason = sp === -1 ? rest : rest.slice(sp + 1)
188188+ throw classifyNgReason(reason)
189189+ }
190190+ }
191191+}
192192+193193+async function writeAll(stream: NodeJS.WritableStream, data: Buffer): Promise<void> {
194194+ await new Promise<void>((resolve, reject) => {
195195+ stream.write(data, err => (err ? reject(err) : resolve()))
196196+ })
197197+}
198198+199199+async function pipePack(stdin: NodeJS.WritableStream, packStream: AsyncIterable<Buffer>): Promise<void> {
200200+ const src = Readable.from(packStream)
201201+ await new Promise<void>((resolve, reject) => {
202202+ src.on('error', reject)
203203+ stdin.on('error', reject)
204204+ src.pipe(stdin, { end: true })
205205+ stdin.on('finish', resolve)
206206+ stdin.on('close', resolve)
207207+ })
208208+}
209209+210210+export { RemoteRejectedError, ZERO_SHA }
+69
server/utils/git-wire/refs.ts
···11+import { lineToString } from './pkt-line'
22+33+const ZERO_SHA = '0000000000000000000000000000000000000000'
44+55+export interface Advertisement {
66+ /** Ref name -> object SHA (unpeeled). For annotated tags this is the tag object. */
77+ refs: Map<string, string>
88+ /** For annotated tags, the `<tag>^{}` peeled line: ref name -> commit SHA. */
99+ peeled: Map<string, string>
1010+ capabilities: Set<string>
1111+}
1212+1313+/**
1414+ * Parse a git ref advertisement (protocol v0) from a list of pkt-line
1515+ * payloads (flush-pkts already stripped by the reader).
1616+ *
1717+ * Handles three shapes that occur in practice:
1818+ * - smart-HTTP prelude: a leading `# service=git-upload-pack` line, which
1919+ * the ssh transport omits;
2020+ * - a populated repo: `<sha> <refname>\0<caps>` on the first ref line,
2121+ * `<sha> <refname>` thereafter, with `<sha> <refname>^{}` peeled lines
2222+ * for annotated tags;
2323+ * - an empty repo: a single `<zero-sha> capabilities^{}\0<caps>` line that
2424+ * carries capabilities but advertises no usable ref.
2525+ */
2626+export function parseAdvertisement(lines: Buffer[]): Advertisement {
2727+ const refs = new Map<string, string>()
2828+ const peeled = new Map<string, string>()
2929+ const capabilities = new Set<string>()
3030+3131+ let first = true
3232+ for (const raw of lines) {
3333+ const line = lineToString(raw)
3434+ if (line.startsWith('# service=')) continue
3535+3636+ let sha: string
3737+ let rest: string
3838+ if (first) {
3939+ const nul = line.indexOf('\0')
4040+ const head = nul === -1 ? line : line.slice(0, nul)
4141+ const caps = nul === -1 ? '' : line.slice(nul + 1)
4242+ for (const cap of caps.split(' ')) {
4343+ if (cap) capabilities.add(cap)
4444+ }
4545+ first = false
4646+ const sp = head.indexOf(' ')
4747+ sha = head.slice(0, sp)
4848+ rest = head.slice(sp + 1)
4949+ // Empty-repo sentinel: zero SHA + the literal "capabilities^{}" name.
5050+ if (sha === ZERO_SHA && rest === 'capabilities^{}') continue
5151+ }
5252+ else {
5353+ const sp = line.indexOf(' ')
5454+ sha = line.slice(0, sp)
5555+ rest = line.slice(sp + 1)
5656+ }
5757+5858+ if (rest.endsWith('^{}')) {
5959+ peeled.set(rest.slice(0, -3), sha)
6060+ }
6161+ else {
6262+ refs.set(rest, sha)
6363+ }
6464+ }
6565+6666+ return { refs, peeled, capabilities }
6767+}
6868+6969+export { ZERO_SHA }
+134
server/utils/git-wire/upload-pack.ts
···11+import { Buffer } from 'node:buffer'
22+import { RemoteRejectedError, WireError } from './errors'
33+import { encodePktLine, flushPkt, lineToString, PktLineReader } from './pkt-line'
44+import { type Advertisement, parseAdvertisement } from './refs'
55+66+const AGENT = 'synchub.to'
77+const ADVERTISEMENT_TIMEOUT_MS = 30_000
88+99+function repoUrl(repoFullName: string): string {
1010+ return `https://github.com/${repoFullName}.git`
1111+}
1212+1313+function authHeader(token: string): string {
1414+ return `Basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}`
1515+}
1616+1717+async function* streamBytes(body: ReadableStream<Uint8Array>): AsyncGenerator<Buffer> {
1818+ const reader = body.getReader()
1919+ try {
2020+ for (;;) {
2121+ // eslint-disable-next-line no-await-in-loop -- sequential drain of the response body
2222+ const { value, done } = await reader.read()
2323+ if (done) return
2424+ if (value) yield Buffer.from(value)
2525+ }
2626+ }
2727+ finally {
2828+ reader.releaseLock()
2929+ }
3030+}
3131+3232+/**
3333+ * Fetch GitHub's `git-upload-pack` ref advertisement over smart HTTP. We need
3434+ * this both to resolve a ref name to a SHA (create-ref path) and, more
3535+ * generally, to learn the capability set before negotiating.
3636+ */
3737+export async function fetchAdvertisement(repoFullName: string, token: string): Promise<Advertisement> {
3838+ const url = `${repoUrl(repoFullName)}/info/refs?service=git-upload-pack`
3939+ const res = await fetch(url, {
4040+ headers: {
4141+ Authorization: authHeader(token),
4242+ // Pin protocol v0; v2 would frame the advertisement differently.
4343+ 'Git-Protocol': 'version=0',
4444+ },
4545+ signal: AbortSignal.timeout(ADVERTISEMENT_TIMEOUT_MS),
4646+ })
4747+ if (!res.ok || !res.body) {
4848+ throw new WireError(`github info/refs failed: ${res.status} ${res.statusText}`)
4949+ }
5050+ const reader = new PktLineReader(streamBytes(res.body))
5151+ const lines = await reader.readUntilFlush()
5252+ // The first flush ends the `# service` banner; the advertisement follows.
5353+ const adv = await reader.readUntilFlush()
5454+ return parseAdvertisement([...(lines ?? []), ...(adv ?? [])])
5555+}
5656+5757+export interface FetchPackOptions {
5858+ repoFullName: string
5959+ token: string
6060+ /** SHA we want fetched. Requires GitHub's allow-reachable-sha1-in-want. */
6161+ want: string
6262+ /** Knot ref tips to advertise as haves so GitHub sends a thin delta. */
6363+ haves: string[]
6464+ /** Abort and throw `too-big` once the pack exceeds this many bytes. */
6565+ maxBytes: number
6666+}
6767+6868+export interface FetchPackResult {
6969+ /** Raw packfile bytes. Pipe straight into receive-pack; do not buffer. */
7070+ pack: AsyncGenerator<Buffer>
7171+}
7272+7373+/**
7474+ * Negotiate a thin pack from GitHub for `want`, advertising `haves` so the
7575+ * server deltas against objects the knot already holds. Returns a streaming
7676+ * generator of the raw packfile bytes; the caller pipes them into
7777+ * receive-pack and never materialises them.
7878+ *
7979+ * Protocol v0, no side-band: after the single NAK/ACK pkt-line the response
8080+ * body is the raw packfile to EOF, which is exactly what we forward.
8181+ */
8282+export async function fetchPack(opts: FetchPackOptions): Promise<FetchPackResult> {
8383+ const { repoFullName, token, want, haves, maxBytes } = opts
8484+8585+ const wantLine = `want ${want} thin-pack ofs-delta agent=${AGENT}/1\n`
8686+ const body: Buffer[] = [encodePktLine(wantLine), flushPkt]
8787+ for (const have of haves) {
8888+ body.push(encodePktLine(`have ${have}\n`))
8989+ }
9090+ body.push(encodePktLine('done\n'))
9191+9292+ const res = await fetch(`${repoUrl(repoFullName)}/git-upload-pack`, {
9393+ method: 'POST',
9494+ headers: {
9595+ Authorization: authHeader(token),
9696+ 'Content-Type': 'application/x-git-upload-pack-request',
9797+ 'Accept': 'application/x-git-upload-pack-result',
9898+ 'Git-Protocol': 'version=0',
9999+ },
100100+ body: Buffer.concat(body),
101101+ })
102102+ if (!res.ok || !res.body) {
103103+ throw new WireError(`github git-upload-pack failed: ${res.status} ${res.statusText}`)
104104+ }
105105+106106+ const reader = new PktLineReader(streamBytes(res.body))
107107+ // Read the negotiation result: one ACK/NAK line, or an ERR line on failure.
108108+ const ack = await reader.next()
109109+ if (ack === null || ack.type === 'flush') {
110110+ throw new WireError('github git-upload-pack: empty negotiation response')
111111+ }
112112+ const ackStr = lineToString(ack.data)
113113+ if (ackStr.startsWith('ERR ')) {
114114+ // `ERR upload-pack: not our ref` is a propagation race on GitHub's side;
115115+ // surface as a plain WireError so the queue retries with backoff.
116116+ throw new WireError(`github git-upload-pack: ${ackStr.slice(4)}`)
117117+ }
118118+ if (!ackStr.startsWith('ACK') && !ackStr.startsWith('NAK')) {
119119+ throw new WireError(`github git-upload-pack: unexpected negotiation line ${JSON.stringify(ackStr)}`)
120120+ }
121121+122122+ async function* capped(): AsyncGenerator<Buffer> {
123123+ let total = 0
124124+ for await (const chunk of reader.remaining()) {
125125+ total += chunk.length
126126+ if (total > maxBytes) {
127127+ throw new RemoteRejectedError(`pack exceeded ${maxBytes} bytes`, 'too-big')
128128+ }
129129+ yield chunk
130130+ }
131131+ }
132132+133133+ return { pack: capped() }
134134+}
-55
server/utils/git.ts
···11-import { execa, type Options } from 'execa'
22-33-/**
44- * Thin wrapper over `execa` for invoking the system `git` binary with
55- * predictable defaults.
66- *
77- * - Forces non-interactive mode so a misconfigured ssh setup never hangs
88- * waiting for a passphrase or `yes/no` prompt.
99- * - Captures stderr so callers can produce useful error messages.
1010- * - Adds a default 60s timeout; callers can override via `options.timeout`.
1111- */
1212-export async function git(args: string[], options: Options = {}): Promise<{ stdout: string, stderr: string }> {
1313- const result = await execa('git', args, {
1414- timeout: 60_000,
1515- ...options,
1616- env: {
1717- // Belt and braces against interactive prompts. `GIT_TERMINAL_PROMPT=0`
1818- // makes git fail rather than hang if it would otherwise ask for input
1919- // (e.g. credentials).
2020- GIT_TERMINAL_PROMPT: '0',
2121- // Don't pick up the running user's ssh config / known_hosts. The caller
2222- // supplies a complete GIT_SSH_COMMAND for ssh transports.
2323- GIT_CONFIG_NOSYSTEM: '1',
2424- ...options.env,
2525- },
2626- // Buffer (default) is fine for small operations; for very large fetches
2727- // we'd want to stream stderr instead.
2828- reject: true,
2929- all: true,
3030- })
3131- return { stdout: String(result.stdout), stderr: String(result.stderr) }
3232-}
3333-3434-/**
3535- * Recognised remote rejection patterns from the knot when a repo no longer
3636- * exists or our key has been revoked. Surfaces as a typed error so the
3737- * worker can mark the mapping as terminally failed rather than retry forever.
3838- */
3939-export class RemoteRejectedPushError extends Error {
4040- constructor(message: string, public readonly reason: 'repo-gone' | 'auth-rejected' | 'other') {
4141- super(message)
4242- this.name = 'RemoteRejectedPushError'
4343- }
4444-}
4545-4646-export function classifyPushFailure(stderr: string): RemoteRejectedPushError | null {
4747- const lc = stderr.toLowerCase()
4848- if (lc.includes('repository not found') || lc.includes('does not exist') || lc.includes('does not appear to be a git repository')) {
4949- return new RemoteRejectedPushError(stderr.trim(), 'repo-gone')
5050- }
5151- if (lc.includes('permission denied') || lc.includes('publickey') && lc.includes('denied')) {
5252- return new RemoteRejectedPushError(stderr.trim(), 'auth-rejected')
5353- }
5454- return null
5555-}
+152
server/utils/splice.ts
···11+import {
22+ type ReceivePackFactory,
33+ ReceivePackSession,
44+ type RefUpdate,
55+ sshReceivePackFactory,
66+} from './git-wire/receive-pack'
77+import { ZERO_SHA } from './git-wire/refs'
88+import { fetchAdvertisement, fetchPack } from './git-wire/upload-pack'
99+import { loadSshArgsForInstall } from './ssh-cmd'
1010+import { sshEndpointForKnot } from './sync-push-host'
1111+1212+const DEFAULT_MAX_PACK_BYTES = 1024 * 1024 * 1024
1313+/** Cap haves so a repo with thousands of refs can't bloat the negotiation. */
1414+const MAX_HAVES = 256
1515+1616+function maxPackBytes(): number {
1717+ const raw = process.env.NUXT_MAX_PACK_BYTES
1818+ if (!raw) return DEFAULT_MAX_PACK_BYTES
1919+ const n = Number.parseInt(raw, 10)
2020+ return Number.isNaN(n) || n <= 0 ? DEFAULT_MAX_PACK_BYTES : n
2121+}
2222+2323+async function sshFactory(installationId: number, knot: string, repoDid: string): Promise<{
2424+ factory: ReceivePackFactory
2525+ cleanup: () => void
2626+}> {
2727+ const { args, cleanup } = await loadSshArgsForInstall(installationId)
2828+ const { host, port } = sshEndpointForKnot(knot)
2929+ // ssh:// path form: leading slash, the knot resolves the repo by DID.
3030+ const factory = sshReceivePackFactory({ host, port, repoPath: `/${repoDid}`, sshArgs: args })
3131+ return { factory, cleanup }
3232+}
3333+3434+export interface SplicePushParams {
3535+ installationId: number
3636+ repoFullName: string
3737+ knot: string
3838+ repoDid: string
3939+ /** Fully-qualified ref, e.g. `refs/heads/main`. */
4040+ ref: string
4141+ /** The SHA to land on the knot. */
4242+ want: string
4343+ /** GitHub installation token authorising the fetch. */
4444+ token: string
4545+}
4646+4747+export interface SplicePushResult {
4848+ status: 'synced' | 'already-synced'
4949+ sha: string
5050+}
5151+5252+/**
5353+ * Stream a single ref update from GitHub to the knot without materialising a
5454+ * repository:
5555+ *
5656+ * 1. open receive-pack, read the knot's tips;
5757+ * 2. if the knot's tip for `ref` already equals `want`, no-op;
5858+ * 3. fetch a thin pack from GitHub with the knot's tips as haves;
5959+ * 4. send the compare-and-swap command and pipe the pack straight through;
6060+ * 5. read report-status.
6161+ *
6262+ * Steps 1 and 3 share one ssh session: it sits idle for the duration of the
6363+ * GitHub round-trip (receive-pack waits indefinitely for commands), which
6464+ * keeps the knot's advertised tip as the authoritative compare-and-swap base.
6565+ */
6666+export async function splicePush(params: SplicePushParams): Promise<SplicePushResult> {
6767+ const { factory, cleanup } = await sshFactory(params.installationId, params.knot, params.repoDid)
6868+ try {
6969+ return await runSplice(factory, params)
7070+ }
7171+ finally {
7272+ cleanup()
7373+ }
7474+}
7575+7676+/** The fetch + push exchange over an open session. Split out for the wire test. */
7777+export async function runSplice(
7878+ factory: ReceivePackFactory,
7979+ params: { repoFullName: string, ref: string, want: string, token: string },
8080+): Promise<SplicePushResult> {
8181+ const session = await ReceivePackSession.open(factory)
8282+ let pushStarted = false
8383+ try {
8484+ const old = session.tips.get(params.ref) ?? ZERO_SHA
8585+ if (old === params.want) {
8686+ await session.close()
8787+ return { status: 'already-synced', sha: params.want }
8888+ }
8989+9090+ const haves = [...new Set(session.tips.values())]
9191+ .filter(sha => sha !== ZERO_SHA)
9292+ .slice(0, MAX_HAVES)
9393+9494+ const { pack } = await fetchPack({
9595+ repoFullName: params.repoFullName,
9696+ token: params.token,
9797+ want: params.want,
9898+ haves,
9999+ maxBytes: maxPackBytes(),
100100+ })
101101+102102+ const update: RefUpdate = { ref: params.ref, old, next: params.want }
103103+ pushStarted = true
104104+ await session.push([update], pack)
105105+ return { status: 'synced', sha: params.want }
106106+ }
107107+ finally {
108108+ // `push` tears the session down itself; only close here if we threw before
109109+ // reaching it (e.g. the byte cap fired inside fetchPack's stream).
110110+ if (!pushStarted) await session.close()
111111+ }
112112+}
113113+114114+export interface SpliceDeleteResult {
115115+ status: 'synced' | 'already-absent'
116116+}
117117+118118+/**
119119+ * Delete a ref on the knot. No GitHub leg and no pack: read the knot's
120120+ * advertisement, and if the ref is absent we're already done (idempotent).
121121+ * Otherwise send a delete command with the advertised value as the
122122+ * compare-and-swap base.
123123+ */
124124+export async function spliceDelete(params: {
125125+ installationId: number
126126+ knot: string
127127+ repoDid: string
128128+ ref: string
129129+}): Promise<SpliceDeleteResult> {
130130+ const { factory, cleanup } = await sshFactory(params.installationId, params.knot, params.repoDid)
131131+ try {
132132+ return await runSpliceDelete(factory, params.ref)
133133+ }
134134+ finally {
135135+ cleanup()
136136+ }
137137+}
138138+139139+/** The delete exchange over an open session. Split out for the wire test. */
140140+export async function runSpliceDelete(factory: ReceivePackFactory, ref: string): Promise<SpliceDeleteResult> {
141141+ const session = await ReceivePackSession.open(factory)
142142+ const old = session.tips.get(ref)
143143+ if (!old || old === ZERO_SHA) {
144144+ await session.close()
145145+ return { status: 'already-absent' }
146146+ }
147147+ // push() owns teardown for the success and rejection paths.
148148+ await session.push([{ ref, old, next: ZERO_SHA }], null)
149149+ return { status: 'synced' }
150150+}
151151+152152+export { fetchAdvertisement }
+9-18
server/utils/ssh-cmd.ts
···1010/**
1111 * Materialise the install's SSH private key as an OpenSSH-format file on disk
1212 * and return:
1313- * - the `GIT_SSH_COMMAND` string to point `git` at it
1313+ * - `args`: the ssh option list (`-i <key> -o ...`) ready to splice into a
1414+ * `spawn('ssh', [...args, target, command])` call
1415 * - a `cleanup()` callback that synchronously removes the temp dir
1516 *
1617 * The key file lives in `os.tmpdir()` with 0600 perms, has a random filename
···2425 * commit can ship pinned host keys for the canonical knots once we know what
2526 * those are.
2627 */
2727-export async function loadSshCommandForInstall(installationId: number): Promise<{
2828- gitSshCommand: string
2828+export async function loadSshArgsForInstall(installationId: number): Promise<{
2929+ args: string[]
2930 cleanup: () => void
3031}> {
3132 const db = useDb()
···5455 chmodSync(keyPath, 0o600)
5556 writeFileSync(knownHostsPath, '', { mode: 0o600 })
56575757- const gitSshCommand = [
5858- 'ssh',
5959- '-i', shellQuote(keyPath),
6060- '-o', `UserKnownHostsFile=${shellQuote(knownHostsPath)}`,
5858+ const args = [
5959+ '-i', keyPath,
6060+ '-o', `UserKnownHostsFile=${knownHostsPath}`,
6161 '-o', 'StrictHostKeyChecking=accept-new',
6262 '-o', 'IdentitiesOnly=yes',
6363 '-o', 'BatchMode=yes',
6464 '-o', 'ConnectTimeout=15',
6565- ].join(' ')
6565+ ]
66666767 return {
6868- gitSshCommand,
6868+ args,
6969 cleanup: () => {
7070 try {
7171 rmSync(dir, { recursive: true, force: true })
···7676 },
7777 }
7878}
7979-8080-/** Minimal shell-quoting for paths inside GIT_SSH_COMMAND. */
8181-function shellQuote(s: string): string {
8282- // GIT_SSH_COMMAND is split on whitespace by git, so escape spaces. We don't
8383- // bother with full shell-quoting here because the paths we generate (in
8484- // os.tmpdir()) won't contain quotes/backslashes; this is defense in depth.
8585- if (!/[\s"'\\]/.test(s)) return s
8686- return `"${s.replace(/(["\\])/g, '\\$1')}"`
8787-}
+14
server/utils/sync-push-host.ts
···1414 if (knot === 'knot1.tangled.sh') return 'tangled.org'
1515 return knot
1616}
1717+1818+/**
1919+ * Split a knot value into the ssh host and optional port. Self-hosted knots
2020+ * may carry a `:port` suffix for a non-default ssh port; the appview-hosted
2121+ * knot maps through `sshHostForKnot` and has no port.
2222+ */
2323+export function sshEndpointForKnot(knot: string): { host: string, port?: number } {
2424+ const mapped = sshHostForKnot(knot)
2525+ const colon = mapped.lastIndexOf(':')
2626+ if (colon === -1) return { host: mapped }
2727+ const port = Number.parseInt(mapped.slice(colon + 1), 10)
2828+ if (Number.isNaN(port)) return { host: mapped }
2929+ return { host: mapped.slice(0, colon), port }
3030+}
+36-80
server/utils/sync-push.ts
···11-import { mkdtempSync, rmSync } from 'node:fs'
22-import os from 'node:os'
33-import path from 'node:path'
41import { and, eq, sql } from 'drizzle-orm'
52import { repoMapping } from '../db/schema'
63import { useDb } from './db'
77-import { classifyPushFailure, git, RemoteRejectedPushError } from './git'
44+import { RemoteRejectedError } from './git-wire/errors'
85import { installationOctokit } from './github-app'
99-import { loadSshCommandForInstall } from './ssh-cmd'
1010-import { sshHostForKnot } from './sync-push-host'
66+import { splicePush } from './splice'
117128const ZERO_SHA = '0000000000000000000000000000000000000000'
139···3026 * 1. Look up the repo_mapping (installationId, githubRepoId). Skip if absent
3127 * or disabled.
3228 * 2. Ref-tip dedupe: if lastSyncedRefs[ref] === after, no-op. Guards against
3333- * GitHub redeliveries and v1.1's tangled-primary loop (PLAN.md).
3434- * 3. Skip ref deletions (after = 0000…). Handled by github.delete in commit 13.
3535- * 4. Bare-init /tmp scratch; fetch `after` from GitHub via smart-HTTP using
3636- * the install token; push that ref to the knot over SSH with the
3737- * install's key, force-with-lease against our last known tip.
2929+ * GitHub redeliveries. This is a cache only; correctness comes from the
3030+ * protocol-level compare-and-swap in the splice.
3131+ * 3. Skip ref deletions (after = 0000…). Handled by github.delete.
3232+ * 4. Splice: open receive-pack to the knot, fetch a thin pack of `after`
3333+ * from GitHub with the knot's tips as haves, pipe it straight through.
3434+ * Nothing touches disk.
3835 * 5. Update lastSyncedRefs[ref] = after.
3936 *
4040- * On terminal failures (repo gone from knot, auth rejected) we mark the
4141- * mapping as `status='error'` so the worker stops retrying. Transient
4242- * failures (network blips, missing objects) re-throw and the queue retries
4343- * with backoff.
3737+ * On terminal failures (repo gone from knot, auth rejected, pack too big) we
3838+ * mark the mapping `status='error'` so the worker stops retrying. A lost
3939+ * compare-and-swap (`stale-old-sha`) and other transient failures re-throw so
4040+ * the queue retries with backoff; the retry re-reads the knot's tip.
4441 */
4542export async function syncPush(payload: PushPayload): Promise<PushResult> {
4643 const db = useDb()
···6259 const lastSynced = (row.lastSyncedRefs as Record<string, string>)[payload.ref]
6360 if (lastSynced === payload.after) return { status: 'skipped', reason: 'already-synced' }
64616565- const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'synchub-push-'))
6666- let sshCleanup: (() => void) | undefined
6262+ const octokit = await installationOctokit(payload.installationId)
6363+ const { token } = (await octokit.auth({ type: 'installation' })) as { token: string }
67646865 try {
6969- // 1. Bare init. No working tree, no objects until we fetch.
7070- await git(['init', '--bare', '-q'], { cwd: tmpDir })
7171-7272- // 2. Install-token-authed clone URL. The `x-access-token` username is
7373- // GitHub's convention for installation tokens.
7474- const octokit = await installationOctokit(payload.installationId)
7575- const { token } = (await octokit.auth({ type: 'installation' })) as { token: string }
7676- const githubUrl = `https://x-access-token:${token}@github.com/${row.githubFullName}.git`
7777-7878- // 3. Fetch exactly the new ref. The `<sha>:<ref>` refspec asks git to
7979- // fetch the object reachable from `after` and store it under our
8080- // local refs/heads/... or refs/tags/... at the same name.
8181- await git(
8282- ['fetch', '--no-tags', '-q', githubUrl, `+${payload.after}:${payload.ref}`],
8383- { cwd: tmpDir, timeout: 120_000 },
8484- )
8585-8686- // 4. Push to the knot. `force-with-lease` means "only update the ref if
8787- // its current tip on the knot still matches what we last saw". Without
8888- // a lease value we fall back to plain `--force` because we have no
8989- // way to know the knot's current tip otherwise (we don't `ls-remote`).
9090- // The lease is `<our last synced sha>` when we have one; on first
9191- // sync we use plain force.
9292- const { gitSshCommand, cleanup } = await loadSshCommandForInstall(payload.installationId)
9393- sshCleanup = cleanup
9494-9595- const knotUrl = `ssh://git@${sshHostForKnot(row.knot)}/${row.tangledRepoDid}`
9696- const pushRefspec = lastSynced
9797- ? `--force-with-lease=${payload.ref}:${lastSynced} ${payload.after}:${payload.ref}`
9898- : `+${payload.after}:${payload.ref}`
9999-100100- try {
101101- await git(
102102- ['push', '-q', knotUrl, ...pushRefspec.split(' ')],
103103- {
104104- cwd: tmpDir,
105105- env: { GIT_SSH_COMMAND: gitSshCommand },
106106- timeout: 120_000,
107107- },
108108- )
109109- }
110110- catch (err) {
111111- const stderr = err instanceof Error && 'stderr' in err ? String((err as { stderr: unknown }).stderr) : ''
112112- const classified = classifyPushFailure(stderr)
113113- if (classified?.reason === 'repo-gone') {
114114- await markMappingError(row.id, 'knot reports repo no longer exists; stopping sync')
115115- return { status: 'skipped', reason: 'repo-gone' }
116116- }
117117- throw classified ?? err
118118- }
6666+ const result = await splicePush({
6767+ installationId: payload.installationId,
6868+ repoFullName: row.githubFullName,
6969+ knot: row.knot,
7070+ repoDid: row.tangledRepoDid,
7171+ ref: payload.ref,
7272+ want: payload.after,
7373+ token,
7474+ })
11975120120- // 5. Update last-synced tip for this ref. Use jsonb_set to leave other
121121- // refs untouched.
12276 await db.update(repoMapping)
12377 .set({
124124- lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPath(payload.ref)}}`}::text[], ${`"${payload.after}"`}::jsonb, true)`,
7878+ lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPath(payload.ref)}}`}::text[], ${`"${result.sha}"`}::jsonb, true)`,
12579 updatedAt: new Date(),
12680 })
12781 .where(eq(repoMapping.id, row.id))
1288212983 return { status: 'synced' }
13084 }
131131- finally {
132132- sshCleanup?.()
133133- try {
134134- rmSync(tmpDir, { recursive: true, force: true })
8585+ catch (err) {
8686+ if (err instanceof RemoteRejectedError && (err.reason === 'repo-gone' || err.reason === 'auth-rejected' || err.reason === 'too-big')) {
8787+ await markMappingError(row.id, terminalMessage(err))
8888+ return { status: 'skipped', reason: 'repo-gone' }
13589 }
136136- catch {
137137- // best-effort
138138- }
9090+ throw err
13991 }
14092}
141939494+function terminalMessage(err: RemoteRejectedError): string {
9595+ if (err.reason === 'too-big') return `pack exceeded the configured size limit; stopping sync (${err.message})`
9696+ if (err.reason === 'auth-rejected') return 'knot rejected our ssh key; stopping sync'
9797+ return 'knot reports repo no longer exists; stopping sync'
9898+}
9999+142100/** jsonb_set path argument: `refs/heads/main` becomes a single text array element. */
143101function jsonbPath(ref: string): string {
144144- // Escape any double-quotes inside the ref. We only support standard git ref
145145- // names which never contain quotes, but be defensive.
146102 return `"${ref.replaceAll('"', '\\"')}"`
147103}
148104···153109 .where(eq(repoMapping.id, mappingId))
154110}
155111156156-export { RemoteRejectedPushError }
112112+export { RemoteRejectedError }
+49-101
server/utils/sync-ref.ts
···11-import { mkdtempSync, rmSync } from 'node:fs'
22-import os from 'node:os'
33-import path from 'node:path'
41import { and, eq, sql } from 'drizzle-orm'
52import { repoMapping } from '../db/schema'
63import { useDb } from './db'
77-import { classifyPushFailure, git } from './git'
44+import { RemoteRejectedError, WireError } from './git-wire/errors'
85import { installationOctokit } from './github-app'
99-import { loadSshCommandForInstall } from './ssh-cmd'
1010-import { sshHostForKnot } from './sync-push-host'
66+import { fetchAdvertisement, spliceDelete, splicePush } from './splice'
117128export type RefType = 'branch' | 'tag'
139···1511 installationId: number
1612 githubRepoId: number
1713 refType: RefType
1818- /** Short ref name as GitHub delivers it (e.g. `v1.0`, `feature-x`) \u2014 NOT
1414+ /** Short ref name as GitHub delivers it (e.g. `v1.0`, `feature-x`) — NOT
1915 * the `refs/...` qualified form. */
2016 ref: string
2117}
···3228 *
3329 * Triggered by GitHub's `create` webhook event. For branches, GitHub also
3430 * sends a parallel `push` event (with `before = 0000…`), so the branch will
3535- * usually have been created already by the time this fires \u2014 the push to
3636- * knot is then a no-op via ref-tip dedupe. For lightweight and annotated
3737- * tags, no `push` event is sent, so this is the only path that creates them
3838- * on the knot.
3131+ * usually have been created already by the time this fires — the splice is
3232+ * then a no-op via the knot tip already matching. For lightweight and
3333+ * annotated tags, no `push` event is sent, so this is the only path that
3434+ * creates them on the knot.
3535+ *
3636+ * We resolve the ref name to a SHA from GitHub's advertisement (annotated tags
3737+ * resolve to the tag object), then splice that SHA to the knot.
3938 */
4039export async function syncCreateRef(payload: CreateRefPayload): Promise<RefResult> {
4140 if (payload.refType !== 'branch' && payload.refType !== 'tag') {
···4645 if ('skip' in mapping) return mapping.skip
47464847 const fullRef = qualifyRef(payload.refType, payload.ref)
4949- const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'synchub-create-'))
5050- let sshCleanup: (() => void) | undefined
51485252- try {
5353- await git(['init', '--bare', '-q'], { cwd: tmpDir })
4949+ const octokit = await installationOctokit(payload.installationId)
5050+ const { token } = (await octokit.auth({ type: 'installation' })) as { token: string }
54515555- const octokit = await installationOctokit(payload.installationId)
5656- const { token } = (await octokit.auth({ type: 'installation' })) as { token: string }
5757- const githubUrl = `https://x-access-token:${token}@github.com/${mapping.githubFullName}.git`
5252+ const adv = await fetchAdvertisement(mapping.githubFullName, token)
5353+ const want = adv.refs.get(fullRef)
5454+ if (!want) {
5555+ // GitHub can deliver the create webhook before its replicas advertise the
5656+ // ref. Transient: re-throw so the queue retries with backoff.
5757+ throw new WireError(`github does not yet advertise ${fullRef} for ${mapping.githubFullName}`)
5858+ }
58595959- // Fetch the ref by name. Tags carry whatever object git stores at the
6060- // ref (commit for lightweight; tag object for annotated); fetch gives
6161- // us all the reachable objects either way.
6262- await git(
6363- ['fetch', '--no-tags', '-q', githubUrl, `+${fullRef}:${fullRef}`],
6464- { cwd: tmpDir, timeout: 120_000 },
6565- )
6666-6767- const { gitSshCommand, cleanup } = await loadSshCommandForInstall(payload.installationId)
6868- sshCleanup = cleanup
6969- const knotUrl = `ssh://git@${sshHostForKnot(mapping.knot)}/${mapping.tangledRepoDid}`
7070-7171- try {
7272- await git(
7373- ['push', '-q', knotUrl, `+${fullRef}:${fullRef}`],
7474- { cwd: tmpDir, env: { GIT_SSH_COMMAND: gitSshCommand }, timeout: 120_000 },
7575- )
7676- }
7777- catch (err) {
7878- const stderr = err instanceof Error && 'stderr' in err ? String((err as { stderr: unknown }).stderr) : ''
7979- const classified = classifyPushFailure(stderr)
8080- if (classified?.reason === 'repo-gone') {
8181- await markMappingError(mapping.id, 'knot reports repo no longer exists; stopping sync')
8282- return { status: 'skipped', reason: 'repo-gone' }
8383- }
8484- throw classified ?? err
8585- }
8686-8787- // For branches we get the SHA from the local ref after fetch; for tags
8888- // we still update lastSyncedRefs so a subsequent push event with the
8989- // same SHA short-circuits via ref-tip dedupe.
9090- const { stdout: sha } = await git(['rev-parse', fullRef], { cwd: tmpDir })
9191- await updateLastSyncedRef(mapping.id, fullRef, sha.trim())
9292-6060+ try {
6161+ const result = await splicePush({
6262+ installationId: payload.installationId,
6363+ repoFullName: mapping.githubFullName,
6464+ knot: mapping.knot,
6565+ repoDid: mapping.tangledRepoDid,
6666+ ref: fullRef,
6767+ want,
6868+ token,
6969+ })
7070+ await updateLastSyncedRef(mapping.id, fullRef, result.sha)
9371 return { status: 'synced' }
9472 }
9595- finally {
9696- sshCleanup?.()
9797- try {
9898- rmSync(tmpDir, { recursive: true, force: true })
7373+ catch (err) {
7474+ if (err instanceof RemoteRejectedError && (err.reason === 'repo-gone' || err.reason === 'auth-rejected' || err.reason === 'too-big')) {
7575+ await markMappingError(mapping.id, 'knot reports repo no longer exists; stopping sync')
7676+ return { status: 'skipped', reason: 'repo-gone' }
9977 }
100100- catch {
101101- // best-effort
102102- }
7878+ throw err
10379 }
10480}
10581···10783 * Mirror a branch or tag deletion from GitHub to the configured knot.
10884 *
10985 * Triggered by GitHub's `delete` webhook event. For branches, GitHub also
110110- * sends a parallel `push` event with `after = 0000\u2026`, which `syncPush`
111111- * currently skips (`reason: 'deletion'`) \u2014 this is the path that actually
8686+ * sends a parallel `push` event with `after = 0000…`, which `syncPush`
8787+ * currently skips (`reason: 'deletion'`) — this is the path that actually
11288 * removes the ref on the knot. Tag deletion arrives only via this event.
11389 *
114114- * Deletion is idempotent: if the ref doesn't exist on the knot we treat it
9090+ * Deletion is idempotent: if the ref is already absent on the knot we treat it
11591 * as success. We wanted it gone, it's gone.
11692 */
11793export async function syncDeleteRef(payload: DeleteRefPayload): Promise<RefResult> {
···12399 if ('skip' in mapping) return mapping.skip
124100125101 const fullRef = qualifyRef(payload.refType, payload.ref)
126126- const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'synchub-delete-'))
127127- let sshCleanup: (() => void) | undefined
128102129103 try {
130130- // No fetch needed; we're only telling the remote to drop a ref.
131131- await git(['init', '--bare', '-q'], { cwd: tmpDir })
132132-133133- const { gitSshCommand, cleanup } = await loadSshCommandForInstall(payload.installationId)
134134- sshCleanup = cleanup
135135- const knotUrl = `ssh://git@${sshHostForKnot(mapping.knot)}/${mapping.tangledRepoDid}`
136136-137137- // The `:<ref>` (empty source) refspec means "delete <ref> on the remote".
138138- try {
139139- await git(
140140- ['push', '-q', knotUrl, `:${fullRef}`],
141141- { cwd: tmpDir, env: { GIT_SSH_COMMAND: gitSshCommand }, timeout: 60_000 },
142142- )
143143- }
144144- catch (err) {
145145- const stderr = err instanceof Error && 'stderr' in err ? String((err as { stderr: unknown }).stderr) : ''
146146- // "remote ref does not exist" is success for our purposes \u2014 the ref is
147147- // gone, which is what we wanted.
148148- if (/remote ref does not exist|unable to delete.*does not exist/i.test(stderr)) {
149149- await clearLastSyncedRef(mapping.id, fullRef)
150150- return { status: 'synced' }
151151- }
152152- const classified = classifyPushFailure(stderr)
153153- if (classified?.reason === 'repo-gone') {
154154- await markMappingError(mapping.id, 'knot reports repo no longer exists; stopping sync')
155155- return { status: 'skipped', reason: 'repo-gone' }
156156- }
157157- throw classified ?? err
158158- }
159159-104104+ await spliceDelete({
105105+ installationId: payload.installationId,
106106+ knot: mapping.knot,
107107+ repoDid: mapping.tangledRepoDid,
108108+ ref: fullRef,
109109+ })
160110 await clearLastSyncedRef(mapping.id, fullRef)
161111 return { status: 'synced' }
162112 }
163163- finally {
164164- sshCleanup?.()
165165- try {
166166- rmSync(tmpDir, { recursive: true, force: true })
113113+ catch (err) {
114114+ if (err instanceof RemoteRejectedError && err.reason === 'repo-gone') {
115115+ await markMappingError(mapping.id, 'knot reports repo no longer exists; stopping sync')
116116+ return { status: 'skipped', reason: 'repo-gone' }
167117 }
168168- catch {
169169- // best-effort
170170- }
118118+ throw err
171119 }
172120}
173121