git.ts
Raw
1import path from "node:path";
2import { $ as _$ } from "bun";
3
4import { REPOS_DIR } from "../constants.ts";
5
6const gitEnv = {
7 ...process.env,
8 LC_ALL: "C",
9 LANG: "C",
10 GIT_CONFIG_GLOBAL: "/dev/null",
11 GIT_CONFIG_SYSTEM: "/dev/null",
12 GIT_CONFIG_COUNT: "0",
13 GIT_ASKPASS: "echo",
14 GIT_TERMINAL_PROMPT: "0",
15};
16
17const $ = _$.env(gitEnv);
18
19// Per-repo mutex: prevents concurrent git write operations on the same repo
20// (e.g. two patches being merged simultaneously, which would corrupt the index).
21const repoWriteLocks = new Map<string, Promise<void>>();
22
23async function withRepoLock<T>(name: string, fn: () => Promise<T>): Promise<T> {
24 const prev = repoWriteLocks.get(name) ?? Promise.resolve();
25 let unlock!: () => void;
26 repoWriteLocks.set(
27 name,
28 prev.then(
29 () =>
30 new Promise<void>((res) => {
31 unlock = res;
32 }),
33 ),
34 );
35 await prev;
36 try {
37 return await fn();
38 } finally {
39 unlock();
40 }
41}
42
43export function repoPath(name: string): string {
44 return path.join(REPOS_DIR, `${name}.git`);
45}
46
47export async function validateCommit(
48 repoName: string,
49 hash: string,
50): Promise<boolean> {
51 const p = repoPath(repoName);
52 try {
53 const out = await $`git -C ${p} cat-file -t ${hash}`.text();
54 return out.trim() === "commit";
55 } catch {
56 return false;
57 }
58}
59
60export async function archiveRepo(
61 repoName: string,
62 ref: string,
63 slug: string,
64 outDir: string,
65 signal?: AbortSignal,
66): Promise<void> {
67 const p = repoPath(repoName);
68 const base = `${slug}-${ref}`;
69
70 const zip = Bun.spawn(
71 [
72 "git",
73 "-C",
74 p,
75 "archive",
76 "--format=zip",
77 `--output=${path.join(outDir, `${base}.zip`)}`,
78 ref,
79 ],
80 { signal, env: gitEnv },
81 );
82 if ((await zip.exited) !== 0) throw new Error("git archive (zip) failed");
83
84 const tgz = Bun.spawn(
85 [
86 "git",
87 "-C",
88 p,
89 "archive",
90 "--format=tar.gz",
91 `--output=${path.join(outDir, `${base}.tar.gz`)}`,
92 ref,
93 ],
94 { signal, env: gitEnv },
95 );
96 if ((await tgz.exited) !== 0)
97 throw new Error("git archive (tar.gz) failed");
98
99 try {
100 const tar = Bun.spawn(
101 ["git", "-C", p, "archive", "--format=tar", ref],
102 { signal, env: gitEnv, stdout: "pipe" },
103 );
104 const zst = Bun.spawn(
105 ["zstd", "-o", path.join(outDir, `${base}.tar.zst`)],
106 { signal, env: gitEnv, stdin: tar.stdout },
107 );
108 await Promise.all([tar.exited, zst.exited]);
109 } catch {
110 // zstd not available — skip silently
111 }
112}
113
114export interface CommitEntry {
115 hash: string;
116 subject: string;
117 author: string;
118 date: string;
119}
120
121export interface CommitMeta {
122 hash: string;
123 subject: string;
124 body: string;
125 author: string;
126 email: string;
127 date: string;
128 committer: string;
129 committerEmail: string;
130 committerDate: string;
131 parents: string[];
132}
133
134export interface TreeEntry {
135 mode: string;
136 type: "blob" | "tree";
137 hash: string;
138 size: string;
139 name: string;
140}
141
142function parseLog(out: string): CommitEntry[] {
143 return out
144 .split("\n")
145 .filter(Boolean)
146 .map((line) => {
147 const parts = line.split("\x1f");
148 return {
149 hash: parts[0] ?? "",
150 subject: parts[1] ?? "",
151 author: parts[2] ?? "",
152 date: parts[3] ?? "",
153 };
154 });
155}
156
157function parseLsTree(out: string): TreeEntry[] {
158 return out
159 .split("\n")
160 .filter(Boolean)
161 .map((line) => {
162 // format: <mode> SP <type> SP <object> SP <object size> TAB <file>
163 const tabIdx = line.indexOf("\t");
164 const name = line.slice(tabIdx + 1);
165 const meta = line.slice(0, tabIdx).trim().split(/\s+/);
166 return {
167 mode: meta[0] ?? "",
168 type: (meta[1] ?? "blob") as "blob" | "tree",
169 hash: meta[2] ?? "",
170 size: meta[3] ?? "-",
171 name,
172 };
173 });
174}
175
176export function extractPatchSubject(patch: string): string {
177 for (const line of patch.split("\n").slice(0, 30)) {
178 if (line.startsWith("Subject: ")) {
179 // Strip "[PATCH ...] " prefix added by git format-patch
180 return line.slice(9).replace(/^\[PATCH[^\]]*\]\s*/, "");
181 }
182 }
183 return "";
184}
185
186export interface PatchMeta {
187 subject: string;
188 body: string;
189 author: string;
190 email: string;
191 date: string;
192}
193
194export function extractPatchMeta(patch: string): PatchMeta {
195 const lines = patch.split("\n");
196 let subject = "";
197 let author = "";
198 let email = "";
199 let date = "";
200 const bodyLines: string[] = [];
201 let inHeaders = true;
202 let pastSubject = false;
203
204 for (const line of lines) {
205 if (inHeaders) {
206 if (line.startsWith("From: ")) {
207 const match = line.slice(6).match(/^(.*?)\s*<([^>]+)>/);
208 if (match) {
209 author = match[1]!.trim();
210 email = match[2]!;
211 } else {
212 author = line.slice(6).trim();
213 }
214 } else if (line.startsWith("Date: ")) {
215 date = line.slice(6).trim();
216 } else if (line.startsWith("Subject: ")) {
217 subject = line.slice(9).replace(/^\[PATCH[^\]]*\]\s*/, "");
218 pastSubject = true;
219 } else if (pastSubject && line === "") {
220 inHeaders = false;
221 }
222 } else {
223 if (line === "---") break;
224 bodyLines.push(line);
225 }
226 }
227
228 while (
229 bodyLines.length > 0 &&
230 bodyLines[bodyLines.length - 1]!.trim() === ""
231 ) {
232 bodyLines.pop();
233 }
234
235 return { subject, body: bodyLines.join("\n"), author, email, date };
236}
237
238export const git = {
239 async init(name: string, branch = "main") {
240 return withRepoLock(name, async () => {
241 const p = repoPath(name);
242 await $`git init --bare --initial-branch=${branch} ${p}`;
243 });
244 },
245
246 async log(
247 name: string,
248 ref = "HEAD",
249 limit = 30,
250 skip = 0,
251 ): Promise<CommitEntry[]> {
252 const p = repoPath(name);
253 try {
254 const out =
255 await $`git -C ${p} log ${ref} --format=%H%x1f%s%x1f%an%x1f%ai --max-count=${limit} --skip=${skip}`.text();
256 return parseLog(out);
257 } catch {
258 return [];
259 }
260 },
261
262 async lsTree(
263 name: string,
264 ref: string,
265 subpath = "",
266 ): Promise<TreeEntry[]> {
267 const p = repoPath(name);
268 try {
269 const args = subpath
270 ? [
271 "git",
272 "-C",
273 p,
274 "ls-tree",
275 "--long",
276 ref,
277 "--",
278 `${subpath}/`,
279 ]
280 : ["git", "-C", p, "ls-tree", "--long", ref];
281 const out = await $`${args}`.text();
282 const entries = parseLsTree(out);
283 if (subpath) {
284 // git ls-tree returns full paths like "subpath/name" — strip the prefix
285 const prefix = `${subpath}/`;
286 return entries.map((e) => ({
287 ...e,
288 name: e.name.startsWith(prefix)
289 ? e.name.slice(prefix.length)
290 : e.name,
291 }));
292 }
293 return entries;
294 } catch {
295 return [];
296 }
297 },
298
299 async show(
300 name: string,
301 ref: string,
302 filePath: string,
303 ): Promise<Buffer | null> {
304 const p = repoPath(name);
305 try {
306 const buf =
307 await $`git -C ${p} show ${`${ref}:${filePath}`}`.arrayBuffer();
308 return Buffer.from(buf);
309 } catch {
310 return null;
311 }
312 },
313
314 async diff(name: string, sha: string): Promise<string> {
315 const p = repoPath(name);
316 try {
317 return await $`git -C ${p} diff-tree --no-commit-id -r -p --root ${sha}`.text();
318 } catch {
319 return "";
320 }
321 },
322
323 async blobSize(name: string, hash: string): Promise<number> {
324 if (/^0+$/.test(hash)) return 0;
325 const p = repoPath(name);
326 try {
327 const out = await $`git -C ${p} cat-file -s ${hash}`.text();
328 return parseInt(out.trim(), 10) || 0;
329 } catch {
330 return 0;
331 }
332 },
333
334 async branches(name: string): Promise<string[]> {
335 const p = repoPath(name);
336 try {
337 // %(refname:short) must be a variable — Bun Shell parses bare `()` as subshell syntax
338 const fmt = "%(refname:short)";
339 const out = await $`git -C ${p} branch --format=${fmt}`.text();
340 return out.split("\n").filter(Boolean);
341 } catch {
342 return [];
343 }
344 },
345
346 async defaultBranch(name: string): Promise<string> {
347 const p = repoPath(name);
348 try {
349 const fmt = "%(refname:short)";
350 const branchesOut =
351 await $`git -C ${p} branch --format=${fmt}`.text();
352 const branches = branchesOut.split("\n").filter(Boolean);
353
354 // Read what HEAD points to (may be an unborn branch).
355 let headBranch: string | null = null;
356 try {
357 const out =
358 await $`git -C ${p} symbolic-ref --short HEAD`.text();
359 headBranch = out.trim();
360 } catch {
361 // detached HEAD — fall through
362 }
363
364 // Only trust HEAD if it names a branch that actually exists.
365 if (headBranch && branches.includes(headBranch)) {
366 return headBranch;
367 }
368
369 // HEAD points to an unborn branch or is detached — prefer "main",
370 // then "master", then whatever branch exists first.
371 return (
372 branches.find((b) => b === "main") ??
373 branches.find((b) => b === "master") ??
374 branches[0] ??
375 "main"
376 );
377 } catch {
378 return "main";
379 }
380 },
381
382 async getFileSize(
383 name: string,
384 ref: string,
385 filePath: string,
386 ): Promise<number | null> {
387 const p = repoPath(name);
388 try {
389 const out =
390 await $`git -C ${p} cat-file -s ${`${ref}:${filePath}`}`.text();
391 return parseInt(out.trim(), 10);
392 } catch {
393 return null;
394 }
395 },
396
397 async checkPatch(
398 name: string,
399 patchContent: string,
400 ): Promise<{ clean: boolean; output: string }> {
401 const p = repoPath(name);
402 const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
403 try {
404 await Bun.write(tmpFile, patchContent);
405 // Bare repos have no working tree; populate the index from HEAD so we can
406 // check against git objects (--cached) rather than the filesystem.
407 await $`git -C ${p} read-tree HEAD`.quiet();
408 const result =
409 await $`git -C ${p} apply --check --cached ${tmpFile}`
410 .quiet()
411 .nothrow();
412 return {
413 clean: result.exitCode === 0,
414 output: result.stderr.toString(),
415 };
416 } catch (e) {
417 return { clean: false, output: String(e) };
418 } finally {
419 await $`rm -f ${tmpFile}`.quiet().nothrow();
420 }
421 },
422
423 async applyPatch(
424 name: string,
425 patchContent: string,
426 authorName: string,
427 authorEmail: string,
428 committerName: string,
429 committerEmail: string,
430 ): Promise<void> {
431 return withRepoLock(name, async () => {
432 const p = repoPath(name);
433 const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
434 try {
435 await Bun.write(tmpFile, patchContent);
436 // Populate index, apply to index, then create a real commit in the bare repo.
437 await $`git -C ${p} read-tree HEAD`;
438 await $`git -C ${p} apply --cached ${tmpFile}`;
439 const tree = (await $`git -C ${p} write-tree`.text()).trim();
440 const parent = (
441 await $`git -C ${p} rev-parse HEAD`.text()
442 ).trim();
443 const msg = extractPatchSubject(patchContent);
444 const commit = (
445 await $`git -C ${p} commit-tree ${tree} -p ${parent} -m ${msg}`
446 .env({
447 ...process.env,
448 LC_ALL: "C",
449 LANG: "C",
450 GIT_AUTHOR_NAME: authorName,
451 GIT_AUTHOR_EMAIL: authorEmail,
452 GIT_COMMITTER_NAME: committerName,
453 GIT_COMMITTER_EMAIL: committerEmail,
454 })
455 .text()
456 ).trim();
457 const ref = (
458 await $`git -C ${p} symbolic-ref HEAD`.text()
459 ).trim();
460 await $`git -C ${p} update-ref ${ref} ${commit}`;
461 } finally {
462 await $`rm -f ${tmpFile}`.quiet().nothrow();
463 }
464 });
465 },
466
467 async createTag(
468 repoName: string,
469 tagName: string,
470 ref: string,
471 message?: string,
472 taggerName?: string,
473 taggerEmail?: string,
474 ): Promise<"ok" | "already_exists" | "bad_ref" | "error"> {
475 return withRepoLock(repoName, async () => {
476 const p = repoPath(repoName);
477 const result =
478 message !== undefined
479 ? await $`git -C ${p} tag -a ${tagName} ${ref} -m ${message}`
480 .env({
481 ...gitEnv,
482 GIT_COMMITTER_NAME: taggerName!,
483 GIT_COMMITTER_EMAIL: taggerEmail!,
484 })
485 .nothrow()
486 : await $`git -C ${p} tag ${tagName} ${ref}`.nothrow();
487 if (result.exitCode === 0) return "ok";
488 const stderr = result.stderr.toString();
489 if (stderr.includes("already exists")) return "already_exists";
490 if (
491 stderr.includes("not a valid object name") ||
492 stderr.includes("unknown revision") ||
493 stderr.includes("ambiguous argument")
494 )
495 return "bad_ref";
496 return "error";
497 });
498 },
499
500 async setHead(name: string, branch: string): Promise<void> {
501 const p = repoPath(name);
502 await $`git -C ${p} symbolic-ref HEAD refs/heads/${branch}`;
503 },
504
505 async resolveRef(name: string, ref: string): Promise<string | null> {
506 const p = repoPath(name);
507 try {
508 const out = await $`git -C ${p} rev-parse --verify ${ref}`.text();
509 return out.trim() || null;
510 } catch {
511 return null;
512 }
513 },
514
515 async hasCommits(name: string): Promise<boolean> {
516 const p = repoPath(name);
517 try {
518 const out = await $`git -C ${p} log --oneline -1`.quiet().text();
519 return out.trim().length > 0;
520 } catch {
521 return false;
522 }
523 },
524
525 async commitMeta(name: string, sha: string): Promise<CommitMeta | null> {
526 const p = repoPath(name);
527 try {
528 const [metaOut, msgOut] = await Promise.all([
529 $`git -C ${p} show --no-patch --format=%H%x1f%an%x1f%ae%x1f%ai%x1f%cn%x1f%ce%x1f%ci%x1f%P ${sha}`.text(),
530 $`git -C ${p} log --format=%B -1 ${sha}`.text(),
531 ]);
532 const parts = metaOut.trim().split("\x1f");
533 const fullMsg = msgOut.trimEnd();
534 const firstNl = fullMsg.indexOf("\n");
535 const subject = firstNl >= 0 ? fullMsg.slice(0, firstNl) : fullMsg;
536 const body =
537 firstNl >= 0
538 ? fullMsg
539 .slice(firstNl + 1)
540 .trimStart()
541 .trimEnd()
542 : "";
543 return {
544 hash: parts[0] ?? sha,
545 subject,
546 body,
547 author: parts[1] ?? "",
548 email: parts[2] ?? "",
549 date: parts[3] ?? "",
550 committer: parts[4] ?? "",
551 committerEmail: parts[5] ?? "",
552 committerDate: parts[6] ?? "",
553 parents: (parts[7] ?? "").trim().split(/\s+/).filter(Boolean),
554 };
555 } catch {
556 return null;
557 }
558 },
559};
560