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 BranchInfo {
197 name: string;
198 shortHash: string;
199 subject: string;
200 authorName: string;
201 date: string;
202}
203
204export interface TagInfo {
205 name: string;
206 shortHash: string;
207 subject: string;
208 taggerName: string;
209 date: string;
210 isAnnotated: boolean;
211}
212
213export interface PatchMeta {
214 subject: string;
215 body: string;
216 author: string;
217 email: string;
218 date: string;
219}
220
221export function extractPatchMeta(patch: string): PatchMeta {
222 const lines = patch.split("\n");
223 let subject = "";
224 let author = "";
225 let email = "";
226 let date = "";
227 const bodyLines: string[] = [];
228 let inHeaders = true;
229 let pastSubject = false;
230
231 for (const line of lines) {
232 if (inHeaders) {
233 if (line.startsWith("From: ")) {
234 const match = line.slice(6).match(/^(.*?)\s*<([^>]+)>/);
235 if (match) {
236 author = match[1]!.trim();
237 email = match[2]!;
238 } else {
239 author = line.slice(6).trim();
240 }
241 } else if (line.startsWith("Date: ")) {
242 date = line.slice(6).trim();
243 } else if (line.startsWith("Subject: ")) {
244 subject = line.slice(9).replace(/^\[PATCH[^\]]*\]\s*/, "");
245 pastSubject = true;
246 } else if (pastSubject && line === "") {
247 inHeaders = false;
248 }
249 } else {
250 if (line === "---") break;
251 bodyLines.push(line);
252 }
253 }
254
255 while (
256 bodyLines.length > 0 &&
257 bodyLines[bodyLines.length - 1]!.trim() === ""
258 ) {
259 bodyLines.pop();
260 }
261
262 return { subject, body: bodyLines.join("\n"), author, email, date };
263}
264
265export const git = {
266 async init(name: string, branch = "main") {
267 return withRepoLock(name, async () => {
268 const p = repoPath(name);
269 await $`git init --bare --initial-branch=${branch} ${p}`;
270 });
271 },
272
273 async log(
274 name: string,
275 ref = "HEAD",
276 limit = 30,
277 skip = 0,
278 ): Promise<CommitEntry[]> {
279 const p = repoPath(name);
280 const sigArgs = [
281 "-c",
282 "gpg.format=ssh",
283 "-c",
284 `gpg.ssh.allowedSignersFile=${paths.ALLOWED_SIGNERS_PATH}`,
285 ];
286 try {
287 const out =
288 await $`git ${sigArgs} -C ${p} log ${ref} --format=%H%x1f%s%x1f%an%x1f%ai%x1f%G? --max-count=${limit} --skip=${skip}`.text();
289 return parseLog(out);
290 } catch {
291 return [];
292 }
293 },
294
295 async lsTree(
296 name: string,
297 ref: string,
298 subpath = "",
299 ): Promise<TreeEntry[]> {
300 const p = repoPath(name);
301 try {
302 const args = subpath
303 ? [
304 "git",
305 "-C",
306 p,
307 "ls-tree",
308 "--long",
309 ref,
310 "--",
311 `${subpath}/`,
312 ]
313 : ["git", "-C", p, "ls-tree", "--long", ref];
314 const out = await $`${args}`.text();
315 const entries = parseLsTree(out);
316 if (subpath) {
317 // git ls-tree returns full paths like "subpath/name" — strip the prefix
318 const prefix = `${subpath}/`;
319 return entries.map((e) => ({
320 ...e,
321 name: e.name.startsWith(prefix)
322 ? e.name.slice(prefix.length)
323 : e.name,
324 }));
325 }
326 return entries;
327 } catch {
328 return [];
329 }
330 },
331
332 async show(
333 name: string,
334 ref: string,
335 filePath: string,
336 ): Promise<Buffer | null> {
337 const p = repoPath(name);
338 try {
339 const buf =
340 await $`git -C ${p} show ${`${ref}:${filePath}`}`.arrayBuffer();
341 return Buffer.from(buf);
342 } catch {
343 return null;
344 }
345 },
346
347 async diff(name: string, sha: string): Promise<string> {
348 const p = repoPath(name);
349 try {
350 return await $`git -C ${p} diff-tree --no-commit-id -r -p -M --root ${sha}`.text();
351 } catch {
352 return "";
353 }
354 },
355
356 async blobSize(name: string, hash: string): Promise<number> {
357 if (/^0+$/.test(hash)) return 0;
358 const p = repoPath(name);
359 try {
360 const out = await $`git -C ${p} cat-file -s ${hash}`.text();
361 return parseInt(out.trim(), 10) || 0;
362 } catch {
363 return 0;
364 }
365 },
366
367 async branches(name: string): Promise<string[]> {
368 const p = repoPath(name);
369 try {
370 // %(refname:short) must be a variable — Bun Shell parses bare `()` as subshell syntax
371 const fmt = "%(refname:short)";
372 const out = await $`git -C ${p} branch --format=${fmt}`.text();
373 return out.split("\n").filter(Boolean);
374 } catch {
375 return [];
376 }
377 },
378
379 async tags(name: string): Promise<string[]> {
380 const p = repoPath(name);
381 try {
382 const fmt = "%(refname:short)";
383 const out =
384 await $`git -C ${p} for-each-ref --format=${fmt} refs/tags/`.text();
385 return out.split("\n").filter(Boolean);
386 } catch {
387 return [];
388 }
389 },
390
391 async branchesWithInfo(name: string): Promise<BranchInfo[]> {
392 const p = repoPath(name);
393 try {
394 // Use actual unit separator byte (\x1f) — git for-each-ref does not
395 // support the %x1f hex escape (that is a git-log pretty-format feature).
396 const sep = "\x1f";
397 const fmt = `%(refname:short)${sep}%(objectname:short)${sep}%(contents:subject)${sep}%(authorname)${sep}%(authordate:iso8601)`;
398 const out =
399 await $`git -C ${p} for-each-ref --format=${fmt} refs/heads/`.text();
400 return out
401 .split("\n")
402 .filter(Boolean)
403 .map((line) => {
404 const parts = line.split(sep);
405 return {
406 name: parts[0] ?? "",
407 shortHash: parts[1] ?? "",
408 subject: parts[2] ?? "",
409 authorName: parts[3] ?? "",
410 date: parts[4] ?? "",
411 };
412 });
413 } catch {
414 return [];
415 }
416 },
417
418 async tagsWithInfo(name: string): Promise<TagInfo[]> {
419 const p = repoPath(name);
420 try {
421 // Use actual unit separator byte (\x1f) — git for-each-ref does not
422 // support the %x1f hex escape (that is a git-log pretty-format feature).
423 // %(*objectname:short) is the dereferenced commit for annotated tags; empty for lightweight.
424 const sep = "\x1f";
425 const fmt = `%(refname:short)${sep}%(*objectname:short)${sep}%(objectname:short)${sep}%(contents:subject)${sep}%(taggername)${sep}%(creatordate:iso8601)`;
426 const out =
427 await $`git -C ${p} for-each-ref --format=${fmt} refs/tags/`.text();
428 return out
429 .split("\n")
430 .filter(Boolean)
431 .map((line) => {
432 const parts = line.split(sep);
433 const derefHash = (parts[1] ?? "").trim();
434 const ownHash = (parts[2] ?? "").trim();
435 const isAnnotated = derefHash.length > 0;
436 return {
437 name: parts[0] ?? "",
438 shortHash: isAnnotated ? derefHash : ownHash,
439 subject: parts[3] ?? "",
440 taggerName: parts[4] ?? "",
441 date: parts[5] ?? "",
442 isAnnotated,
443 };
444 });
445 } catch {
446 return [];
447 }
448 },
449
450 async defaultBranch(name: string): Promise<string> {
451 const p = repoPath(name);
452 try {
453 const fmt = "%(refname:short)";
454 const branchesOut =
455 await $`git -C ${p} branch --format=${fmt}`.text();
456 const branches = branchesOut.split("\n").filter(Boolean);
457
458 // Read what HEAD points to (may be an unborn branch).
459 let headBranch: string | null = null;
460 try {
461 const out =
462 await $`git -C ${p} symbolic-ref --short HEAD`.text();
463 headBranch = out.trim();
464 } catch {
465 // detached HEAD — fall through
466 }
467
468 // Only trust HEAD if it names a branch that actually exists.
469 if (headBranch && branches.includes(headBranch)) {
470 return headBranch;
471 }
472
473 // HEAD points to an unborn branch or is detached — prefer "main",
474 // then "master", then whatever branch exists first.
475 return (
476 branches.find((b) => b === "main") ??
477 branches.find((b) => b === "master") ??
478 branches[0] ??
479 "main"
480 );
481 } catch {
482 return "main";
483 }
484 },
485
486 async getFileSize(
487 name: string,
488 ref: string,
489 filePath: string,
490 ): Promise<number | null> {
491 const p = repoPath(name);
492 try {
493 const out =
494 await $`git -C ${p} cat-file -s ${`${ref}:${filePath}`}`.text();
495 return parseInt(out.trim(), 10);
496 } catch {
497 return null;
498 }
499 },
500
501 async checkPatch(
502 name: string,
503 patchContent: string,
504 ): Promise<{ clean: boolean; output: string }> {
505 const p = repoPath(name);
506 const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
507 try {
508 await Bun.write(tmpFile, patchContent);
509 // Bare repos have no working tree; populate the index from HEAD so we can
510 // check against git objects (--cached) rather than the filesystem.
511 await $`git -C ${p} read-tree HEAD`.quiet();
512 const result =
513 await $`git -C ${p} apply --check --cached ${tmpFile}`
514 .quiet()
515 .nothrow();
516 return {
517 clean: result.exitCode === 0,
518 output: result.stderr.toString(),
519 };
520 } catch (e) {
521 return { clean: false, output: String(e) };
522 } finally {
523 await $`rm -f ${tmpFile}`.quiet().nothrow();
524 }
525 },
526
527 async applyPatch(
528 name: string,
529 patchContent: string,
530 authorName: string,
531 authorEmail: string,
532 committerName: string,
533 committerEmail: string,
534 ): Promise<void> {
535 return withRepoLock(name, async () => {
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 // Populate index, apply to index, then create a real commit in the bare repo.
541 await $`git -C ${p} read-tree HEAD`;
542 await $`git -C ${p} apply --cached ${tmpFile}`;
543 const tree = (await $`git -C ${p} write-tree`.text()).trim();
544 const parent = (
545 await $`git -C ${p} rev-parse HEAD`.text()
546 ).trim();
547 const msg = extractPatchSubject(patchContent);
548 const sigArgs = [
549 "-c",
550 "gpg.format=ssh",
551 "-c",
552 `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
553 ];
554 const commit = (
555 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${msg}`
556 .env({
557 ...gitEnv,
558 GIT_AUTHOR_NAME: authorName,
559 GIT_AUTHOR_EMAIL: authorEmail,
560 GIT_COMMITTER_NAME: committerName,
561 GIT_COMMITTER_EMAIL: committerEmail,
562 })
563 .text()
564 ).trim();
565 const ref = (
566 await $`git -C ${p} symbolic-ref HEAD`.text()
567 ).trim();
568 await $`git -C ${p} update-ref ${ref} ${commit}`;
569 } finally {
570 await $`rm -f ${tmpFile}`.quiet().nothrow();
571 }
572 });
573 },
574
575 async editFile(
576 name: string,
577 branch: string,
578 filePath: string,
579 content: string,
580 message: string,
581 committerName: string,
582 committerEmail: string,
583 newPath?: string,
584 ): Promise<string> {
585 return withRepoLock(name, async () => {
586 const targetPath =
587 newPath && newPath !== filePath ? newPath : filePath;
588 const isMove = targetPath !== filePath;
589 const p = repoPath(name);
590 const tmpFile = `/tmp/hf-edit-${Date.now()}-${Math.random().toString(36).slice(2)}`;
591 try {
592 await Bun.write(tmpFile, content);
593 if (isMove) {
594 await $`git --work-tree=/tmp -C ${p} read-tree refs/heads/${branch}`;
595 } else {
596 await $`git -C ${p} read-tree refs/heads/${branch}`;
597 }
598 const blobHash = (
599 await $`git -C ${p} hash-object -w ${tmpFile}`.text()
600 ).trim();
601 if (isMove) {
602 await $`git --work-tree=/tmp -C ${p} update-index --remove ${filePath}`;
603 }
604 await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${targetPath}`;
605 const tree = isMove
606 ? (
607 await $`git --work-tree=/tmp -C ${p} write-tree`.text()
608 ).trim()
609 : (await $`git -C ${p} write-tree`.text()).trim();
610 const parent = (
611 await $`git -C ${p} rev-parse refs/heads/${branch}`.text()
612 ).trim();
613 const sigArgs = [
614 "-c",
615 "gpg.format=ssh",
616 "-c",
617 `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
618 ];
619 const commit = (
620 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${message}`
621 .env({
622 ...gitEnv,
623 GIT_AUTHOR_NAME: committerName,
624 GIT_AUTHOR_EMAIL: committerEmail,
625 GIT_COMMITTER_NAME: committerName,
626 GIT_COMMITTER_EMAIL: committerEmail,
627 })
628 .text()
629 ).trim();
630 await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
631 return commit;
632 } finally {
633 await $`rm -f ${tmpFile}`.quiet().nothrow();
634 }
635 });
636 },
637
638 async createFile(
639 name: string,
640 branch: string,
641 filePath: string,
642 content: string,
643 message: string,
644 committerName: string,
645 committerEmail: string,
646 ): Promise<string> {
647 return withRepoLock(name, async () => {
648 const p = repoPath(name);
649 const tmpFile = `/tmp/hf-new-${Date.now()}-${Math.random().toString(36).slice(2)}`;
650 try {
651 await Bun.write(tmpFile, content);
652 const parentSha = await git.resolveRef(
653 name,
654 `refs/heads/${branch}`,
655 );
656 if (parentSha) {
657 await $`git -C ${p} read-tree refs/heads/${branch}`;
658 }
659 const blobHash = (
660 await $`git -C ${p} hash-object -w ${tmpFile}`.text()
661 ).trim();
662 await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${filePath}`;
663 const tree = (await $`git -C ${p} write-tree`.text()).trim();
664 const sigArgs = [
665 "-c",
666 "gpg.format=ssh",
667 "-c",
668 `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
669 ];
670 const commitEnv = {
671 ...gitEnv,
672 GIT_AUTHOR_NAME: committerName,
673 GIT_AUTHOR_EMAIL: committerEmail,
674 GIT_COMMITTER_NAME: committerName,
675 GIT_COMMITTER_EMAIL: committerEmail,
676 };
677 const commit = parentSha
678 ? (
679 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parentSha} -m ${message}`
680 .env(commitEnv)
681 .text()
682 ).trim()
683 : (
684 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -m ${message}`
685 .env(commitEnv)
686 .text()
687 ).trim();
688 await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
689 return commit;
690 } finally {
691 await $`rm -f ${tmpFile}`.quiet().nothrow();
692 }
693 });
694 },
695
696 async deleteFile(
697 name: string,
698 branch: string,
699 filePath: string,
700 message: string,
701 committerName: string,
702 committerEmail: string,
703 ): Promise<string> {
704 return withRepoLock(name, async () => {
705 const p = repoPath(name);
706 // --work-tree=/tmp is needed because bare repos have no work tree and
707 // `update-index --remove` requires one (even though it only touches the index).
708 await $`git --work-tree=/tmp -C ${p} read-tree refs/heads/${branch}`;
709 await $`git --work-tree=/tmp -C ${p} update-index --remove ${filePath}`;
710 const tree = (
711 await $`git --work-tree=/tmp -C ${p} write-tree`.text()
712 ).trim();
713 const parent = (
714 await $`git -C ${p} rev-parse refs/heads/${branch}`.text()
715 ).trim();
716 const sigArgs = [
717 "-c",
718 "gpg.format=ssh",
719 "-c",
720 `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
721 ];
722 const commit = (
723 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${message}`
724 .env({
725 ...gitEnv,
726 GIT_AUTHOR_NAME: committerName,
727 GIT_AUTHOR_EMAIL: committerEmail,
728 GIT_COMMITTER_NAME: committerName,
729 GIT_COMMITTER_EMAIL: committerEmail,
730 })
731 .text()
732 ).trim();
733 await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
734 return commit;
735 });
736 },
737
738 async moveFile(
739 name: string,
740 branch: string,
741 oldPath: string,
742 newPath: string,
743 message: string,
744 committerName: string,
745 committerEmail: string,
746 ): Promise<string> {
747 return withRepoLock(name, async () => {
748 const p = repoPath(name);
749 const tmpFile = `/tmp/hf-move-${Date.now()}-${Math.random().toString(36).slice(2)}`;
750 try {
751 const contentBuf =
752 await $`git -C ${p} show ${`${branch}:${oldPath}`}`.arrayBuffer();
753 await Bun.write(tmpFile, contentBuf);
754 // --work-tree=/tmp is needed because bare repos have no work tree and
755 // `update-index --remove` requires one (even though it only touches the index).
756 await $`git --work-tree=/tmp -C ${p} read-tree refs/heads/${branch}`;
757 const blobHash = (
758 await $`git -C ${p} hash-object -w ${tmpFile}`.text()
759 ).trim();
760 await $`git --work-tree=/tmp -C ${p} update-index --remove ${oldPath}`;
761 await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${newPath}`;
762 const tree = (
763 await $`git --work-tree=/tmp -C ${p} write-tree`.text()
764 ).trim();
765 const parent = (
766 await $`git -C ${p} rev-parse refs/heads/${branch}`.text()
767 ).trim();
768 const sigArgs = [
769 "-c",
770 "gpg.format=ssh",
771 "-c",
772 `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
773 ];
774 const commit = (
775 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${message}`
776 .env({
777 ...gitEnv,
778 GIT_AUTHOR_NAME: committerName,
779 GIT_AUTHOR_EMAIL: committerEmail,
780 GIT_COMMITTER_NAME: committerName,
781 GIT_COMMITTER_EMAIL: committerEmail,
782 })
783 .text()
784 ).trim();
785 await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
786 return commit;
787 } finally {
788 await $`rm -f ${tmpFile}`.quiet().nothrow();
789 }
790 });
791 },
792
793 async createTag(
794 repoName: string,
795 tagName: string,
796 ref: string,
797 message?: string,
798 taggerName?: string,
799 taggerEmail?: string,
800 ): Promise<"ok" | "already_exists" | "bad_ref" | "error"> {
801 return withRepoLock(repoName, async () => {
802 const p = repoPath(repoName);
803 const sigArgs = [
804 "-c",
805 "gpg.format=ssh",
806 "-c",
807 `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
808 ];
809 const result =
810 message !== undefined
811 ? await $`git ${sigArgs} -C ${p} tag -s ${tagName} ${ref} -m ${message}`
812 .env({
813 ...gitEnv,
814 GIT_COMMITTER_NAME: taggerName!,
815 GIT_COMMITTER_EMAIL: taggerEmail!,
816 })
817 .nothrow()
818 : await $`git -C ${p} tag ${tagName} ${ref}`.nothrow();
819 if (result.exitCode === 0) return "ok";
820 const stderr = result.stderr.toString();
821 if (stderr.includes("already exists")) return "already_exists";
822 if (
823 stderr.includes("not a valid object name") ||
824 stderr.includes("unknown revision") ||
825 stderr.includes("ambiguous argument")
826 )
827 return "bad_ref";
828 return "error";
829 });
830 },
831
832 async createBranch(
833 name: string,
834 branchName: string,
835 sourceRef: string,
836 ): Promise<"ok" | "already_exists" | "bad_ref" | "error"> {
837 return withRepoLock(name, async () => {
838 const p = repoPath(name);
839 try {
840 const sha = await git.resolveRef(name, sourceRef);
841 if (!sha) return "bad_ref";
842 const exists = await git.resolveRef(
843 name,
844 `refs/heads/${branchName}`,
845 );
846 if (exists) return "already_exists";
847 await $`git -C ${p} update-ref refs/heads/${branchName} ${sha}`;
848 return "ok";
849 } catch {
850 return "error";
851 }
852 });
853 },
854
855 async deleteBranch(
856 name: string,
857 branchName: string,
858 ): Promise<"ok" | "not_found" | "error"> {
859 return withRepoLock(name, async () => {
860 const p = repoPath(name);
861 try {
862 const exists = await git.resolveRef(
863 name,
864 `refs/heads/${branchName}`,
865 );
866 if (!exists) return "not_found";
867 await $`git -C ${p} update-ref -d refs/heads/${branchName}`;
868 return "ok";
869 } catch {
870 return "error";
871 }
872 });
873 },
874
875 async renameBranch(
876 name: string,
877 oldName: string,
878 newName: string,
879 ): Promise<"ok" | "not_found" | "already_exists" | "error"> {
880 return withRepoLock(name, async () => {
881 const p = repoPath(name);
882 try {
883 const sha = await git.resolveRef(name, `refs/heads/${oldName}`);
884 if (!sha) return "not_found";
885 const exists = await git.resolveRef(
886 name,
887 `refs/heads/${newName}`,
888 );
889 if (exists) return "already_exists";
890 await $`git -C ${p} update-ref refs/heads/${newName} ${sha}`;
891 await $`git -C ${p} update-ref -d refs/heads/${oldName}`;
892 return "ok";
893 } catch {
894 return "error";
895 }
896 });
897 },
898
899 async deleteTag(
900 name: string,
901 tagName: string,
902 ): Promise<"ok" | "not_found" | "error"> {
903 return withRepoLock(name, async () => {
904 const p = repoPath(name);
905 try {
906 const exists = await git.resolveRef(
907 name,
908 `refs/tags/${tagName}`,
909 );
910 if (!exists) return "not_found";
911 await $`git -C ${p} tag -d ${tagName}`;
912 return "ok";
913 } catch {
914 return "error";
915 }
916 });
917 },
918
919 async setHead(name: string, branch: string): Promise<void> {
920 const p = repoPath(name);
921 await $`git -C ${p} symbolic-ref HEAD refs/heads/${branch}`;
922 },
923
924 async resolveRef(name: string, ref: string): Promise<string | null> {
925 const p = repoPath(name);
926 try {
927 const out = await $`git -C ${p} rev-parse --verify ${ref}`.text();
928 return out.trim() || null;
929 } catch {
930 return null;
931 }
932 },
933
934 async hasCommits(name: string): Promise<boolean> {
935 const p = repoPath(name);
936 try {
937 const out = await $`git -C ${p} log --oneline -1`.quiet().text();
938 return out.trim().length > 0;
939 } catch {
940 return false;
941 }
942 },
943
944 async commitMeta(name: string, sha: string): Promise<CommitMeta | null> {
945 const p = repoPath(name);
946 const sigArgs = [
947 "-c",
948 "gpg.format=ssh",
949 "-c",
950 `gpg.ssh.allowedSignersFile=${paths.ALLOWED_SIGNERS_PATH}`,
951 ];
952 try {
953 const [metaOut, msgOut] = await Promise.all([
954 $`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(),
955 $`git -C ${p} log --format=%B -1 ${sha}`.text(),
956 ]);
957 const parts = metaOut.trim().split("\x1f");
958 const fullMsg = msgOut.trimEnd();
959 const firstNl = fullMsg.indexOf("\n");
960 const subject = firstNl >= 0 ? fullMsg.slice(0, firstNl) : fullMsg;
961 const body =
962 firstNl >= 0
963 ? fullMsg
964 .slice(firstNl + 1)
965 .trimStart()
966 .trimEnd()
967 : "";
968 return {
969 hash: parts[0] ?? sha,
970 subject,
971 body,
972 author: parts[1] ?? "",
973 email: parts[2] ?? "",
974 date: parts[3] ?? "",
975 committer: parts[4] ?? "",
976 committerEmail: parts[5] ?? "",
977 committerDate: parts[6] ?? "",
978 parents: (parts[7] ?? "").trim().split(/\s+/).filter(Boolean),
979 sigStatus: parseSigStatus(parts[8] ?? ""),
980 };
981 } catch {
982 return null;
983 }
984 },
985};
986