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