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.

235 lines · 8.7 KBRaw History
1import { ByteWriter, FLUSH, DELIM, ZERO_OID, fromUtf8, planFetch, 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 type 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=shallow 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 waitForDone = false;
110 let noProgress = false;
111 let depth: number | undefined;
112 let since: number | undefined;
113 const clientShallow = new Set<string>();
114 for (const a of req.args) {
115 if (a.startsWith("want ")) wants.push(a.slice(5));
116 else if (a.startsWith("want-ref ")) {
117 const ref = ctx.refs.find((r) => r.name === a.slice(9));
118 if (ref) wants.push(ref.oid);
119 } else if (a.startsWith("have ")) haves.push(a.slice(5));
120 else if (a === "done") done = true;
121 else if (a === "wait-for-done") waitForDone = true;
122 else if (a === "no-progress") noProgress = true;
123 else if (a.startsWith("shallow ")) clientShallow.add(a.slice(8));
124 else if (a.startsWith("deepen ")) depth = Math.max(1, Number.parseInt(a.slice(7), 10) || 1);
125 else if (a.startsWith("deepen-since ")) since = Number.parseInt(a.slice(13), 10);
126 else if (a === "deepen-relative") {
127 // Approximation: treat relative deepening as absolute depth from the wants.
128 } else if (a.startsWith("deepen-not ")) throw new Error("--shallow-exclude is not supported; use --depth or --shallow-since");
129 else if (a.startsWith("filter ")) throw new Error("partial clones (--filter) are not supported yet");
130 }
131 for (const w of wants) if (!ctx.store.has(w)) throw new Error(`not our ref ${w}`);
132 const common = haves.filter((h) => ctx.store.has(h));
133
134 const out = new ByteWriter();
135 if (!done) {
136 out.write(pkt("acknowledgments\n"));
137 if (common.length === 0) out.write(pkt("NAK\n"));
138 for (const c of common) out.write(pkt(`ACK ${c}\n`));
139 if (waitForDone) {
140 // Negotiation only (git push with push.negotiate): no "ready" and no pack until "done".
141 out.write(FLUSH);
142 return out.bytes();
143 }
144 out.write(pkt("ready\n"));
145 out.write(DELIM);
146 }
147 const plan = await planFetch(ctx.store, wants, common, { depth, since, clientShallow });
148 if (depth !== undefined || since !== undefined || clientShallow.size) {
149 out.write(pkt("shallow-info\n"));
150 for (const oid of plan.shallow) out.write(pkt(`shallow ${oid}\n`));
151 for (const oid of plan.unshallow) out.write(pkt(`unshallow ${oid}\n`));
152 out.write(DELIM);
153 }
154 out.write(pkt("packfile\n"));
155 const oids = plan.oids;
156 if (!noProgress) sideband(2, utf8(`Enumerating objects: ${oids.length}, done.\n`), out);
157 const entries = [];
158 for (const oid of oids) {
159 const raw = await ctx.store.readRaw(oid);
160 if (!raw) throw new Error(`missing object ${oid}`);
161 entries.push({ type: raw.type, size: raw.size, deflated: raw.deflated });
162 }
163 sideband(1, await writePack(entries), out);
164 if (!noProgress) sideband(2, utf8(`Total ${oids.length} (delta 0), reused ${oids.length} (delta 0)\n`), out);
165 out.write(FLUSH);
166 return out.bytes();
167}
168
169// ------------------------------------------------------------------ receive-pack
170
171export interface RefCommand {
172 oldOid: string;
173 newOid: string;
174 ref: string;
175}
176
177export interface ReceiveRequest {
178 commands: RefCommand[];
179 capabilities: Set<string>;
180 pushOptions: string[];
181 pack: Uint8Array | null;
182}
183
184export function parseReceivePack(body: Uint8Array): ReceiveRequest {
185 const { pkts, offset } = readPkts(body, 0, true);
186 const commands: RefCommand[] = [];
187 let capabilities = new Set<string>();
188 for (const p of pkts) {
189 if (p.type !== "data") continue;
190 let line = fromUtf8(p.data).replace(/\n$/, "");
191 const nul = line.indexOf("\0");
192 if (nul >= 0) {
193 capabilities = new Set(line.slice(nul + 1).trim().split(" ").filter(Boolean));
194 line = line.slice(0, nul);
195 }
196 if (line.startsWith("shallow ")) continue;
197 const [oldOid, newOid, ref] = line.split(" ");
198 if (!oldOid || !newOid || !ref) throw new Error(`bad command: ${line}`);
199 commands.push({ oldOid, newOid, ref });
200 }
201 let rest = offset;
202 const pushOptions: string[] = [];
203 if (capabilities.has("push-options")) {
204 const opts = readPkts(body, rest, true);
205 for (const p of opts.pkts) if (p.type === "data") pushOptions.push(pktText(p));
206 rest = opts.offset;
207 }
208 const pack = rest < body.length ? body.subarray(rest) : null;
209 return { commands, capabilities, pushOptions, pack };
210}
211
212export interface RefResult {
213 ref: string;
214 ok: boolean;
215 reason?: string;
216}
217
218export function reportStatus(req: ReceiveRequest, unpackError: string | null, results: RefResult[], messages: string[] = []): Uint8Array {
219 const report = new ByteWriter();
220 report.write(pkt(unpackError ? `unpack ${unpackError}\n` : "unpack ok\n"));
221 for (const r of results) report.write(pkt(r.ok ? `ok ${r.ref}\n` : `ng ${r.ref} ${r.reason ?? "rejected"}\n`));
222 report.write(FLUSH);
223 if (!req.capabilities.has("side-band-64k")) return report.bytes();
224 const out = new ByteWriter();
225 for (const m of messages) sideband(2, utf8(`${m}\n`), out);
226 sideband(1, report.bytes(), out);
227 out.write(FLUSH);
228 return out.bytes();
229}
230
231export function errorPacket(message: string): Uint8Array {
232 const w = new ByteWriter();
233 w.write(pkt(`ERR ${message}\n`));
234 return w.bytes();
235}