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