Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

214 lines · 7.7 KBRaw History
1import { ByteWriter, FLUSH, DELIM, ZERO_OID, fromUtf8, objectsForFetch, parseTag, pkt, pktText, readPkts, sideband, writePack, utf8, type ObjectReader } from "@diffinery/git-core";
2import type { RawObject } from "../repo/objects.js";
3
4export const AGENT = "diffinery/0.1";
5
6export interface RefEntry {
7 name: string;
8 oid: string;
9}
10
11export 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
20export 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
32export 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
48interface V2Request {
49 command: string;
50 capabilities: string[];
51 args: string[];
52}
53
54function 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
73export 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
80async 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
105async 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
150export interface RefCommand {
151 oldOid: string;
152 newOid: string;
153 ref: string;
154}
155
156export interface ReceiveRequest {
157 commands: RefCommand[];
158 capabilities: Set<string>;
159 pushOptions: string[];
160 pack: Uint8Array | null;
161}
162
163export 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
191export interface RefResult {
192 ref: string;
193 ok: boolean;
194 reason?: string;
195}
196
197export 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
210export function errorPacket(message: string): Uint8Array {
211 const w = new ByteWriter();
212 w.write(pkt(`ERR ${message}\n`));
213 return w.bytes();
214}