git.ts
Raw
1import path from "node:path";
2import { $ as _$ } from "bun";
3
4import {
5 ALLOWED_SIGNERS_PATH,
6 REPOS_DIR,
7 SSH_HOST_KEY_PATH,
8} from "../constants.ts";
9
10const gitEnv = {
11 ...process.env,
12 LC_ALL: "C",
13 LANG: "C",
14 GIT_CONFIG_GLOBAL: "/dev/null",
15 GIT_CONFIG_SYSTEM: "/dev/null",
16 GIT_CONFIG_COUNT: "0",
17 GIT_ASKPASS: "echo",
18 GIT_TERMINAL_PROMPT: "0",
19};
20
21const $ = _$.env(gitEnv);
22
23// Per-repo mutex: prevents concurrent git write operations on the same repo
24// (e.g. two patches being merged simultaneously, which would corrupt the index).
25const repoWriteLocks = new Map<string, Promise<void>>();
26
27async function withRepoLock<T>(name: string, fn: () => Promise<T>): Promise<T> {
28 const prev = repoWriteLocks.get(name) ?? Promise.resolve();
29 let unlock!: () => void;
30 repoWriteLocks.set(
31 name,
32 prev.then(
33 () =>
34 new Promise<void>((res) => {
35 unlock = res;
36 }),
37 ),
38 );
39 await prev;
40 try {
41 return await fn();
42 } finally {
43 unlock();
44 }
45}
46
47export function repoPath(name: string): string {
48 return path.join(REPOS_DIR, `${name}.git`);
49}
50
51export async function validateCommit(
52 repoName: string,
53 hash: string,
54): Promise<boolean> {
55 const p = repoPath(repoName);
56 try {
57 const out = await $`git -C ${p} cat-file -t ${hash}`.text();
58 return out.trim() === "commit";
59 } catch {
60 return false;
61 }
62}
63
64export async function archiveRepo(
65 repoName: string,
66 ref: string,
67 slug: string,
68 outDir: string,
69 signal?: AbortSignal,
70): Promise<void> {
71 const p = repoPath(repoName);
72 const base = `${slug}-${ref}`;
73
74 const zip = Bun.spawn(
75 [
76 "git",
77 "-C",
78 p,
79 "archive",
80 "--format=zip",
81 `--output=${path.join(outDir, `${base}.zip`)}`,
82 ref,
83 ],
84 { signal, env: gitEnv },
85 );
86 if ((await zip.exited) !== 0) throw new Error("git archive (zip) failed");
87
88 const tgz = Bun.spawn(
89 [
90 "git",
91 "-C",
92 p,
93 "archive",
94 "--format=tar.gz",
95 `--output=${path.join(outDir, `${base}.tar.gz`)}`,
96 ref,
97 ],
98 { signal, env: gitEnv },
99 );
100 if ((await tgz.exited) !== 0)
101 throw new Error("git archive (tar.gz) failed");
102
103 try {
104 const tar = Bun.spawn(
105 ["git", "-C", p, "archive", "--format=tar", ref],
106 { signal, env: gitEnv, stdout: "pipe" },
107 );
108 const zst = Bun.spawn(
109 ["zstd", "-o", path.join(outDir, `${base}.tar.zst`)],
110 { signal, env: gitEnv, stdin: tar.stdout },
111 );
112 await Promise.all([tar.exited, zst.exited]);
113 } catch {
114 // zstd not available — skip silently
115 }
116}
117
118export interface CommitEntry {
119 hash: string;
120 subject: string;
121 author: string;
122 date: string;
123 sigStatus: "good" | "bad" | "none";
124}
125
126export interface CommitMeta {
127 hash: string;
128 subject: string;
129 body: string;
130 author: string;
131 email: string;
132 date: string;
133 committer: string;
134 committerEmail: string;
135 committerDate: string;
136 parents: string[];
137 sigStatus: "good" | "bad" | "none";
138}
139
140export interface TreeEntry {
141 mode: string;
142 type: "blob" | "tree";
143 hash: string;
144 size: string;
145 name: string;
146}
147
148function parseSigStatus(code: string): "good" | "bad" | "none" {
149 if (code === "G" || code === "X" || code === "Y" || code === "R")
150 return "good";
151 if (code === "B" || code === "U" || code === "E") return "bad";
152 return "none";
153}
154
155function parseLog(out: string): CommitEntry[] {
156 return out
157 .split("\n")
158 .filter(Boolean)
159 .map((line) => {
160 const parts = line.split("\x1f");
161 return {
162 hash: parts[0] ?? "",
163 subject: parts[1] ?? "",
164 author: parts[2] ?? "",
165 date: parts[3] ?? "",
166 sigStatus: parseSigStatus(parts[4] ?? ""),
167 };
168 });
169}
170
171function parseLsTree(out: string): TreeEntry[] {
172 return out
173 .split("\n")
174 .filter(Boolean)
175 .map((line) => {
176 // format: <mode> SP <type> SP <object> SP <object size> TAB <file>
177 const tabIdx = line.indexOf("\t");
178 const name = line.slice(tabIdx + 1);
179 const meta = line.slice(0, tabIdx).trim().split(/\s+/);
180 return {
181 mode: meta[0] ?? "",
182 type: (meta[1] ?? "blob") as "blob" | "tree",
183 hash: meta[2] ?? "",
184 size: meta[3] ?? "-",
185 name,
186 };
187 });
188}
189
190export function extractPatchSubject(patch: string): string {
191 for (const line of patch.split("\n").slice(0, 30)) {
192 if (line.startsWith("Subject: ")) {
193 // Strip "[PATCH ...] " prefix added by git format-patch
194 return line.slice(9).replace(/^\[PATCH[^\]]*\]\s*/, "");
195 }
196 }
197 return "";
198}
199
200export interface PatchMeta {
201 subject: string;
202 body: string;
203 author: string;
204 email: string;
205 date: string;
206}
207
208export function extractPatchMeta(patch: string): PatchMeta {
209 const lines = patch.split("\n");
210 let subject = "";
211 let author = "";
212 let email = "";
213 let date = "";
214 const bodyLines: string[] = [];
215 let inHeaders = true;
216 let pastSubject = false;
217
218 for (const line of lines) {
219 if (inHeaders) {
220 if (line.startsWith("From: ")) {
221 const match = line.slice(6).match(/^(.*?)\s*<([^>]+)>/);
222 if (match) {
223 author = match[1]!.trim();
224 email = match[2]!;
225 } else {
226 author = line.slice(6).trim();
227 }
228 } else if (line.startsWith("Date: ")) {
229 date = line.slice(6).trim();
230 } else if (line.startsWith("Subject: ")) {
231 subject = line.slice(9).replace(/^\[PATCH[^\]]*\]\s*/, "");
232 pastSubject = true;
233 } else if (pastSubject && line === "") {
234 inHeaders = false;
235 }
236 } else {
237 if (line === "---") break;
238 bodyLines.push(line);
239 }
240 }
241
242 while (
243 bodyLines.length > 0 &&
244 bodyLines[bodyLines.length - 1]!.trim() === ""
245 ) {
246 bodyLines.pop();
247 }
248
249 return { subject, body: bodyLines.join("\n"), author, email, date };
250}
251
252export const git = {
253 async init(name: string, branch = "main") {
254 return withRepoLock(name, async () => {
255 const p = repoPath(name);
256 await $`git init --bare --initial-branch=${branch} ${p}`;
257 });
258 },
259
260 async log(
261 name: string,
262 ref = "HEAD",
263 limit = 30,
264 skip = 0,
265 ): Promise<CommitEntry[]> {
266 const p = repoPath(name);
267 const sigArgs = [
268 "-c",
269 "gpg.format=ssh",
270 "-c",
271 `gpg.ssh.allowedSignersFile=${ALLOWED_SIGNERS_PATH}`,
272 ];
273 try {
274 const out =
275 await $`git ${sigArgs} -C ${p} log ${ref} --format=%H%x1f%s%x1f%an%x1f%ai%x1f%G? --max-count=${limit} --skip=${skip}`.text();
276 return parseLog(out);
277 } catch {
278 return [];
279 }
280 },
281
282 async lsTree(
283 name: string,
284 ref: string,
285 subpath = "",
286 ): Promise<TreeEntry[]> {
287 const p = repoPath(name);
288 try {
289 const args = subpath
290 ? [
291 "git",
292 "-C",
293 p,
294 "ls-tree",
295 "--long",
296 ref,
297 "--",
298 `${subpath}/`,
299 ]
300 : ["git", "-C", p, "ls-tree", "--long", ref];
301 const out = await $`${args}`.text();
302 const entries = parseLsTree(out);
303 if (subpath) {
304 // git ls-tree returns full paths like "subpath/name" — strip the prefix
305 const prefix = `${subpath}/`;
306 return entries.map((e) => ({
307 ...e,
308 name: e.name.startsWith(prefix)
309 ? e.name.slice(prefix.length)
310 : e.name,
311 }));
312 }
313 return entries;
314 } catch {
315 return [];
316 }
317 },
318
319 async show(
320 name: string,
321 ref: string,
322 filePath: string,
323 ): Promise<Buffer | null> {
324 const p = repoPath(name);
325 try {
326 const buf =
327 await $`git -C ${p} show ${`${ref}:${filePath}`}`.arrayBuffer();
328 return Buffer.from(buf);
329 } catch {
330 return null;
331 }
332 },
333
334 async diff(name: string, sha: string): Promise<string> {
335 const p = repoPath(name);
336 try {
337 return await $`git -C ${p} diff-tree --no-commit-id -r -p --root ${sha}`.text();
338 } catch {
339 return "";
340 }
341 },
342
343 async blobSize(name: string, hash: string): Promise<number> {
344 if (/^0+$/.test(hash)) return 0;
345 const p = repoPath(name);
346 try {
347 const out = await $`git -C ${p} cat-file -s ${hash}`.text();
348 return parseInt(out.trim(), 10) || 0;
349 } catch {
350 return 0;
351 }
352 },
353
354 async branches(name: string): Promise<string[]> {
355 const p = repoPath(name);
356 try {
357 // %(refname:short) must be a variable — Bun Shell parses bare `()` as subshell syntax
358 const fmt = "%(refname:short)";
359 const out = await $`git -C ${p} branch --format=${fmt}`.text();
360 return out.split("\n").filter(Boolean);
361 } catch {
362 return [];
363 }
364 },
365
366 async defaultBranch(name: string): Promise<string> {
367 const p = repoPath(name);
368 try {
369 const fmt = "%(refname:short)";
370 const branchesOut =
371 await $`git -C ${p} branch --format=${fmt}`.text();
372 const branches = branchesOut.split("\n").filter(Boolean);
373
374 // Read what HEAD points to (may be an unborn branch).
375 let headBranch: string | null = null;
376 try {
377 const out =
378 await $`git -C ${p} symbolic-ref --short HEAD`.text();
379 headBranch = out.trim();
380 } catch {
381 // detached HEAD — fall through
382 }
383
384 // Only trust HEAD if it names a branch that actually exists.
385 if (headBranch && branches.includes(headBranch)) {
386 return headBranch;
387 }
388
389 // HEAD points to an unborn branch or is detached — prefer "main",
390 // then "master", then whatever branch exists first.
391 return (
392 branches.find((b) => b === "main") ??
393 branches.find((b) => b === "master") ??
394 branches[0] ??
395 "main"
396 );
397 } catch {
398 return "main";
399 }
400 },
401
402 async getFileSize(
403 name: string,
404 ref: string,
405 filePath: string,
406 ): Promise<number | null> {
407 const p = repoPath(name);
408 try {
409 const out =
410 await $`git -C ${p} cat-file -s ${`${ref}:${filePath}`}`.text();
411 return parseInt(out.trim(), 10);
412 } catch {
413 return null;
414 }
415 },
416
417 async checkPatch(
418 name: string,
419 patchContent: string,
420 ): Promise<{ clean: boolean; output: string }> {
421 const p = repoPath(name);
422 const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
423 try {
424 await Bun.write(tmpFile, patchContent);
425 // Bare repos have no working tree; populate the index from HEAD so we can
426 // check against git objects (--cached) rather than the filesystem.
427 await $`git -C ${p} read-tree HEAD`.quiet();
428 const result =
429 await $`git -C ${p} apply --check --cached ${tmpFile}`
430 .quiet()
431 .nothrow();
432 return {
433 clean: result.exitCode === 0,
434 output: result.stderr.toString(),
435 };
436 } catch (e) {
437 return { clean: false, output: String(e) };
438 } finally {
439 await $`rm -f ${tmpFile}`.quiet().nothrow();
440 }
441 },
442
443 async applyPatch(
444 name: string,
445 patchContent: string,
446 authorName: string,
447 authorEmail: string,
448 committerName: string,
449 committerEmail: string,
450 ): Promise<void> {
451 return withRepoLock(name, async () => {
452 const p = repoPath(name);
453 const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
454 try {
455 await Bun.write(tmpFile, patchContent);
456 // Populate index, apply to index, then create a real commit in the bare repo.
457 await $`git -C ${p} read-tree HEAD`;
458 await $`git -C ${p} apply --cached ${tmpFile}`;
459 const tree = (await $`git -C ${p} write-tree`.text()).trim();
460 const parent = (
461 await $`git -C ${p} rev-parse HEAD`.text()
462 ).trim();
463 const msg = extractPatchSubject(patchContent);
464 const sigArgs = [
465 "-c",
466 "gpg.format=ssh",
467 "-c",
468 `user.signingKey=${SSH_HOST_KEY_PATH}`,
469 ];
470 const commit = (
471 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${msg}`
472 .env({
473 ...process.env,
474 LC_ALL: "C",
475 LANG: "C",
476 GIT_CONFIG_GLOBAL: "/dev/null",
477 GIT_CONFIG_SYSTEM: "/dev/null",
478 GIT_CONFIG_COUNT: "0",
479 GIT_AUTHOR_NAME: authorName,
480 GIT_AUTHOR_EMAIL: authorEmail,
481 GIT_COMMITTER_NAME: committerName,
482 GIT_COMMITTER_EMAIL: committerEmail,
483 })
484 .text()
485 ).trim();
486 const ref = (
487 await $`git -C ${p} symbolic-ref HEAD`.text()
488 ).trim();
489 await $`git -C ${p} update-ref ${ref} ${commit}`;
490 } finally {
491 await $`rm -f ${tmpFile}`.quiet().nothrow();
492 }
493 });
494 },
495
496 async createTag(
497 repoName: string,
498 tagName: string,
499 ref: string,
500 message?: string,
501 taggerName?: string,
502 taggerEmail?: string,
503 ): Promise<"ok" | "already_exists" | "bad_ref" | "error"> {
504 return withRepoLock(repoName, async () => {
505 const p = repoPath(repoName);
506 const sigArgs = [
507 "-c",
508 "gpg.format=ssh",
509 "-c",
510 `user.signingKey=${SSH_HOST_KEY_PATH}`,
511 ];
512 const result =
513 message !== undefined
514 ? await $`git ${sigArgs} -C ${p} tag -s ${tagName} ${ref} -m ${message}`
515 .env({
516 ...gitEnv,
517 GIT_COMMITTER_NAME: taggerName!,
518 GIT_COMMITTER_EMAIL: taggerEmail!,
519 })
520 .nothrow()
521 : await $`git -C ${p} tag ${tagName} ${ref}`.nothrow();
522 if (result.exitCode === 0) return "ok";
523 const stderr = result.stderr.toString();
524 if (stderr.includes("already exists")) return "already_exists";
525 if (
526 stderr.includes("not a valid object name") ||
527 stderr.includes("unknown revision") ||
528 stderr.includes("ambiguous argument")
529 )
530 return "bad_ref";
531 return "error";
532 });
533 },
534
535 async setHead(name: string, branch: string): Promise<void> {
536 const p = repoPath(name);
537 await $`git -C ${p} symbolic-ref HEAD refs/heads/${branch}`;
538 },
539
540 async resolveRef(name: string, ref: string): Promise<string | null> {
541 const p = repoPath(name);
542 try {
543 const out = await $`git -C ${p} rev-parse --verify ${ref}`.text();
544 return out.trim() || null;
545 } catch {
546 return null;
547 }
548 },
549
550 async hasCommits(name: string): Promise<boolean> {
551 const p = repoPath(name);
552 try {
553 const out = await $`git -C ${p} log --oneline -1`.quiet().text();
554 return out.trim().length > 0;
555 } catch {
556 return false;
557 }
558 },
559
560 async commitMeta(name: string, sha: string): Promise<CommitMeta | null> {
561 const p = repoPath(name);
562 const sigArgs = [
563 "-c",
564 "gpg.format=ssh",
565 "-c",
566 `gpg.ssh.allowedSignersFile=${ALLOWED_SIGNERS_PATH}`,
567 ];
568 try {
569 const [metaOut, msgOut] = await Promise.all([
570 $`git ${sigArgs} -C ${p} show --no-patch --format=%H%x1f%an%x1f%ae%x1f%ai%x1f%cn%x1f%ce%x1f%ci%x1f%P%x1f%G? ${sha}`.text(),
571 $`git -C ${p} log --format=%B -1 ${sha}`.text(),
572 ]);
573 const parts = metaOut.trim().split("\x1f");
574 const fullMsg = msgOut.trimEnd();
575 const firstNl = fullMsg.indexOf("\n");
576 const subject = firstNl >= 0 ? fullMsg.slice(0, firstNl) : fullMsg;
577 const body =
578 firstNl >= 0
579 ? fullMsg
580 .slice(firstNl + 1)
581 .trimStart()
582 .trimEnd()
583 : "";
584 return {
585 hash: parts[0] ?? sha,
586 subject,
587 body,
588 author: parts[1] ?? "",
589 email: parts[2] ?? "",
590 date: parts[3] ?? "",
591 committer: parts[4] ?? "",
592 committerEmail: parts[5] ?? "",
593 committerDate: parts[6] ?? "",
594 parents: (parts[7] ?? "").trim().split(/\s+/).filter(Boolean),
595 sigStatus: parseSigStatus(parts[8] ?? ""),
596 };
597 } catch {
598 return null;
599 }
600 },
601};
602