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 ensureBare(name: string): Promise<void> {
358 const cfg = path.join(repoPath(name), "config");
359 return withRepoLock(name, async () => {
360 const current = await $`git config --file ${cfg} --get core.bare`
361 .quiet()
362 .nothrow();
363 if (current.exitCode === 0 && current.text().trim() === "true") {
364 return;
365 }
366
367 const res = await $`git config --file ${cfg} core.bare true`
368 .quiet()
369 .nothrow();
370 if (res.exitCode !== 0) {
371 logGitError("ensureBare", name, res.stderr.toString().trim());
372 }
373 });
374 },
375
376 async log(
377 name: string,
378 ref = "HEAD",
379 limit = 30,
380 skip = 0,
381 ): Promise<CommitEntry[]> {
382 const p = repoPath(name);
383 const sigArgs = [
384 "-c",
385 "gpg.format=ssh",
386 "-c",
387 `gpg.ssh.allowedSignersFile=${paths.ALLOWED_SIGNERS_PATH}`,
388 ];
389 try {
390 // `--end-of-options` before the ref stops a user-supplied ref that
391 // begins with `-` from being parsed as a git option (e.g. `--output=`,
392 // which would write to an arbitrary file). All real options must
393 // therefore precede it.
394 const out =
395 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();
396 return parseLog(out);
397 } catch (e) {
398 logGitError(`log(${ref})`, name, e);
399 return [];
400 }
401 },
402
403 async lsTree(
404 name: string,
405 ref: string,
406 subpath = "",
407 ): Promise<TreeEntry[]> {
408 const p = repoPath(name);
409 try {
410 const args = subpath
411 ? [
412 "git",
413 "-C",
414 p,
415 "ls-tree",
416 "--long",
417 "--end-of-options",
418 ref,
419 "--",
420 `${subpath}/`,
421 ]
422 : [
423 "git",
424 "-C",
425 p,
426 "ls-tree",
427 "--long",
428 "--end-of-options",
429 ref,
430 ];
431 const out = await $`${args}`.text();
432 const entries = parseLsTree(out);
433 if (subpath) {
434 // git ls-tree returns full paths like "subpath/name" — strip the prefix
435 const prefix = `${subpath}/`;
436 return entries.map((e) => ({
437 ...e,
438 name: e.name.startsWith(prefix)
439 ? e.name.slice(prefix.length)
440 : e.name,
441 }));
442 }
443 return entries;
444 } catch (e) {
445 logGitError(`lsTree(${ref})`, name, e);
446 return [];
447 }
448 },
449
450 async show(
451 name: string,
452 ref: string,
453 filePath: string,
454 ): Promise<Buffer | null> {
455 const p = repoPath(name);
456 try {
457 const buf =
458 await $`git -C ${p} show --end-of-options ${`${ref}:${filePath}`}`.arrayBuffer();
459 return Buffer.from(buf);
460 } catch (e) {
461 logGitError(`show(${ref}:${filePath})`, name, e);
462 return null;
463 }
464 },
465
466 async diff(name: string, sha: string): Promise<string> {
467 const p = repoPath(name);
468 try {
469 return await $`git -C ${p} diff-tree --no-commit-id -r -p -M --root --end-of-options ${sha}`.text();
470 } catch (e) {
471 logGitError(`diff(${sha})`, name, e);
472 return "";
473 }
474 },
475
476 async blobSize(name: string, hash: string): Promise<number> {
477 if (/^0+$/.test(hash)) return 0;
478 const p = repoPath(name);
479 try {
480 const out =
481 await $`git -C ${p} cat-file -s --end-of-options ${hash}`.text();
482 return parseInt(out.trim(), 10) || 0;
483 } catch {
484 return 0;
485 }
486 },
487
488 async branches(name: string): Promise<string[]> {
489 const now = Date.now();
490 const cached = branchCache.get(name);
491 if (cached && cached.expiresAt > now) return cached.value;
492 const p = repoPath(name);
493 try {
494 // %(refname:short) must be a variable — Bun Shell parses bare `()` as subshell syntax
495 const fmt = "%(refname:short)";
496 const out = await $`git -C ${p} branch --format=${fmt}`.text();
497 const value = out.split("\n").filter(Boolean);
498 branchCache.set(name, { value, expiresAt: now + REF_CACHE_TTL_MS });
499 if (branchCache.size > MAX_BRANCH_CACHE) {
500 branchCache.delete(branchCache.keys().next().value!);
501 }
502 return value;
503 } catch (e) {
504 logGitError("branches", name, e);
505 return [];
506 }
507 },
508
509 async tags(name: string): Promise<string[]> {
510 const now = Date.now();
511 const cached = tagCache.get(name);
512 if (cached && cached.expiresAt > now) return cached.value;
513 const p = repoPath(name);
514 try {
515 const fmt = "%(refname:short)";
516 const out =
517 await $`git -C ${p} for-each-ref --format=${fmt} refs/tags/`.text();
518 const value = out.split("\n").filter(Boolean);
519 tagCache.set(name, { value, expiresAt: now + REF_CACHE_TTL_MS });
520 if (tagCache.size > MAX_TAG_CACHE) {
521 tagCache.delete(tagCache.keys().next().value!);
522 }
523 return value;
524 } catch (e) {
525 logGitError("tags", name, e);
526 return [];
527 }
528 },
529
530 async branchesWithInfo(
531 name: string,
532 maxCount = MAX_REF_LIST,
533 ): Promise<BranchInfo[]> {
534 const p = repoPath(name);
535 try {
536 // Use actual unit separator byte (\x1f) — git for-each-ref does not
537 // support the %x1f hex escape (that is a git-log pretty-format feature).
538 const sep = "\x1f";
539 const fmt = `%(refname:short)${sep}%(objectname:short)${sep}%(contents:subject)${sep}%(authorname)${sep}%(authordate:iso8601)`;
540 const out =
541 await $`git -C ${p} for-each-ref --sort=-creatordate --count=${maxCount} --format=${fmt} refs/heads/`.text();
542 return out
543 .split("\n")
544 .filter(Boolean)
545 .map((line) => {
546 const parts = line.split(sep);
547 return {
548 name: parts[0] ?? "",
549 shortHash: parts[1] ?? "",
550 subject: parts[2] ?? "",
551 authorName: parts[3] ?? "",
552 date: parts[4] ?? "",
553 };
554 });
555 } catch (e) {
556 logGitError("branchesWithInfo", name, e);
557 return [];
558 }
559 },
560
561 async tagsWithInfo(name: string, maxCount = 1000): Promise<TagInfo[]> {
562 const p = repoPath(name);
563 try {
564 // Use actual unit separator byte (\x1f) — git for-each-ref does not
565 // support the %x1f hex escape (that is a git-log pretty-format feature).
566 // %(*objectname:short) is the dereferenced commit for annotated tags; empty for lightweight.
567 const sep = "\x1f";
568 const fmt = `%(refname:short)${sep}%(*objectname:short)${sep}%(objectname:short)${sep}%(contents:subject)${sep}%(taggername)${sep}%(creatordate:iso8601)`;
569 const out =
570 await $`git -C ${p} for-each-ref --sort=-creatordate --count=${maxCount} --format=${fmt} refs/tags/`.text();
571 return out
572 .split("\n")
573 .filter(Boolean)
574 .map((line) => {
575 const parts = line.split(sep);
576 const derefHash = (parts[1] ?? "").trim();
577 const ownHash = (parts[2] ?? "").trim();
578 const isAnnotated = derefHash.length > 0;
579 return {
580 name: parts[0] ?? "",
581 shortHash: isAnnotated ? derefHash : ownHash,
582 subject: parts[3] ?? "",
583 taggerName: parts[4] ?? "",
584 date: parts[5] ?? "",
585 isAnnotated,
586 };
587 });
588 } catch (e) {
589 logGitError("tagsWithInfo", name, e);
590 return [];
591 }
592 },
593
594 async defaultBranch(name: string): Promise<string> {
595 const p = repoPath(name);
596 try {
597 const branches = await git.branches(name);
598
599 // Read what HEAD points to (may be an unborn branch).
600 let headBranch: string | null = null;
601 try {
602 const out =
603 await $`git -C ${p} symbolic-ref --short HEAD`.text();
604 headBranch = out.trim();
605 } catch {
606 // detached HEAD — fall through
607 }
608
609 // Only trust HEAD if it names a branch that actually exists.
610 if (headBranch && branches.includes(headBranch)) {
611 return headBranch;
612 }
613
614 // HEAD points to an unborn branch or is detached — prefer "main",
615 // then "master", then whatever branch exists first.
616 return (
617 branches.find((b) => b === "main") ??
618 branches.find((b) => b === "master") ??
619 branches[0] ??
620 "main"
621 );
622 } catch {
623 return "main";
624 }
625 },
626
627 async getFileSize(
628 name: string,
629 ref: string,
630 filePath: string,
631 ): Promise<number | null> {
632 const p = repoPath(name);
633 try {
634 const out =
635 await $`git -C ${p} cat-file -s --end-of-options ${`${ref}:${filePath}`}`.text();
636 return parseInt(out.trim(), 10);
637 } catch {
638 return null;
639 }
640 },
641
642 async checkPatch(
643 name: string,
644 patchContent: string,
645 ): Promise<{ clean: boolean; output: string }> {
646 const p = repoPath(name);
647 try {
648 return await withTempDir("patch", async (dir) => {
649 const tmpFile = path.join(dir, "change.patch");
650 // Use a throwaway index (GIT_INDEX_FILE) so this read-only
651 // preview never mutates — nor races a concurrent
652 // applyPatch/editFile on — the repo's shared index. Without it,
653 // this GET-triggered check could reset the index mid-merge and
654 // silently drop the patch being written.
655 const idxEnv = {
656 ...gitEnv,
657 GIT_INDEX_FILE: path.join(dir, "index"),
658 };
659 await Bun.write(tmpFile, patchContent);
660 // Bare repos have no working tree; populate the index from HEAD
661 // so we can check against git objects (--cached) rather than the
662 // filesystem.
663 await $`git -C ${p} read-tree HEAD`.env(idxEnv).quiet();
664 const result =
665 await $`git -C ${p} apply --check --cached ${tmpFile}`
666 .env(idxEnv)
667 .quiet()
668 .nothrow();
669 return {
670 clean: result.exitCode === 0,
671 output: result.stderr.toString(),
672 };
673 });
674 } catch (e) {
675 return { clean: false, output: String(e) };
676 }
677 },
678
679 async applyPatch(
680 name: string,
681 patchContent: string,
682 authorName: string,
683 authorEmail: string,
684 committerName: string,
685 committerEmail: string,
686 ): Promise<void> {
687 return withRepoLock(name, async () => {
688 const p = repoPath(name);
689 await withTempDir("patch", async (dir) => {
690 const tmpFile = path.join(dir, "change.patch");
691 await Bun.write(tmpFile, patchContent);
692 // Populate index, apply to index, then create a real commit in the bare repo.
693 await $`git -C ${p} read-tree HEAD`;
694 await $`git -C ${p} apply --cached ${tmpFile}`;
695 const tree = (await $`git -C ${p} write-tree`.text()).trim();
696 const parent = (
697 await $`git -C ${p} rev-parse HEAD`.text()
698 ).trim();
699 const msg = extractPatchSubject(patchContent);
700 const sigArgs = [
701 "-c",
702 "gpg.format=ssh",
703 "-c",
704 `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
705 ];
706 const commit = (
707 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${msg}`
708 .env({
709 ...gitEnv,
710 GIT_AUTHOR_NAME: authorName,
711 GIT_AUTHOR_EMAIL: authorEmail,
712 GIT_COMMITTER_NAME: committerName,
713 GIT_COMMITTER_EMAIL: committerEmail,
714 })
715 .text()
716 ).trim();
717 const ref = (
718 await $`git -C ${p} symbolic-ref HEAD`.text()
719 ).trim();
720 await $`git -C ${p} update-ref ${ref} ${commit}`;
721 });
722 });
723 },
724
725 async editFile(
726 name: string,
727 branch: string,
728 filePath: string,
729 content: string,
730 message: string,
731 committerName: string,
732 committerEmail: string,
733 newPath?: string,
734 ): Promise<string> {
735 return withRepoLock(name, async () => {
736 const targetPath =
737 newPath && newPath !== filePath ? newPath : filePath;
738 const isMove = targetPath !== filePath;
739 const p = repoPath(name);
740 // Preserve the file's existing mode (executable bit / symlink)
741 // rather than forcing every edit back to a plain 100644 file.
742 const mode =
743 (await treeFileMode(p, `refs/heads/${branch}`, filePath)) ??
744 "100644";
745 return await withTempDir("edit", async (dir) => {
746 const tmpFile = path.join(dir, "blob");
747 await Bun.write(tmpFile, content);
748 if (isMove) {
749 await $`git --work-tree=/tmp -C ${p} read-tree refs/heads/${branch}`;
750 } else {
751 await $`git -C ${p} read-tree refs/heads/${branch}`;
752 }
753 const blobHash = (
754 await $`git -C ${p} hash-object -w ${tmpFile}`.text()
755 ).trim();
756 if (isMove) {
757 await $`git --work-tree=/tmp -C ${p} update-index --remove ${filePath}`;
758 }
759 const cacheInfo = `${mode},${blobHash},${targetPath}`;
760 await $`git -C ${p} update-index --add --cacheinfo ${cacheInfo}`;
761 const tree = isMove
762 ? (
763 await $`git --work-tree=/tmp -C ${p} write-tree`.text()
764 ).trim()
765 : (await $`git -C ${p} write-tree`.text()).trim();
766 const parent = (
767 await $`git -C ${p} rev-parse refs/heads/${branch}`.text()
768 ).trim();
769 const sigArgs = [
770 "-c",
771 "gpg.format=ssh",
772 "-c",
773 `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
774 ];
775 const commit = (
776 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${message}`
777 .env({
778 ...gitEnv,
779 GIT_AUTHOR_NAME: committerName,
780 GIT_AUTHOR_EMAIL: committerEmail,
781 GIT_COMMITTER_NAME: committerName,
782 GIT_COMMITTER_EMAIL: committerEmail,
783 })
784 .text()
785 ).trim();
786 await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
787 return commit;
788 });
789 });
790 },
791
792 async createFile(
793 name: string,
794 branch: string,
795 filePath: string,
796 content: string,
797 message: string,
798 committerName: string,
799 committerEmail: string,
800 ): Promise<string> {
801 return withRepoLock(name, async () => {
802 const p = repoPath(name);
803 return await withTempDir("new", async (dir) => {
804 const tmpFile = path.join(dir, "blob");
805 await Bun.write(tmpFile, content);
806 const parentSha = await git.resolveRef(
807 name,
808 `refs/heads/${branch}`,
809 );
810 if (parentSha) {
811 await $`git -C ${p} read-tree refs/heads/${branch}`;
812 }
813 const blobHash = (
814 await $`git -C ${p} hash-object -w ${tmpFile}`.text()
815 ).trim();
816 await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${filePath}`;
817 const tree = (await $`git -C ${p} write-tree`.text()).trim();
818 const sigArgs = [
819 "-c",
820 "gpg.format=ssh",
821 "-c",
822 `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
823 ];
824 const commitEnv = {
825 ...gitEnv,
826 GIT_AUTHOR_NAME: committerName,
827 GIT_AUTHOR_EMAIL: committerEmail,
828 GIT_COMMITTER_NAME: committerName,
829 GIT_COMMITTER_EMAIL: committerEmail,
830 };
831 const commit = parentSha
832 ? (
833 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parentSha} -m ${message}`
834 .env(commitEnv)
835 .text()
836 ).trim()
837 : (
838 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -m ${message}`
839 .env(commitEnv)
840 .text()
841 ).trim();
842 await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
843 return commit;
844 });
845 });
846 },
847
848 async deleteFile(
849 name: string,
850 branch: string,
851 filePath: string,
852 message: string,
853 committerName: string,
854 committerEmail: string,
855 ): Promise<string> {
856 return withRepoLock(name, async () => {
857 const p = repoPath(name);
858 // --work-tree=/tmp is needed because bare repos have no work tree and
859 // `update-index --remove` requires one (even though it only touches the index).
860 await $`git --work-tree=/tmp -C ${p} read-tree refs/heads/${branch}`;
861 await $`git --work-tree=/tmp -C ${p} update-index --remove ${filePath}`;
862 const tree = (
863 await $`git --work-tree=/tmp -C ${p} write-tree`.text()
864 ).trim();
865 const parent = (
866 await $`git -C ${p} rev-parse refs/heads/${branch}`.text()
867 ).trim();
868 const sigArgs = [
869 "-c",
870 "gpg.format=ssh",
871 "-c",
872 `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
873 ];
874 const commit = (
875 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${message}`
876 .env({
877 ...gitEnv,
878 GIT_AUTHOR_NAME: committerName,
879 GIT_AUTHOR_EMAIL: committerEmail,
880 GIT_COMMITTER_NAME: committerName,
881 GIT_COMMITTER_EMAIL: committerEmail,
882 })
883 .text()
884 ).trim();
885 await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
886 return commit;
887 });
888 },
889
890 async moveFile(
891 name: string,
892 branch: string,
893 oldPath: string,
894 newPath: string,
895 message: string,
896 committerName: string,
897 committerEmail: string,
898 ): Promise<string> {
899 return withRepoLock(name, async () => {
900 const p = repoPath(name);
901 // Preserve the moved file's mode (executable bit / symlink).
902 const mode =
903 (await treeFileMode(p, `refs/heads/${branch}`, oldPath)) ??
904 "100644";
905 return await withTempDir("move", async (dir) => {
906 const tmpFile = path.join(dir, "blob");
907 const contentBuf =
908 await $`git -C ${p} show --end-of-options ${`${branch}:${oldPath}`}`.arrayBuffer();
909 await Bun.write(tmpFile, contentBuf);
910 // --work-tree=/tmp is needed because bare repos have no work tree and
911 // `update-index --remove` requires one (even though it only touches the index).
912 await $`git --work-tree=/tmp -C ${p} read-tree refs/heads/${branch}`;
913 const blobHash = (
914 await $`git -C ${p} hash-object -w ${tmpFile}`.text()
915 ).trim();
916 await $`git --work-tree=/tmp -C ${p} update-index --remove ${oldPath}`;
917 const cacheInfo = `${mode},${blobHash},${newPath}`;
918 await $`git -C ${p} update-index --add --cacheinfo ${cacheInfo}`;
919 const tree = (
920 await $`git --work-tree=/tmp -C ${p} write-tree`.text()
921 ).trim();
922 const parent = (
923 await $`git -C ${p} rev-parse refs/heads/${branch}`.text()
924 ).trim();
925 const sigArgs = [
926 "-c",
927 "gpg.format=ssh",
928 "-c",
929 `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
930 ];
931 const commit = (
932 await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${message}`
933 .env({
934 ...gitEnv,
935 GIT_AUTHOR_NAME: committerName,
936 GIT_AUTHOR_EMAIL: committerEmail,
937 GIT_COMMITTER_NAME: committerName,
938 GIT_COMMITTER_EMAIL: committerEmail,
939 })
940 .text()
941 ).trim();
942 await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
943 return commit;
944 });
945 });
946 },
947
948 async createTag(
949 repoName: string,
950 tagName: string,
951 ref: string,
952 message?: string,
953 taggerName?: string,
954 taggerEmail?: string,
955 ): Promise<"ok" | "already_exists" | "bad_ref" | "error"> {
956 return withRepoLock(repoName, async () => {
957 const p = repoPath(repoName);
958 const sigArgs = [
959 "-c",
960 "gpg.format=ssh",
961 "-c",
962 `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
963 ];
964 const result =
965 message !== undefined
966 ? await $`git ${sigArgs} -C ${p} tag -s -m ${message} --end-of-options ${tagName} ${ref}`
967 .env({
968 ...gitEnv,
969 GIT_COMMITTER_NAME: taggerName!,
970 GIT_COMMITTER_EMAIL: taggerEmail!,
971 })
972 .nothrow()
973 : await $`git -C ${p} tag --end-of-options ${tagName} ${ref}`.nothrow();
974 if (result.exitCode === 0) {
975 invalidateRefCache(repoName);
976 return "ok";
977 }
978 const stderr = result.stderr.toString();
979 if (stderr.includes("already exists")) return "already_exists";
980 if (
981 stderr.includes("not a valid object name") ||
982 stderr.includes("unknown revision") ||
983 stderr.includes("ambiguous argument")
984 )
985 return "bad_ref";
986 return "error";
987 });
988 },
989
990 async createBranch(
991 name: string,
992 branchName: string,
993 sourceRef: string,
994 ): Promise<"ok" | "already_exists" | "bad_ref" | "error"> {
995 return withRepoLock(name, async () => {
996 const p = repoPath(name);
997 try {
998 const sha = await git.resolveRef(name, sourceRef);
999 if (!sha) return "bad_ref";
1000 const exists = await git.resolveRef(
1001 name,
1002 `refs/heads/${branchName}`,
1003 );
1004 if (exists) return "already_exists";
1005 await $`git -C ${p} update-ref refs/heads/${branchName} ${sha}`;
1006 invalidateRefCache(name);
1007 return "ok";
1008 } catch {
1009 return "error";
1010 }
1011 });
1012 },
1013
1014 async deleteBranch(
1015 name: string,
1016 branchName: string,
1017 ): Promise<"ok" | "not_found" | "error"> {
1018 return withRepoLock(name, async () => {
1019 const p = repoPath(name);
1020 try {
1021 const exists = await git.resolveRef(
1022 name,
1023 `refs/heads/${branchName}`,
1024 );
1025 if (!exists) return "not_found";
1026 await $`git -C ${p} update-ref -d refs/heads/${branchName}`;
1027 invalidateRefCache(name);
1028 return "ok";
1029 } catch {
1030 return "error";
1031 }
1032 });
1033 },
1034
1035 async renameBranch(
1036 name: string,
1037 oldName: string,
1038 newName: string,
1039 ): Promise<"ok" | "not_found" | "already_exists" | "error"> {
1040 return withRepoLock(name, async () => {
1041 const p = repoPath(name);
1042 try {
1043 const sha = await git.resolveRef(name, `refs/heads/${oldName}`);
1044 if (!sha) return "not_found";
1045 const exists = await git.resolveRef(
1046 name,
1047 `refs/heads/${newName}`,
1048 );
1049 if (exists) return "already_exists";
1050 await $`git -C ${p} update-ref refs/heads/${newName} ${sha}`;
1051 await $`git -C ${p} update-ref -d refs/heads/${oldName}`;
1052 invalidateRefCache(name);
1053 return "ok";
1054 } catch {
1055 return "error";
1056 }
1057 });
1058 },
1059
1060 async deleteTag(
1061 name: string,
1062 tagName: string,
1063 ): Promise<"ok" | "not_found" | "error"> {
1064 return withRepoLock(name, async () => {
1065 const p = repoPath(name);
1066 try {
1067 const exists = await git.resolveRef(
1068 name,
1069 `refs/tags/${tagName}`,
1070 );
1071 if (!exists) return "not_found";
1072 await $`git -C ${p} tag -d ${tagName}`;
1073 invalidateRefCache(name);
1074 return "ok";
1075 } catch {
1076 return "error";
1077 }
1078 });
1079 },
1080
1081 async setHead(name: string, branch: string): Promise<void> {
1082 const p = repoPath(name);
1083 await $`git -C ${p} symbolic-ref HEAD refs/heads/${branch}`;
1084 },
1085
1086 async resolveRef(name: string, ref: string): Promise<string | null> {
1087 const p = repoPath(name);
1088 try {
1089 const out =
1090 await $`git -C ${p} rev-parse --verify --end-of-options ${ref}`.text();
1091 return out.trim() || null;
1092 } catch {
1093 return null;
1094 }
1095 },
1096
1097 async hasCommits(name: string): Promise<boolean> {
1098 const p = repoPath(name);
1099 try {
1100 const out = await $`git -C ${p} log --oneline -1`.quiet().text();
1101 return out.trim().length > 0;
1102 } catch {
1103 return false;
1104 }
1105 },
1106
1107 async commitMeta(name: string, sha: string): Promise<CommitMeta | null> {
1108 const p = repoPath(name);
1109 const sigArgs = [
1110 "-c",
1111 "gpg.format=ssh",
1112 "-c",
1113 `gpg.ssh.allowedSignersFile=${paths.ALLOWED_SIGNERS_PATH}`,
1114 ];
1115 try {
1116 const [metaOut, msgOut] = await Promise.all([
1117 $`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(),
1118 $`git -C ${p} log --format=%B -1 --end-of-options ${sha}`.text(),
1119 ]);
1120 const parts = metaOut.trim().split("\x1f");
1121 const fullMsg = msgOut.trimEnd();
1122 const firstNl = fullMsg.indexOf("\n");
1123 const subject = firstNl >= 0 ? fullMsg.slice(0, firstNl) : fullMsg;
1124 const body =
1125 firstNl >= 0
1126 ? fullMsg
1127 .slice(firstNl + 1)
1128 .trimStart()
1129 .trimEnd()
1130 : "";
1131 return {
1132 hash: parts[0] ?? sha,
1133 subject,
1134 body,
1135 author: parts[1] ?? "",
1136 email: parts[2] ?? "",
1137 date: parts[3] ?? "",
1138 committer: parts[4] ?? "",
1139 committerEmail: parts[5] ?? "",
1140 committerDate: parts[6] ?? "",
1141 parents: (parts[7] ?? "").trim().split(/\s+/).filter(Boolean),
1142 sigStatus: parseSigStatus(parts[8] ?? ""),
1143 };
1144 } catch (e) {
1145 logGitError(`commitMeta(${sha})`, name, e);
1146 return null;
1147 }
1148 },
1149};
1150