| author | Sree Narayanan <sreeaadhi07@gmail.com> | 2026-08-20 04:49:48 +0400 |
|---|---|---|
| committer | Sree Narayanan <sreeaadhi07@gmail.com> | 2026-08-20 04:49:48 +0400 |
| commit | d44c93d56faf7ff47dcc913da420229297f788a9 (patch) | |
| tree | 3f9b4ec3f617b8138730ea2757787a5a2f1fe061 | |
| parent | 17b03631eb6676f3b2c86b6662d9af4aa9237bef (diff) | |
| download | dgit-d44c93d56f.tar.gz zip | |
gitprotocol v2main
Diffstat
| src/git/protocol.ts | +46 | -9 |
| src/index.ts | +2 | -2 |
| src/repo.ts | +2 | -1 |
| wrangler.jsonc | +2 | -2 |
4 files changed, 52 insertions(+), 14 deletions(-)
diff --git a/src/git/protocol.ts b/src/git/protocol.ts @@ -35,8 +35,11 @@ beginMultipart(key: string, objects: number): Promise<MultipartPackUpload | null>; } -/** GET /info/refs?service=... — smart ref advertisement (protocol v0). */ -export function advertisement(store: GitStore, service: Service): Uint8Array { +/** GET /info/refs?service=... — smart ref advertisement. */ +export function advertisement(store: GitStore, service: Service, v2 = false): Uint8Array { + if (v2 && service === "git-upload-pack") { + return concat([pkt("version 2\n"), pkt(`${AGENT}\n`), pkt("ls-refs\n"), pkt("fetch=shallow\n"), FLUSH]); + } const caps = service === "git-upload-pack" ? [ @@ -124,7 +127,7 @@ /** * Serve a full clone by streaming the R2-cached raw pack straight from the - * Worker: the NAK preamble a full-clone negotiation always produces, the + * Worker: the protocol's pack preamble, the * optional band-2 progress line, then the pack re-chunked into band-1 side-band * frames as R2 hands it over, then flush. Framing is identical to the DO path * and the pack bytes are the stored bytes, so the client receives a @@ -134,7 +137,7 @@ export function streamingCloneResponse( body: ReadableStream<Uint8Array>, objects: number, - noProgress: boolean + noProgress: boolean, v2 = false ): Response { const headers = { "content-type": "application/x-git-upload-pack-result", @@ -149,7 +152,7 @@ try { if (!started) { started = true; - ctrl.enqueue(pkt("NAK\n")); + ctrl.enqueue(pkt(v2 ? "packfile\n" : "NAK\n")); if (!noProgress) { for (const f of sidebandFrames(2, te.encode(`Enumerating objects: ${objects}, done.\n`))) ctrl.enqueue(f); } @@ -174,6 +177,7 @@ } interface UploadRequest { + command: "fetch" | "ls-refs" | null; wants: string[]; haves: string[]; done: boolean; @@ -185,6 +189,7 @@ export function parseUploadRequest(body: Uint8Array): UploadRequest { const parser = new PktParser(body); const req: UploadRequest = { + command: null, wants: [], haves: [], done: false, @@ -195,7 +200,11 @@ for (let p = parser.read(); p !== null; p = parser.read()) { if (p.kind !== "line") continue; const line = p.text; - if (line.startsWith("want ")) { + if (line === "command=fetch" || line === "command=ls-refs") { + req.command = line === "command=fetch" ? "fetch" : "ls-refs"; + } else if (line === "no-progress" || line === "symrefs" || line === "peel") { + req.caps.add(line); + } else if (line.startsWith("want ")) { req.wants.push(line.slice(5, 45)); // capabilities ride on the first want line for (const cap of line.slice(45).trim().split(" ")) if (cap) req.caps.add(cap); @@ -212,6 +221,21 @@ return req; } +async function lsRefs(store: GitStore, req: UploadRequest): Promise<Uint8Array> { + const lines: Uint8Array[] = []; + const head = store.resolveHead(); + if (head) lines.push(pkt(`${head} HEAD${req.caps.has("symrefs") ? ` symref-target:${store.head()}` : ""}\n`)); + for (const ref of store.refs()) { + let peeled = ""; + if (req.caps.has("peel") && ref.name.startsWith("refs/tags/") && store.typeAndSize(ref.target)?.type === "tag") { + const oid = await peelToCommitOid(store, ref.target); + if (oid) peeled = ` peeled:${oid}`; + } + lines.push(pkt(`${ref.target} ${ref.name}${peeled}\n`)); + } + return concat([...lines, FLUSH]); +} + /** Follow tag objects down to the underlying commit oid (or null). */ async function peelToCommitOid(store: GitStore, oid: string): Promise<string | null> { for (let i = 0; i < 10; i++) { @@ -510,6 +534,11 @@ "cache-control": "no-cache", }; const req = parseUploadRequest(body); + if (req.command === "ls-refs") { + release(); + return new Response(await lsRefs(store, req) as unknown as BodyInit, { headers }); + } + const v2 = req.command === "fetch"; if (!req.wants.length || req.wants.some((w) => !isOid(w))) { release(); return new Response(pkt("ERR no valid wants\n") as unknown as BodyInit, { headers }); @@ -528,6 +557,7 @@ // shallow section (only when the client asked to deepen) let commitLimit: Set<string> | null = null; if (req.deepen > 0) { + if (v2) preamble.push(pkt("shallow-info\n")); const clientShallowSet = new Set(req.clientShallows); if (req.deepen >= INFINITE_DEPTH) { // --unshallow: full history, everything the client thought was shallow opens up @@ -544,8 +574,9 @@ if (commits.has(s) && !boundary.has(s)) preamble.push(pkt(`unshallow ${s}\n`)); } } - preamble.push(FLUSH); + preamble.push(v2 ? te.encode("0001") : FLUSH); } + if (v2 && !req.done) preamble.length = 0; // Negotiation. A round with neither haves nor done (shallow discovery) gets // no ack line at all — a stray NAK would desync the client's stateless @@ -562,7 +593,13 @@ const common = req.haves.filter((h) => isOid(h) && store.has(h)); const lastCommon = common.length ? common[common.length - 1] : null; let sendPack = req.done; - if (!req.caps.has("multi_ack_detailed")) { + if (v2) { + if (req.done) preamble.push(pkt("packfile\n")); + else { + const acknowledgments = common.length ? common.map((oid) => pkt(`ACK ${oid}\n`)) : [pkt("NAK\n")]; + preamble.push(pkt("acknowledgments\n"), ...acknowledgments, FLUSH); + } + } else if (!req.caps.has("multi_ack_detailed")) { if (req.done || req.haves.length) { preamble.push(pkt(common.length ? `ACK ${common[0]}\n` : "NAK\n")); } @@ -590,7 +627,7 @@ return new Response(concat(preamble) as unknown as BodyInit, { headers }); } - const sideband = req.caps.has("side-band-64k"); + const sideband = v2 || req.caps.has("side-band-64k"); const noProgress = req.caps.has("no-progress"); // Full-clone fast path: no haves, not shallow/deepen, and the wants are diff --git a/src/index.ts b/src/index.ts @@ -220,7 +220,7 @@ req.done && req.deepen === 0 && req.clientShallows.length === 0 && - req.caps.has("side-band-64k"); + (req.command === "fetch" || req.caps.has("side-band-64k")); if (!fullClone) return null; let key: string | null; try { @@ -238,7 +238,7 @@ if (!obj) return null; const objects = parseInt(obj.customMetadata?.objects ?? "", 10); if (!Number.isFinite(objects)) return null; - return streamingCloneResponse(obj.body, objects, req.caps.has("no-progress")); + return streamingCloneResponse(obj.body, objects, req.caps.has("no-progress"), req.command === "fetch"); } export default { diff --git a/src/repo.ts b/src/repo.ts @@ -123,7 +123,8 @@ if (service !== "git-upload-pack" && service !== "git-receive-pack") { return new Response("smart HTTP only\n", { status: 400 }); } - return new Response(advertisement(this.store, service as Service) as unknown as BodyInit, { + const v2 = req.headers.get("git-protocol")?.split(":").some((value) => value.trim() === "version=2"); + return new Response(advertisement(this.store, service as Service, v2) as unknown as BodyInit, { headers: { "content-type": `application/x-${service}-advertisement`, "cache-control": "no-cache", diff --git a/wrangler.jsonc b/wrangler.jsonc @@ -7,7 +7,7 @@ "main": "src/index.ts", "compatibility_date": "2026-08-01", "routes": [ - { "pattern": "git.littledivy.com", "custom_domain": true } + { "pattern": "git.eers.dev", "custom_domain": true } ], "limits": { // pack indexing of a large push is CPU-bound; 5 minutes is the paid-plan max @@ -29,7 +29,7 @@ { "binding": "PACK_CACHE", "bucket_name": "dgit-pack-cache" } ], "vars": { - "SITE_NAME": "git.littledivy.com", + "SITE_NAME": "git.eers.dev", "SITE_DESC": "a fast webinterface for the git dscm, on Cloudflare", "SITE_OWNER": "divy" } |