git.ts
Raw
1import path from "node:path";
2import { $ as _$ } from "bun";
3
4import {
5 MAX_BRANCH_CACHE,
6 MAX_REF_LIST,
7 MAX_TAG_CACHE,
8 paths,
9 REF_CACHE_TTL_MS,
10} from "../constants.ts";
11
12const gitEnv = {
13 ...process.env,
14 LC_ALL: "C",
15 LANG: "C",
16 GIT_CONFIG_GLOBAL: "/dev/null",
17 GIT_CONFIG_SYSTEM: "/dev/null",
18 GIT_CONFIG_COUNT: "0",
19 GIT_ASKPASS: "echo",
20 GIT_TERMINAL_PROMPT: "0",
21};
22
23const $ = _$.env(gitEnv);
24
25// Per-repo mutex: prevents concurrent git write operations on the same repo
26// (e.g. two patches being merged simultaneously, which would corrupt the index).
27const repoWriteLocks = new Map<string, Promise<void>>();
28
29// Short-lived caches for ref lists — these change only on push/branch ops.
30const branchCache = new Map<string, { value: string[]; expiresAt: number }>();
31const tagCache = new Map<string, { value: string[]; expiresAt: number }>();
32
33export function invalidateRefCache(name: string): void {
34 branchCache.delete(name);
35 tagCache.delete(name);
36}
37
38async function withRepoLock<T>(name: string, fn: () => Promise<T>): Promise<T> {
39 const prev = repoWriteLocks.get(name) ?? Promise.resolve();
40 let unlock!: () => void;
41 repoWriteLocks.set(
42 name,
43 prev.then(
44 () =>
45 new Promise<void>((res) => {
46 unlock = res;
47 }),
48 ),
49 );
50 await prev;
51 try {
52 return await fn();
53 } finally {
54 unlock();
55 }
56}
57
58export function repoPath(name: string): string {
59 return path.join(paths.REPOS_DIR, `${name}.git`);
60}
61
62export async function validateCommit(
63 repoName: string,
64 hash: string,
65): Promise<boolean> {
66 const p = repoPath(repoName);
67 try {
68 const out = await $`git -C ${p} cat-file -t ${hash}`.text();
69 return out.trim() === "commit";
70 } catch {
71 return false;
72 }
73}
74
75export async function archiveRepo(
76 repoName: string,
77 ref: string,
78 slug: string,
79 outDir: string,
80 signal?: AbortSignal,
81): Promise<void> {
82 const p = repoPath(repoName);
83 const base = `${slug}-${ref}`;
84
85 const zip = Bun.spawn(
86 [
87 "git",
88 "-C",
89 p,
90 "archive",
91 "--format=zip",
92 `--output=${path.join(outDir, `${base}.zip`)}`,
93 ref,
94 ],
95 { signal, env: gitEnv },
96 );
97 if ((await zip.exited) !== 0) throw new Error("git archive (zip) failed");
98
99 const tgz = Bun.spawn(
100 [
101 "git",
102 "-C",
103 p,
104 "archive",
105 "--format=tar.gz",
106 `--output=${path.join(outDir, `${base}.tar.gz`)}`,
107 ref,
108 ],
109 { signal, env: gitEnv },
110 );
111 if ((await tgz.exited) !== 0)
112 throw new Error("git archive (tar.gz) failed");
113
114 try {
115 const tar = Bun.spawn(
116 ["git", "-C", p, "archive", "--format=tar", ref],
117 { signal, env: gitEnv, stdout: "pipe" },
118 );
119 const zst = Bun.spawn(
120 ["zstd", "-o", path.join(outDir, `${base}.tar.zst`)],
121 { signal, env: gitEnv, stdin: tar.stdout },
122 );
123 await Promise.all([tar.exited, zst.exited]);
124 } catch {
125 // zstd not available — skip silently
126 }
127}
128
129export interface CommitEntry {
130 hash: string;
131 subject: string;
132 author: string;
133 date: string;
134 sigStatus: "good" | "bad" | "none";
135}
136
137export interface CommitMeta {
138 hash: string;
139 subject: string;
140 body: string;
141 author: string;
142 email: string;
143 date: string;
144 committer: string;
145 committerEmail: string;
146 committerDate: string;
147 parents: string[];
148 sigStatus: "good" | "bad" | "none";
149}
150
151export interface TreeEntry {
152 mode: string;
153 type: "blob" | "tree";
154 hash: string;
155 size: string;
156 name: string;
157}
158
159function parseSigStatus(code: string): "good" | "bad" | "none" {
160 if (code === "G" || code === "X" || code === "Y" || code === "R")
161 return "good";
162 if (code === "B" || code === "U" || code === "E") return "bad";
163 return "none";
164}
165
166function parseLog(out: string): CommitEntry[] {
167 return out
168 .split("\n")
169 .filter(Boolean)
170 .map((line) => {
171 const parts = line.split("\x1f");
172 return {
173 hash: parts[0] ?? "",
174 subject: parts[1] ?? "",
175 author: parts[2] ?? "",
176 date: parts[3] ?? "",
177 sigStatus: parseSigStatus(parts[4] ?? ""),
178 };
179 });
180}
181
182function parseLsTree(out: string): TreeEntry[] {
183 return out
184 .split("\n")
185 .filter(Boolean)
186 .map((line) => {
187 // format: <mode> SP <type> SP <object> SP <object size> TAB <file>
188 const tabIdx = line.indexOf("\t");
189 const name = line.slice(tabIdx + 1);
190 const meta = line.slice(0, tabIdx).trim().split(/\s+/);
191 return {
192 mode: meta[0] ?? "",
193 type: (meta[1] ?? "blob") as "blob" | "tree",
194 hash: meta[2] ?? "",
195 size: meta[3] ?? "-",
196 name,
197 };
198 });
199}
200
201export function extractPatchSubject(patch: string): string {
202 for (const line of patch.split("\n").slice(0, 30)) {
203 if (line.startsWith("Subject: ")) {
204 // Strip "[PATCH ...] " prefix added by git format-patch
205 return line.slice(9).replace(/^\[PATCH[^\]]*\]\s*/, "");
206 }
207 }
208 return "";
209}
210
211export interface BranchInfo {
212 name: string;
213 shortHash: string;
214 subject: string;
215 authorName: string;
216 date: string;
217}
218
219export interface TagInfo {
220 name: string;
221 shortHash: string;
222 subject: string;
223 taggerName: string;
224 date: string;
225 isAnnotated: boolean;
226}
227
228export interface PatchMeta {
229 subject: string;
230 body: string;
231 author: string;
232 email: string;
233 date: string;
234}
235
236export function extractPatchMeta(patch: string): PatchMeta {
237 const lines = patch.split("\n");
238 let subject = "";
239 let author = "";
240 let email = "";
241 let date = "";
242 const bodyLines: string[] = [];
243 let inHeaders = true;
244 let pastSubject = false;
245
246 for (const line of lines) {
247 if (inHeaders) {
248 if (line.startsWith("From: ")) {
249 const match = line.slice(6).match(/^(.*?)\s*<([^>]+)>/);
250 if (match) {
251 author = match[1]!.trim();
252 email = match[2]!;
253 } else {
254 author = line.slice(6).trim();
255 }
256 } else if (line.startsWith("Date: ")) {
257 date = line.slice(6).trim();
258 } else if (line.startsWith("Subject: ")) {
259 subject = line.slice(9).replace(/^\[PATCH[^\]]*\]\s*/, "");
260 pastSubject = true;
261 } else if (pastSubject && line === "") {
262 inHeaders = false;
263 }
264 } else {
265 if (line === "---") break;
266 bodyLines.push(line);
267 }
268 }
269
270 while (
271 bodyLines.length > 0 &&
272 bodyLines[bodyLines.length - 1]!.trim() === ""
273 ) {
274 bodyLines.pop();
275 }
276
277 return { subject, body: bodyLines.join("\n"), author, email, date };
278}
279
280export const git = {
281 async init(name: string, branch = "main") {
282 return withRepoLock(name, async () => {
283 const p = repoPath(name);
284 await $`git init --bare --initial-branch=${branch} ${p}`;
285 });
286 },
287
288 async log(
289 name: string,
290 ref = "HEAD",
291 limit = 30,
292 skip = 0,
293 ): Promise<CommitEntry[]> {
294 const p = repoPath(name);
295 const sigArgs = [
296 "-c",
297 "gpg.format=ssh",
298 "-c",
299 `gpg.ssh.allowedSignersFile=${paths.ALLOWED_SIGNERS_PATH}`,
300 ];
301 try {
302 const out =
303 await $`git ${sigArgs} -C ${p} log ${ref} --format=%H%x1f%s%x1f%an%x1f%ai%x1f%G? --max-count=${limit} --skip=${skip}`.text();
304 return parseLog(out);
305 } catch {
306 return [];
307 }
308 },
309
310 async lsTree(
311 name: string,
312 ref: string,
313 subpath = "",
314 ): Promise<TreeEntry[]> {
315 const p = repoPath(name);
316 try {
317 const args = subpath
318 ? [
319 "git",
320 "-C",
321 p,
322 "ls-tree",
323 "--long",
324 ref,
325 "--",
326 `${subpath}/`,
327 ]
328 : ["git", "-C", p, "ls-tree", "--long", ref];
329 const out = await $`${args}`.text();
330 const entries = parseLsTree(out);
331 if (subpath) {
332 // git ls-tree returns full paths like "subpath/name" — strip the prefix
333 const prefix = `${subpath}/`;
334 return entries.map((e) => ({
335 ...e,
336 name: e.name.startsWith(prefix)
337 ? e.name.slice(prefix.length)
338 : e.name,
339 }));
340 }
341 return entries;
342 } catch {
343 return [];
344 }
345 },
346
347 async show(
348 name: string,
349 ref: string,
350 filePath: string,
351 ): Promise<Buffer | null> {
352 const p = repoPath(name);
353 try {
354 const buf =
355 await $`git -C ${p} show ${`${ref}:${filePath}`}`.arrayBuffer();
356 return Buffer.from(buf);
357 } catch {
358 return null;
359 }
360 },
361
362 async diff(name: string, sha: string): Promise<string> {
363 const p = repoPath(name);
364 try {
365 return await $`git -C ${p} diff-tree --no-commit-id -r -p -M --root ${sha}`.text();
366 } catch {
367 return "";
368 }
369 },
370
371 async blobSize(name: string, hash: string): Promise<number> {
372 if (/^0+$/.test(hash)) return 0;
373 const p = repoPath(name);
374 try {
375 const out = await $`git -C ${p} cat-file -s ${hash}`.text();
376 return parseInt(out.trim(), 10) || 0;
377 } catch {
378 return 0;
379 }
380 },
381
382 async branches(name: string): Promise<string[]> {
383 const now = Date.now();
384 const cached = branchCache.get(name);
385 if (cached && cached.expiresAt > now) return cached.value;
386 const p = repoPath(name);
387 try {
388 // %(refname:short) must be a variable — Bun Shell parses bare `()` as subshell syntax
389 const fmt = "%(refname:short)";
390 const out = await $`git -C ${p} branch --format=${fmt}`.text();
391 const value = out.split("\n").filter(Boolean);
392 branchCache.set(name, { value, expiresAt: now + REF_CACHE_TTL_MS });
393 if (branchCache.size > MAX_BRANCH_CACHE) {
394 branchCache.delete(branchCache.keys().next().value!);
395 }
396 return value;
397 } catch {
398 return [];
399 }
400 },
401
402 async tags(name: string): Promise<string[]> {
403 const now = Date.now();
404 const cached = tagCache.get(name);
405 if (cached && cached.expiresAt > now) return cached.value;
406 const p = repoPath(name);
407 try {
408 const fmt = "%(refname:short)";
409 const out =
410 await $`git -C ${p} for-each-ref --format=${fmt} refs/tags/`.text();
411 const value = out.split("\n").filter(Boolean);
412 tagCache.set(name, { value, expiresAt: now + REF_CACHE_TTL_MS });
413 if (tagCache.size > MAX_TAG_CACHE) {
414 tagCache.delete(tagCache.keys().next().value!);
415 }
416 return value;
417 } catch {
418 return [];
419 }
420 },
421
422 async branchesWithInfo(
423 name: string,
424 maxCount = MAX_REF_LIST,
425 ): Promise<BranchInfo[]> {
426 const p = repoPath(name);
427 try {
428 // Use actual unit separator byte (\x1f) — git for-each-ref does not
429 // support the %x1f hex escape (that is a git-log pretty-format feature).
430 const sep = "\x1f";
431 const fmt = `%(refname:short)${sep}%(objectname:short)${sep}%(contents:subject)${sep}%(authorname)${sep}%(authordate:iso8601)`;
432 const out =
433 await $`git -C ${p} for-each-ref --sort=-creatordate --count=${maxCount} --format=${fmt} refs/heads/`.text();
434 return out
435 .split("\n")
436 .filter(Boolean)
437 .map((line) => {
438 const parts = line.split(sep);
439 return {
440 name: parts[0] ?? "",
441 shortHash: parts[1] ?? "",
442 subject: parts[2] ?? "",
443 authorName: parts[3] ?? "",
444 date: parts[4] ?? "",
445 };
446 });
447 } catch {
448 return [];
449 }
450 },
451
452 async tagsWithInfo(name: string, maxCount = 1000): Promise<TagInfo[]> {
453 const p = repoPath(name);
454 try {
455 // Use actual unit separator byte (\x1f) — git for-each-ref does not
456 // support the %x1f hex escape (that is a git-log pretty-format feature).
457 // %(*objectname:short) is the dereferenced commit for annotated tags; empty for lightweight.
458 const sep = "\x1f";
459 const fmt = `%(refname:short)${sep}%(*objectname:short)${sep}%(objectname:short)${sep}%(contents:subject)${sep}%(taggername)${sep}%(creatordate:iso8601)`;
460 const out =
461 await $`git -C ${p} for-each-ref --sort=-creatordate --count=${maxCount} --format=${fmt} refs/tags/`.text();
462 return out
463 .split("\n")
464 .filter(Boolean)
465 .map((line) => {
466 const parts = line.split(sep);
467 const derefHash = (parts[1] ?? "").trim();
468 const ownHash = (parts[2] ?? "").trim();
469 const isAnnotated = derefHash.length > 0;
470 return {
471 name: parts[0] ?? "",
472 shortHash: isAnnotated ? derefHash : ownHash,
473 subject: parts[3] ?? "",
474 taggerName: parts[4] ?? "",
475 date: parts[5] ?? "",
476 isAnnotated,
477 };
478 });
479 } catch {
480 return [];
481 }
482 },
483
484 async defaultBranch(name: string): Promise<string> {
485 const p = repoPath(name);
486 try {
487 const branches = await git.branches(name);
488
489 // Read what HEAD points to (may be an unborn branch).
490 let headBranch: string | null = null;
491 try {
492 const out =
493 await $`git -C ${p} symbolic-ref --short HEAD`.text();
494 headBranch = out.trim();
495 } catch {
496 // detached HEAD — fall through
497 }
498
499 // Only trust HEAD if it names a branch that actually exists.
500 if (headBranch && branches.includes(headBranch)) {
501 return headBranch;
502 }
503
504 // HEAD points to an unborn branch or is detached — prefer "main",
505 // then "master", then whatever branch exists first.
506 return (
507 branches.find((b) => b === "main") ??
508 branches.find((b) => b === "master") ??
509 branches[0] ??
510 "main"
511 );
512 } catch {
513 return "main";
514 }
515 },
516
517 async getFileSize(
518 name: string,
519 ref: string,
520 filePath: string,
521 ): Promise<number | null> {
522 const p = repoPath(name);
523 try {
524 const out =
525 await $`git -C ${p} cat-file -s ${`${ref}:${filePath}`}`.text();
526 return parseInt(out.trim(), 10);
527 } catch {
528 return null;
529 }
530 },
531
532 async checkPatch(
533 name: string,
534 patchContent: string,
535 ): Promise<{ clean: boolean; output: string }> {
536 const p = repoPath(name);
537 const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
538 try {
539 await Bun.write(tmpFile, patchContent);
540 // Bare repos have no working tree; populate the index from HEAD so we can
541 // check against git objects (--cached) rather than the filesystem.
542 await $`git -C ${p} read-tree HEAD`.quiet();
543 const result =
544 await $`git -C ${p} apply --check --cached ${tmpFile}`
545 .quiet()
546 .nothrow();
547 return {
548 clean: result.exitCode === 0,
549 output: result.stderr.toString(),
550 };
551 } catch (e) {
552 return { clean: false, output: String(e) };
553 } finally {
554 await $`rm -f ${tmpFile}`.quiet().nothrow();
555 }
556 },
557
558 async applyPatch(
559 name: string,
560 patchContent: string,
561 authorName: string,
562 authorEmail: string,
563 committerName: string,
564 committerEmail: string,
565 ): Promise<void> {
566 return withRepoLock(name, async () => {
567 const p = repoPath(name);
568 const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
569 try {
570 await Bun.write(tmpFile, patchContent);
571 // Populate index, apply to index, then create a real commit in the bare repo.
572 await $`git -C ${p} read-tree HEAD`;
573 await $`git -C ${p} apply --cached ${tmpFile}`;
574 const tree = (await $`git -C ${p} write-tree`.text()).trim();
575 const parent = (
576 await $`git -C ${p} rev-parse HEAD`.text()
577 ).trim();
578 const msg = extractPatchSubject(patchContent);
579 const sigArgs = [
580 "-c",
581 "gpg.format=ssh",
582 "-c",
583 `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
584 ];
585 const commit = (
586 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${msg}`
587 .env({
588 ...gitEnv,
589 GIT_AUTHOR_NAME: authorName,
590 GIT_AUTHOR_EMAIL: authorEmail,
591 GIT_COMMITTER_NAME: committerName,
592 GIT_COMMITTER_EMAIL: committerEmail,
593 })
594 .text()
595 ).trim();
596 const ref = (
597 await $`git -C ${p} symbolic-ref HEAD`.text()
598 ).trim();
599 await $`git -C ${p} update-ref ${ref} ${commit}`;
600 } finally {
601 await $`rm -f ${tmpFile}`.quiet().nothrow();
602 }
603 });
604 },
605
606 async editFile(
607 name: string,
608 branch: string,
609 filePath: string,
610 content: string,
611 message: string,
612 committerName: string,
613 committerEmail: string,
614 newPath?: string,
615 ): Promise<string> {
616 return withRepoLock(name, async () => {
617 const targetPath =
618 newPath && newPath !== filePath ? newPath : filePath;
619 const isMove = targetPath !== filePath;
620 const p = repoPath(name);
621 const tmpFile = `/tmp/hf-edit-${Date.now()}-${Math.random().toString(36).slice(2)}`;
622 try {
623 await Bun.write(tmpFile, content);
624 if (isMove) {
625 await $`git --work-tree=/tmp -C ${p} read-tree refs/heads/${branch}`;
626 } else {
627 await $`git -C ${p} read-tree refs/heads/${branch}`;
628 }
629 const blobHash = (
630 await $`git -C ${p} hash-object -w ${tmpFile}`.text()
631 ).trim();
632 if (isMove) {
633 await $`git --work-tree=/tmp -C ${p} update-index --remove ${filePath}`;
634 }
635 await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${targetPath}`;
636 const tree = isMove
637 ? (
638 await $`git --work-tree=/tmp -C ${p} write-tree`.text()
639 ).trim()
640 : (await $`git -C ${p} write-tree`.text()).trim();
641 const parent = (
642 await $`git -C ${p} rev-parse refs/heads/${branch}`.text()
643 ).trim();
644 const sigArgs = [
645 "-c",
646 "gpg.format=ssh",
647 "-c",
648 `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
649 ];
650 const commit = (
651 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${message}`
652 .env({
653 ...gitEnv,
654 GIT_AUTHOR_NAME: committerName,
655 GIT_AUTHOR_EMAIL: committerEmail,
656 GIT_COMMITTER_NAME: committerName,
657 GIT_COMMITTER_EMAIL: committerEmail,
658 })
659 .text()
660 ).trim();
661 await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
662 return commit;
663 } finally {
664 await $`rm -f ${tmpFile}`.quiet().nothrow();
665 }
666 });
667 },
668
669 async createFile(
670 name: string,
671 branch: string,
672 filePath: string,
673 content: string,
674 message: string,
675 committerName: string,
676 committerEmail: string,
677 ): Promise<string> {
678 return withRepoLock(name, async () => {
679 const p = repoPath(name);
680 const tmpFile = `/tmp/hf-new-${Date.now()}-${Math.random().toString(36).slice(2)}`;
681 try {
682 await Bun.write(tmpFile, content);
683 const parentSha = await git.resolveRef(
684 name,
685 `refs/heads/${branch}`,
686 );
687 if (parentSha) {
688 await $`git -C ${p} read-tree refs/heads/${branch}`;
689 }
690 const blobHash = (
691 await $`git -C ${p} hash-object -w ${tmpFile}`.text()
692 ).trim();
693 await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${filePath}`;
694 const tree = (await $`git -C ${p} write-tree`.text()).trim();
695 const sigArgs = [
696 "-c",
697 "gpg.format=ssh",
698 "-c",
699 `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
700 ];
701 const commitEnv = {
702 ...gitEnv,
703 GIT_AUTHOR_NAME: committerName,
704 GIT_AUTHOR_EMAIL: committerEmail,
705 GIT_COMMITTER_NAME: committerName,
706 GIT_COMMITTER_EMAIL: committerEmail,
707 };
708 const commit = parentSha
709 ? (
710 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parentSha} -m ${message}`
711 .env(commitEnv)
712 .text()
713 ).trim()
714 : (
715 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -m ${message}`
716 .env(commitEnv)
717 .text()
718 ).trim();
719 await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
720 return commit;
721 } finally {
722 await $`rm -f ${tmpFile}`.quiet().nothrow();
723 }
724 });
725 },
726
727 async deleteFile(
728 name: string,
729 branch: string,
730 filePath: string,
731 message: string,
732 committerName: string,
733 committerEmail: string,
734 ): Promise<string> {
735 return withRepoLock(name, async () => {
736 const p = repoPath(name);
737 // --work-tree=/tmp is needed because bare repos have no work tree and
738 // `update-index --remove` requires one (even though it only touches the index).
739 await $`git --work-tree=/tmp -C ${p} read-tree refs/heads/${branch}`;
740 await $`git --work-tree=/tmp -C ${p} update-index --remove ${filePath}`;
741 const tree = (
742 await $`git --work-tree=/tmp -C ${p} write-tree`.text()
743 ).trim();
744 const parent = (
745 await $`git -C ${p} rev-parse refs/heads/${branch}`.text()
746 ).trim();
747 const sigArgs = [
748 "-c",
749 "gpg.format=ssh",
750 "-c",
751 `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
752 ];
753 const commit = (
754 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${message}`
755 .env({
756 ...gitEnv,
757 GIT_AUTHOR_NAME: committerName,
758 GIT_AUTHOR_EMAIL: committerEmail,
759 GIT_COMMITTER_NAME: committerName,
760 GIT_COMMITTER_EMAIL: committerEmail,
761 })
762 .text()
763 ).trim();
764 await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
765 return commit;
766 });
767 },
768
769 async moveFile(
770 name: string,
771 branch: string,
772 oldPath: string,
773 newPath: string,
774 message: string,
775 committerName: string,
776 committerEmail: string,
777 ): Promise<string> {
778 return withRepoLock(name, async () => {
779 const p = repoPath(name);
780 const tmpFile = `/tmp/hf-move-${Date.now()}-${Math.random().toString(36).slice(2)}`;
781 try {
782 const contentBuf =
783 await $`git -C ${p} show ${`${branch}:${oldPath}`}`.arrayBuffer();
784 await Bun.write(tmpFile, contentBuf);
785 // --work-tree=/tmp is needed because bare repos have no work tree and
786 // `update-index --remove` requires one (even though it only touches the index).
787 await $`git --work-tree=/tmp -C ${p} read-tree refs/heads/${branch}`;
788 const blobHash = (
789 await $`git -C ${p} hash-object -w ${tmpFile}`.text()
790 ).trim();
791 await $`git --work-tree=/tmp -C ${p} update-index --remove ${oldPath}`;
792 await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${newPath}`;
793 const tree = (
794 await $`git --work-tree=/tmp -C ${p} write-tree`.text()
795 ).trim();
796 const parent = (
797 await $`git -C ${p} rev-parse refs/heads/${branch}`.text()
798 ).trim();
799 const sigArgs = [
800 "-c",
801 "gpg.format=ssh",
802 "-c",
803 `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
804 ];
805 const commit = (
806 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${message}`
807 .env({
808 ...gitEnv,
809 GIT_AUTHOR_NAME: committerName,
810 GIT_AUTHOR_EMAIL: committerEmail,
811 GIT_COMMITTER_NAME: committerName,
812 GIT_COMMITTER_EMAIL: committerEmail,
813 })
814 .text()
815 ).trim();
816 await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
817 return commit;
818 } finally {
819 await $`rm -f ${tmpFile}`.quiet().nothrow();
820 }
821 });
822 },
823
824 async createTag(
825 repoName: string,
826 tagName: string,
827 ref: string,
828 message?: string,
829 taggerName?: string,
830 taggerEmail?: string,
831 ): Promise<"ok" | "already_exists" | "bad_ref" | "error"> {
832 return withRepoLock(repoName, async () => {
833 const p = repoPath(repoName);
834 const sigArgs = [
835 "-c",
836 "gpg.format=ssh",
837 "-c",
838 `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
839 ];
840 const result =
841 message !== undefined
842 ? await $`git ${sigArgs} -C ${p} tag -s ${tagName} ${ref} -m ${message}`
843 .env({
844 ...gitEnv,
845 GIT_COMMITTER_NAME: taggerName!,
846 GIT_COMMITTER_EMAIL: taggerEmail!,
847 })
848 .nothrow()
849 : await $`git -C ${p} tag ${tagName} ${ref}`.nothrow();
850 if (result.exitCode === 0) {
851 invalidateRefCache(repoName);
852 return "ok";
853 }
854 const stderr = result.stderr.toString();
855 if (stderr.includes("already exists")) return "already_exists";
856 if (
857 stderr.includes("not a valid object name") ||
858 stderr.includes("unknown revision") ||
859 stderr.includes("ambiguous argument")
860 )
861 return "bad_ref";
862 return "error";
863 });
864 },
865
866 async createBranch(
867 name: string,
868 branchName: string,
869 sourceRef: string,
870 ): Promise<"ok" | "already_exists" | "bad_ref" | "error"> {
871 return withRepoLock(name, async () => {
872 const p = repoPath(name);
873 try {
874 const sha = await git.resolveRef(name, sourceRef);
875 if (!sha) return "bad_ref";
876 const exists = await git.resolveRef(
877 name,
878 `refs/heads/${branchName}`,
879 );
880 if (exists) return "already_exists";
881 await $`git -C ${p} update-ref refs/heads/${branchName} ${sha}`;
882 invalidateRefCache(name);
883 return "ok";
884 } catch {
885 return "error";
886 }
887 });
888 },
889
890 async deleteBranch(
891 name: string,
892 branchName: string,
893 ): Promise<"ok" | "not_found" | "error"> {
894 return withRepoLock(name, async () => {
895 const p = repoPath(name);
896 try {
897 const exists = await git.resolveRef(
898 name,
899 `refs/heads/${branchName}`,
900 );
901 if (!exists) return "not_found";
902 await $`git -C ${p} update-ref -d refs/heads/${branchName}`;
903 invalidateRefCache(name);
904 return "ok";
905 } catch {
906 return "error";
907 }
908 });
909 },
910
911 async renameBranch(
912 name: string,
913 oldName: string,
914 newName: string,
915 ): Promise<"ok" | "not_found" | "already_exists" | "error"> {
916 return withRepoLock(name, async () => {
917 const p = repoPath(name);
918 try {
919 const sha = await git.resolveRef(name, `refs/heads/${oldName}`);
920 if (!sha) return "not_found";
921 const exists = await git.resolveRef(
922 name,
923 `refs/heads/${newName}`,
924 );
925 if (exists) return "already_exists";
926 await $`git -C ${p} update-ref refs/heads/${newName} ${sha}`;
927 await $`git -C ${p} update-ref -d refs/heads/${oldName}`;
928 invalidateRefCache(name);
929 return "ok";
930 } catch {
931 return "error";
932 }
933 });
934 },
935
936 async deleteTag(
937 name: string,
938 tagName: string,
939 ): Promise<"ok" | "not_found" | "error"> {
940 return withRepoLock(name, async () => {
941 const p = repoPath(name);
942 try {
943 const exists = await git.resolveRef(
944 name,
945 `refs/tags/${tagName}`,
946 );
947 if (!exists) return "not_found";
948 await $`git -C ${p} tag -d ${tagName}`;
949 invalidateRefCache(name);
950 return "ok";
951 } catch {
952 return "error";
953 }
954 });
955 },
956
957 async setHead(name: string, branch: string): Promise<void> {
958 const p = repoPath(name);
959 await $`git -C ${p} symbolic-ref HEAD refs/heads/${branch}`;
960 },
961
962 async resolveRef(name: string, ref: string): Promise<string | null> {
963 const p = repoPath(name);
964 try {
965 const out = await $`git -C ${p} rev-parse --verify ${ref}`.text();
966 return out.trim() || null;
967 } catch {
968 return null;
969 }
970 },
971
972 async hasCommits(name: string): Promise<boolean> {
973 const p = repoPath(name);
974 try {
975 const out = await $`git -C ${p} log --oneline -1`.quiet().text();
976 return out.trim().length > 0;
977 } catch {
978 return false;
979 }
980 },
981
982 async commitMeta(name: string, sha: string): Promise<CommitMeta | null> {
983 const p = repoPath(name);
984 const sigArgs = [
985 "-c",
986 "gpg.format=ssh",
987 "-c",
988 `gpg.ssh.allowedSignersFile=${paths.ALLOWED_SIGNERS_PATH}`,
989 ];
990 try {
991 const [metaOut, msgOut] = await Promise.all([
992 $`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(),
993 $`git -C ${p} log --format=%B -1 ${sha}`.text(),
994 ]);
995 const parts = metaOut.trim().split("\x1f");
996 const fullMsg = msgOut.trimEnd();
997 const firstNl = fullMsg.indexOf("\n");
998 const subject = firstNl >= 0 ? fullMsg.slice(0, firstNl) : fullMsg;
999 const body =
1000 firstNl >= 0
1001 ? fullMsg
1002 .slice(firstNl + 1)
1003 .trimStart()
1004 .trimEnd()
1005 : "";
1006 return {
1007 hash: parts[0] ?? sha,
1008 subject,
1009 body,
1010 author: parts[1] ?? "",
1011 email: parts[2] ?? "",
1012 date: parts[3] ?? "",
1013 committer: parts[4] ?? "",
1014 committerEmail: parts[5] ?? "",
1015 committerDate: parts[6] ?? "",
1016 parents: (parts[7] ?? "").trim().split(/\s+/).filter(Boolean),
1017 sigStatus: parseSigStatus(parts[8] ?? ""),
1018 };
1019 } catch {
1020 return null;
1021 }
1022 },
1023};
1024