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