git.ts
Raw
1import { existsSync } from "node:fs";
2import path from "node:path";
3import * as argon2 from "argon2";
4import { Elysia, t } from "elysia";
5import { ADMIN_USERNAME, paths, VALID_REPO_NAME_RE } from "../constants.ts";
6import { db } from "../db";
7import {
8 parseCiConfig,
9 shouldTriggerPush,
10 shouldTriggerTag,
11 triggerRun,
12} from "../services/ci.ts";
13import { git, invalidateRefCache } from "../services/git.ts";
14
15/** Parse ref updates from git receive-pack request body (pkt-line format). */
16function parseRefUpdates(
17 body: Uint8Array,
18): Array<{ oldSha: string; newSha: string; refname: string }> {
19 const text = new TextDecoder().decode(body.slice(0, 4096));
20 const refs: Array<{ oldSha: string; newSha: string; refname: string }> = [];
21 let pos = 0;
22 while (pos + 4 <= text.length) {
23 const lenStr = text.slice(pos, pos + 4);
24 const len = parseInt(lenStr, 16);
25 if (Number.isNaN(len) || len === 0) break;
26 if (len < 4 || pos + len > text.length) break;
27 // Strip capabilities (after first NUL) and trim
28 const line = text
29 .slice(pos + 4, pos + len)
30 .replace(/\0.*$/, "")
31 .trim();
32 pos += len;
33 const parts = line.split(" ");
34 if (parts.length >= 3) {
35 const oldSha = parts[0] ?? "";
36 const newSha = parts[1] ?? "";
37 const refname = parts[2] ?? "";
38 if (refname) refs.push({ oldSha, newSha, refname });
39 }
40 }
41 return refs;
42}
43
44/** Fire CI runs for any updated refs that match the pipeline config. */
45async function triggerCiForPush(
46 repoName: string,
47 refUpdates: Array<{ oldSha: string; newSha: string; refname: string }>,
48): Promise<void> {
49 for (const { newSha, refname } of refUpdates) {
50 // Skip deletions
51 if (/^0+$/.test(newSha)) continue;
52
53 const isBranch = refname.startsWith("refs/heads/");
54 const isTag = refname.startsWith("refs/tags/");
55 if (!isBranch && !isTag) continue;
56
57 const tomlBuf = await git
58 .show(repoName, newSha, ".hearthforge-ci.toml")
59 .catch(() => null);
60 if (!tomlBuf) continue;
61
62 const cfg = parseCiConfig(tomlBuf.toString("utf-8"));
63 if (!cfg) continue;
64
65 if (isBranch) {
66 const branch = refname.slice("refs/heads/".length);
67 if (shouldTriggerPush(cfg, branch)) {
68 triggerRun(repoName, {
69 triggerSource: "push",
70 commitSha: newSha,
71 commitBranch: branch,
72 }).catch((e) =>
73 console.error(`CI push trigger failed for ${repoName}:`, e),
74 );
75 }
76 } else if (isTag && shouldTriggerTag(cfg)) {
77 const tag = refname.slice("refs/tags/".length);
78 triggerRun(repoName, {
79 triggerSource: "tag",
80 commitSha: newSha,
81 commitTag: tag,
82 }).catch((e) =>
83 console.error(`CI tag trigger failed for ${repoName}:`, e),
84 );
85 }
86 }
87}
88
89function pktLine(str: string): Buffer {
90 const len = Buffer.byteLength(str, "utf-8") + 4;
91 return Buffer.from(len.toString(16).padStart(4, "0") + str, "utf-8");
92}
93
94const PKT_FLUSH = Buffer.from("0000");
95
96async function verifyBasicAuth(
97 authHeader: string | null,
98 adminOnly: boolean,
99): Promise<boolean> {
100 if (!authHeader?.startsWith("Basic ")) return false;
101 const decoded = Buffer.from(authHeader.slice(6), "base64").toString(
102 "utf-8",
103 );
104 const sep = decoded.indexOf(":");
105 if (sep === -1) return false;
106 const username = decoded.slice(0, sep);
107 const password = decoded.slice(sep + 1);
108 if (adminOnly && username !== ADMIN_USERNAME) return false;
109 const user = await db
110 .selectFrom("users")
111 .select("password_hash")
112 .where("username", "=", username)
113 .executeTakeFirst();
114 if (!user?.password_hash) return false;
115 try {
116 return await argon2.verify(user.password_hash, password);
117 } catch {
118 return false;
119 }
120}
121
122function unauthorized(): Response {
123 return new Response("Unauthorized", {
124 status: 401,
125 headers: {
126 "WWW-Authenticate": 'Basic realm="Hearthforge"',
127 "Content-Type": "text/plain",
128 },
129 });
130}
131
132async function getRepo(
133 slug: string,
134): Promise<{ name: string; repoPath: string; isPrivate: boolean } | null> {
135 const repoName = slug.endsWith(".git") ? slug.slice(0, -4) : slug;
136 if (!VALID_REPO_NAME_RE.test(repoName)) return null;
137 const repo = await db
138 .selectFrom("repositories")
139 .select(["name", "is_private"])
140 .where("name", "=", repoName)
141 .executeTakeFirst();
142 if (!repo) return null;
143 const repoPath = path.join(paths.REPOS_DIR, `${repo.name}.git`);
144 if (!existsSync(repoPath)) return null;
145 return { name: repo.name, repoPath, isPrivate: repo.is_private === 1 };
146}
147
148async function spawnGit(
149 args: string[],
150 stdinBytes?: Uint8Array,
151): Promise<Uint8Array> {
152 const proc = Bun.spawn(args, {
153 stdin: stdinBytes ?? "ignore",
154 stdout: "pipe",
155 stderr: "pipe",
156 });
157 await proc.exited;
158 return new Uint8Array(await Bun.readableStreamToArrayBuffer(proc.stdout));
159}
160
161export const gitRoutes = new Elysia()
162 // info/refs — serves both upload-pack (clone/fetch) and receive-pack (push)
163 .get(
164 "/:repo/info/refs",
165 async ({ params, query, request }) => {
166 const service = query.service;
167 if (
168 service !== "git-upload-pack" &&
169 service !== "git-receive-pack"
170 ) {
171 return new Response("Bad Request", { status: 400 });
172 }
173
174 const repo = await getRepo(params.repo);
175 if (!repo) return new Response("Not Found", { status: 404 });
176
177 const authHeader = request.headers.get("Authorization");
178 if (service === "git-receive-pack") {
179 if (!(await verifyBasicAuth(authHeader, true)))
180 return unauthorized();
181 } else if (repo.isPrivate) {
182 if (!(await verifyBasicAuth(authHeader, false)))
183 return unauthorized();
184 }
185
186 const gitCmd =
187 service === "git-receive-pack" ? "receive-pack" : "upload-pack";
188 const refs = await spawnGit([
189 "git",
190 gitCmd,
191 "--stateless-rpc",
192 "--advertise-refs",
193 repo.repoPath,
194 ]);
195 const body = Buffer.concat([
196 pktLine(`# service=git-${gitCmd}\n`),
197 PKT_FLUSH,
198 refs,
199 ]);
200
201 return new Response(body, {
202 headers: {
203 "Content-Type": `application/x-git-${gitCmd}-advertisement`,
204 "Cache-Control": "no-cache",
205 },
206 });
207 },
208 {
209 query: t.Object({ service: t.Optional(t.String()) }),
210 },
211 )
212
213 // upload-pack POST — clone/fetch pack transfer (public for public repos)
214 .post("/:repo/git-upload-pack", async ({ params, request }) => {
215 const repo = await getRepo(params.repo);
216 if (!repo) return new Response("Not Found", { status: 404 });
217 if (repo.isPrivate) {
218 if (
219 !(await verifyBasicAuth(
220 request.headers.get("Authorization"),
221 false,
222 ))
223 )
224 return unauthorized();
225 }
226 const body = new Uint8Array(await request.arrayBuffer());
227 const result = await spawnGit(
228 ["git", "upload-pack", "--stateless-rpc", repo.repoPath],
229 body,
230 );
231 return new Response(result, {
232 headers: {
233 "Content-Type": "application/x-git-upload-pack-result",
234 "Cache-Control": "no-cache",
235 },
236 });
237 })
238
239 // receive-pack POST — push pack transfer (admin only)
240 .post("/:repo/git-receive-pack", async ({ params, request }) => {
241 if (
242 !(await verifyBasicAuth(request.headers.get("Authorization"), true))
243 )
244 return unauthorized();
245 const repo = await getRepo(params.repo);
246 if (!repo) return new Response("Not Found", { status: 404 });
247 const body = new Uint8Array(await request.arrayBuffer());
248 const refUpdates = parseRefUpdates(body);
249 const result = await spawnGit(
250 ["git", "receive-pack", "--stateless-rpc", repo.repoPath],
251 body,
252 );
253 invalidateRefCache(repo.name);
254 // Trigger CI in background — don't block the git push response
255 triggerCiForPush(repo.name, refUpdates).catch(() => {});
256 return new Response(result, {
257 headers: {
258 "Content-Type": "application/x-git-receive-pack-result",
259 "Cache-Control": "no-cache",
260 },
261 });
262 });
263