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