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 ...gitEnv,
474 GIT_AUTHOR_NAME: authorName,
475 GIT_AUTHOR_EMAIL: authorEmail,
476 GIT_COMMITTER_NAME: committerName,
477 GIT_COMMITTER_EMAIL: committerEmail,
478 })
479 .text()
480 ).trim();
481 const ref = (
482 await $`git -C ${p} symbolic-ref HEAD`.text()
483 ).trim();
484 await $`git -C ${p} update-ref ${ref} ${commit}`;
485 } finally {
486 await $`rm -f ${tmpFile}`.quiet().nothrow();
487 }
488 });
489 },
490
491 async editFile(
492 name: string,
493 branch: string,
494 filePath: string,
495 content: string,
496 message: string,
497 committerName: string,
498 committerEmail: string,
499 ): Promise<string> {
500 return withRepoLock(name, async () => {
501 const p = repoPath(name);
502 const tmpFile = `/tmp/hf-edit-${Date.now()}-${Math.random().toString(36).slice(2)}`;
503 try {
504 await Bun.write(tmpFile, content);
505 await $`git -C ${p} read-tree refs/heads/${branch}`;
506 const blobHash = (
507 await $`git -C ${p} hash-object -w ${tmpFile}`.text()
508 ).trim();
509 await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${filePath}`;
510 const tree = (
511 await $`git -C ${p} write-tree`.text()
512 ).trim();
513 const parent = (
514 await $`git -C ${p} rev-parse refs/heads/${branch}`.text()
515 ).trim();
516 const sigArgs = [
517 "-c",
518 "gpg.format=ssh",
519 "-c",
520 `user.signingKey=${SSH_HOST_KEY_PATH}`,
521 ];
522 const commit = (
523 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${message}`
524 .env({
525 ...gitEnv,
526 GIT_AUTHOR_NAME: committerName,
527 GIT_AUTHOR_EMAIL: committerEmail,
528 GIT_COMMITTER_NAME: committerName,
529 GIT_COMMITTER_EMAIL: committerEmail,
530 })
531 .text()
532 ).trim();
533 await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
534 return commit;
535 } finally {
536 await $`rm -f ${tmpFile}`.quiet().nothrow();
537 }
538 });
539 },
540
541 async createTag(
542 repoName: string,
543 tagName: string,
544 ref: string,
545 message?: string,
546 taggerName?: string,
547 taggerEmail?: string,
548 ): Promise<"ok" | "already_exists" | "bad_ref" | "error"> {
549 return withRepoLock(repoName, async () => {
550 const p = repoPath(repoName);
551 const sigArgs = [
552 "-c",
553 "gpg.format=ssh",
554 "-c",
555 `user.signingKey=${SSH_HOST_KEY_PATH}`,
556 ];
557 const result =
558 message !== undefined
559 ? await $`git ${sigArgs} -C ${p} tag -s ${tagName} ${ref} -m ${message}`
560 .env({
561 ...gitEnv,
562 GIT_COMMITTER_NAME: taggerName!,
563 GIT_COMMITTER_EMAIL: taggerEmail!,
564 })
565 .nothrow()
566 : await $`git -C ${p} tag ${tagName} ${ref}`.nothrow();
567 if (result.exitCode === 0) return "ok";
568 const stderr = result.stderr.toString();
569 if (stderr.includes("already exists")) return "already_exists";
570 if (
571 stderr.includes("not a valid object name") ||
572 stderr.includes("unknown revision") ||
573 stderr.includes("ambiguous argument")
574 )
575 return "bad_ref";
576 return "error";
577 });
578 },
579
580 async setHead(name: string, branch: string): Promise<void> {
581 const p = repoPath(name);
582 await $`git -C ${p} symbolic-ref HEAD refs/heads/${branch}`;
583 },
584
585 async resolveRef(name: string, ref: string): Promise<string | null> {
586 const p = repoPath(name);
587 try {
588 const out = await $`git -C ${p} rev-parse --verify ${ref}`.text();
589 return out.trim() || null;
590 } catch {
591 return null;
592 }
593 },
594
595 async hasCommits(name: string): Promise<boolean> {
596 const p = repoPath(name);
597 try {
598 const out = await $`git -C ${p} log --oneline -1`.quiet().text();
599 return out.trim().length > 0;
600 } catch {
601 return false;
602 }
603 },
604
605 async commitMeta(name: string, sha: string): Promise<CommitMeta | null> {
606 const p = repoPath(name);
607 const sigArgs = [
608 "-c",
609 "gpg.format=ssh",
610 "-c",
611 `gpg.ssh.allowedSignersFile=${ALLOWED_SIGNERS_PATH}`,
612 ];
613 try {
614 const [metaOut, msgOut] = await Promise.all([
615 $`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(),
616 $`git -C ${p} log --format=%B -1 ${sha}`.text(),
617 ]);
618 const parts = metaOut.trim().split("\x1f");
619 const fullMsg = msgOut.trimEnd();
620 const firstNl = fullMsg.indexOf("\n");
621 const subject = firstNl >= 0 ? fullMsg.slice(0, firstNl) : fullMsg;
622 const body =
623 firstNl >= 0
624 ? fullMsg
625 .slice(firstNl + 1)
626 .trimStart()
627 .trimEnd()
628 : "";
629 return {
630 hash: parts[0] ?? sha,
631 subject,
632 body,
633 author: parts[1] ?? "",
634 email: parts[2] ?? "",
635 date: parts[3] ?? "",
636 committer: parts[4] ?? "",
637 committerEmail: parts[5] ?? "",
638 committerDate: parts[6] ?? "",
639 parents: (parts[7] ?? "").trim().split(/\s+/).filter(Boolean),
640 sigStatus: parseSigStatus(parts[8] ?? ""),
641 };
642 } catch {
643 return null;
644 }
645 },
646};
647