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