| 1 | import { ByteWriter, FLUSH, DELIM, ZERO_OID, fromUtf8, objectsForFetch, parseTag, pkt, pktText, readPkts, sideband, writePack, utf8, type ObjectReader } from "@diffinery/git-core"; |
| 2 | import type { RawObject } from "../repo/objects.js"; |
| 3 | |
| 4 | export const AGENT = "diffinery/0.1"; |
| 5 | |
| 6 | export interface RefEntry { |
| 7 | name: string; |
| 8 | oid: string; |
| 9 | } |
| 10 | |
| 11 | export interface UploadContext { |
| 12 | store: ObjectReader & { readRaw(oid: string): Promise<RawObject | null>; has(oid: string): boolean }; |
| 13 | refs: RefEntry[]; |
| 14 | /** e.g. refs/heads/main — HEAD points here even when the branch does not exist yet. */ |
| 15 | headTarget: string; |
| 16 | } |
| 17 | |
| 18 | // ------------------------------------------------------------------ advertisements |
| 19 | |
| 20 | export function advertiseUploadPackV2(): Uint8Array { |
| 21 | const w = new ByteWriter(); |
| 22 | w.write(pkt("version 2\n")); |
| 23 | w.write(pkt(`agent=${AGENT}\n`)); |
| 24 | w.write(pkt("ls-refs=unborn\n")); |
| 25 | w.write(pkt("fetch=wait-for-done\n")); |
| 26 | w.write(pkt("server-option\n")); |
| 27 | w.write(pkt("object-format=sha1\n")); |
| 28 | w.write(FLUSH); |
| 29 | return w.bytes(); |
| 30 | } |
| 31 | |
| 32 | export function advertiseReceivePack(refs: RefEntry[]): Uint8Array { |
| 33 | const w = new ByteWriter(); |
| 34 | w.write(pkt("# service=git-receive-pack\n")); |
| 35 | w.write(FLUSH); |
| 36 | const caps = `report-status report-status-v2 delete-refs side-band-64k quiet atomic ofs-delta push-options object-format=sha1 agent=${AGENT}`; |
| 37 | if (refs.length === 0) { |
| 38 | w.write(pkt(`${ZERO_OID} capabilities^{}\0${caps}\n`)); |
| 39 | } else { |
| 40 | refs.forEach((r, i) => w.write(pkt(i === 0 ? `${r.oid} ${r.name}\0${caps}\n` : `${r.oid} ${r.name}\n`))); |
| 41 | } |
| 42 | w.write(FLUSH); |
| 43 | return w.bytes(); |
| 44 | } |
| 45 | |
| 46 | // ------------------------------------------------------------------ protocol v2: upload-pack |
| 47 | |
| 48 | interface V2Request { |
| 49 | command: string; |
| 50 | capabilities: string[]; |
| 51 | args: string[]; |
| 52 | } |
| 53 | |
| 54 | function parseV2(body: Uint8Array): V2Request { |
| 55 | const { pkts } = readPkts(body); |
| 56 | const req: V2Request = { command: "", capabilities: [], args: [] }; |
| 57 | let section: "caps" | "args" = "caps"; |
| 58 | for (const p of pkts) { |
| 59 | if (p.type === "delim") { |
| 60 | section = "args"; |
| 61 | continue; |
| 62 | } |
| 63 | if (p.type === "flush") break; |
| 64 | const line = pktText(p); |
| 65 | if (section === "caps") { |
| 66 | if (line.startsWith("command=")) req.command = line.slice(8); |
| 67 | else req.capabilities.push(line); |
| 68 | } else req.args.push(line); |
| 69 | } |
| 70 | return req; |
| 71 | } |
| 72 | |
| 73 | export async function handleUploadPackV2(body: Uint8Array, ctx: UploadContext): Promise<Uint8Array> { |
| 74 | const req = parseV2(body); |
| 75 | if (req.command === "ls-refs") return lsRefs(req, ctx); |
| 76 | if (req.command === "fetch") return fetchCommand(req, ctx); |
| 77 | throw new Error(`unsupported command: ${req.command || "(none)"}`); |
| 78 | } |
| 79 | |
| 80 | async function lsRefs(req: V2Request, ctx: UploadContext): Promise<Uint8Array> { |
| 81 | const prefixes = req.args.filter((a) => a.startsWith("ref-prefix ")).map((a) => a.slice(11)); |
| 82 | const symrefs = req.args.includes("symrefs"); |
| 83 | const peel = req.args.includes("peel"); |
| 84 | const unborn = req.args.includes("unborn"); |
| 85 | const match = (name: string) => prefixes.length === 0 || prefixes.some((p) => name.startsWith(p)); |
| 86 | const w = new ByteWriter(); |
| 87 | const head = ctx.refs.find((r) => r.name === ctx.headTarget); |
| 88 | if (match("HEAD")) { |
| 89 | if (head) w.write(pkt(`${head.oid} HEAD${symrefs ? ` symref-target:${ctx.headTarget}` : ""}\n`)); |
| 90 | else if (unborn) w.write(pkt(`unborn HEAD${symrefs ? ` symref-target:${ctx.headTarget}` : ""}\n`)); |
| 91 | } |
| 92 | for (const r of ctx.refs) { |
| 93 | if (!match(r.name)) continue; |
| 94 | let line = `${r.oid} ${r.name}`; |
| 95 | if (peel && r.name.startsWith("refs/tags/")) { |
| 96 | const o = await ctx.store.read(r.oid); |
| 97 | if (o?.type === "tag") line += ` peeled:${parseTag(o.content).object}`; |
| 98 | } |
| 99 | w.write(pkt(`${line}\n`)); |
| 100 | } |
| 101 | w.write(FLUSH); |
| 102 | return w.bytes(); |
| 103 | } |
| 104 | |
| 105 | async function fetchCommand(req: V2Request, ctx: UploadContext): Promise<Uint8Array> { |
| 106 | const wants: string[] = []; |
| 107 | const haves: string[] = []; |
| 108 | let done = false; |
| 109 | let noProgress = false; |
| 110 | for (const a of req.args) { |
| 111 | if (a.startsWith("want ")) wants.push(a.slice(5)); |
| 112 | else if (a.startsWith("want-ref ")) { |
| 113 | const ref = ctx.refs.find((r) => r.name === a.slice(9)); |
| 114 | if (ref) wants.push(ref.oid); |
| 115 | } else if (a.startsWith("have ")) haves.push(a.slice(5)); |
| 116 | else if (a === "done") done = true; |
| 117 | else if (a === "no-progress") noProgress = true; |
| 118 | else if (a.startsWith("deepen") || a.startsWith("shallow ") || a.startsWith("filter ")) { |
| 119 | throw new Error("shallow and partial clones are not supported yet; clone without --depth/--filter"); |
| 120 | } |
| 121 | } |
| 122 | for (const w of wants) if (!ctx.store.has(w)) throw new Error(`not our ref ${w}`); |
| 123 | const common = haves.filter((h) => ctx.store.has(h)); |
| 124 | |
| 125 | const out = new ByteWriter(); |
| 126 | if (!done) { |
| 127 | out.write(pkt("acknowledgments\n")); |
| 128 | if (common.length === 0) out.write(pkt("NAK\n")); |
| 129 | for (const c of common) out.write(pkt(`ACK ${c}\n`)); |
| 130 | out.write(pkt("ready\n")); |
| 131 | out.write(DELIM); |
| 132 | } |
| 133 | out.write(pkt("packfile\n")); |
| 134 | const oids = await objectsForFetch(ctx.store, wants, common); |
| 135 | if (!noProgress) sideband(2, utf8(`Enumerating objects: ${oids.length}, done.\n`), out); |
| 136 | const entries = []; |
| 137 | for (const oid of oids) { |
| 138 | const raw = await ctx.store.readRaw(oid); |
| 139 | if (!raw) throw new Error(`missing object ${oid}`); |
| 140 | entries.push({ type: raw.type, size: raw.size, deflated: raw.deflated }); |
| 141 | } |
| 142 | sideband(1, await writePack(entries), out); |
| 143 | if (!noProgress) sideband(2, utf8(`Total ${oids.length} (delta 0), reused ${oids.length} (delta 0)\n`), out); |
| 144 | out.write(FLUSH); |
| 145 | return out.bytes(); |
| 146 | } |
| 147 | |
| 148 | // ------------------------------------------------------------------ receive-pack |
| 149 | |
| 150 | export interface RefCommand { |
| 151 | oldOid: string; |
| 152 | newOid: string; |
| 153 | ref: string; |
| 154 | } |
| 155 | |
| 156 | export interface ReceiveRequest { |
| 157 | commands: RefCommand[]; |
| 158 | capabilities: Set<string>; |
| 159 | pushOptions: string[]; |
| 160 | pack: Uint8Array | null; |
| 161 | } |
| 162 | |
| 163 | export function parseReceivePack(body: Uint8Array): ReceiveRequest { |
| 164 | const { pkts, offset } = readPkts(body, 0, true); |
| 165 | const commands: RefCommand[] = []; |
| 166 | let capabilities = new Set<string>(); |
| 167 | for (const p of pkts) { |
| 168 | if (p.type !== "data") continue; |
| 169 | let line = fromUtf8(p.data).replace(/\n$/, ""); |
| 170 | const nul = line.indexOf("\0"); |
| 171 | if (nul >= 0) { |
| 172 | capabilities = new Set(line.slice(nul + 1).trim().split(" ").filter(Boolean)); |
| 173 | line = line.slice(0, nul); |
| 174 | } |
| 175 | if (line.startsWith("shallow ")) continue; |
| 176 | const [oldOid, newOid, ref] = line.split(" "); |
| 177 | if (!oldOid || !newOid || !ref) throw new Error(`bad command: ${line}`); |
| 178 | commands.push({ oldOid, newOid, ref }); |
| 179 | } |
| 180 | let rest = offset; |
| 181 | const pushOptions: string[] = []; |
| 182 | if (capabilities.has("push-options")) { |
| 183 | const opts = readPkts(body, rest, true); |
| 184 | for (const p of opts.pkts) if (p.type === "data") pushOptions.push(pktText(p)); |
| 185 | rest = opts.offset; |
| 186 | } |
| 187 | const pack = rest < body.length ? body.subarray(rest) : null; |
| 188 | return { commands, capabilities, pushOptions, pack }; |
| 189 | } |
| 190 | |
| 191 | export interface RefResult { |
| 192 | ref: string; |
| 193 | ok: boolean; |
| 194 | reason?: string; |
| 195 | } |
| 196 | |
| 197 | export function reportStatus(req: ReceiveRequest, unpackError: string | null, results: RefResult[], messages: string[] = []): Uint8Array { |
| 198 | const report = new ByteWriter(); |
| 199 | report.write(pkt(unpackError ? `unpack ${unpackError}\n` : "unpack ok\n")); |
| 200 | for (const r of results) report.write(pkt(r.ok ? `ok ${r.ref}\n` : `ng ${r.ref} ${r.reason ?? "rejected"}\n`)); |
| 201 | report.write(FLUSH); |
| 202 | if (!req.capabilities.has("side-band-64k")) return report.bytes(); |
| 203 | const out = new ByteWriter(); |
| 204 | for (const m of messages) sideband(2, utf8(`${m}\n`), out); |
| 205 | sideband(1, report.bytes(), out); |
| 206 | out.write(FLUSH); |
| 207 | return out.bytes(); |
| 208 | } |
| 209 | |
| 210 | export function errorPacket(message: string): Uint8Array { |
| 211 | const w = new ByteWriter(); |
| 212 | w.write(pkt(`ERR ${message}\n`)); |
| 213 | return w.bytes(); |
| 214 | } |