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