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
176function 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 const git = {
187 async init(name: string, branch = "main") {
188 return withRepoLock(name, async () => {
189 const p = repoPath(name);
190 await $`git init --bare --initial-branch=${branch} ${p}`;
191 });
192 },
193
194 async log(
195 name: string,
196 ref = "HEAD",
197 limit = 30,
198 skip = 0,
199 ): Promise<CommitEntry[]> {
200 const p = repoPath(name);
201 try {
202 const out =
203 await $`git -C ${p} log ${ref} --format=%H%x1f%s%x1f%an%x1f%ai --max-count=${limit} --skip=${skip}`.text();
204 return parseLog(out);
205 } catch {
206 return [];
207 }
208 },
209
210 async lsTree(
211 name: string,
212 ref: string,
213 subpath = "",
214 ): Promise<TreeEntry[]> {
215 const p = repoPath(name);
216 try {
217 const args = subpath
218 ? [
219 "git",
220 "-C",
221 p,
222 "ls-tree",
223 "--long",
224 ref,
225 "--",
226 `${subpath}/`,
227 ]
228 : ["git", "-C", p, "ls-tree", "--long", ref];
229 const out = await $`${args}`.text();
230 const entries = parseLsTree(out);
231 if (subpath) {
232 // git ls-tree returns full paths like "subpath/name" — strip the prefix
233 const prefix = `${subpath}/`;
234 return entries.map((e) => ({
235 ...e,
236 name: e.name.startsWith(prefix)
237 ? e.name.slice(prefix.length)
238 : e.name,
239 }));
240 }
241 return entries;
242 } catch {
243 return [];
244 }
245 },
246
247 async show(
248 name: string,
249 ref: string,
250 filePath: string,
251 ): Promise<Buffer | null> {
252 const p = repoPath(name);
253 try {
254 const buf =
255 await $`git -C ${p} show ${`${ref}:${filePath}`}`.arrayBuffer();
256 return Buffer.from(buf);
257 } catch {
258 return null;
259 }
260 },
261
262 async diff(name: string, sha: string): Promise<string> {
263 const p = repoPath(name);
264 try {
265 return await $`git -C ${p} diff-tree --no-commit-id -r -p --root ${sha}`.text();
266 } catch {
267 return "";
268 }
269 },
270
271 async blobSize(name: string, hash: string): Promise<number> {
272 if (/^0+$/.test(hash)) return 0;
273 const p = repoPath(name);
274 try {
275 const out = await $`git -C ${p} cat-file -s ${hash}`.text();
276 return parseInt(out.trim(), 10) || 0;
277 } catch {
278 return 0;
279 }
280 },
281
282 async branches(name: string): Promise<string[]> {
283 const p = repoPath(name);
284 try {
285 // %(refname:short) must be a variable — Bun Shell parses bare `()` as subshell syntax
286 const fmt = "%(refname:short)";
287 const out = await $`git -C ${p} branch --format=${fmt}`.text();
288 return out.split("\n").filter(Boolean);
289 } catch {
290 return [];
291 }
292 },
293
294 async defaultBranch(name: string): Promise<string> {
295 const p = repoPath(name);
296 try {
297 const fmt = "%(refname:short)";
298 const branchesOut =
299 await $`git -C ${p} branch --format=${fmt}`.text();
300 const branches = branchesOut.split("\n").filter(Boolean);
301
302 // Read what HEAD points to (may be an unborn branch).
303 let headBranch: string | null = null;
304 try {
305 const out =
306 await $`git -C ${p} symbolic-ref --short HEAD`.text();
307 headBranch = out.trim();
308 } catch {
309 // detached HEAD — fall through
310 }
311
312 // Only trust HEAD if it names a branch that actually exists.
313 if (headBranch && branches.includes(headBranch)) {
314 return headBranch;
315 }
316
317 // HEAD points to an unborn branch or is detached — prefer "main",
318 // then "master", then whatever branch exists first.
319 return (
320 branches.find((b) => b === "main") ??
321 branches.find((b) => b === "master") ??
322 branches[0] ??
323 "main"
324 );
325 } catch {
326 return "main";
327 }
328 },
329
330 async getFileSize(
331 name: string,
332 ref: string,
333 filePath: string,
334 ): Promise<number | null> {
335 const p = repoPath(name);
336 try {
337 const out =
338 await $`git -C ${p} cat-file -s ${`${ref}:${filePath}`}`.text();
339 return parseInt(out.trim(), 10);
340 } catch {
341 return null;
342 }
343 },
344
345 async checkPatch(
346 name: string,
347 patchContent: string,
348 ): Promise<{ clean: boolean; output: string }> {
349 const p = repoPath(name);
350 const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
351 try {
352 await Bun.write(tmpFile, patchContent);
353 // Bare repos have no working tree; populate the index from HEAD so we can
354 // check against git objects (--cached) rather than the filesystem.
355 await $`git -C ${p} read-tree HEAD`.quiet();
356 const result =
357 await $`git -C ${p} apply --check --cached ${tmpFile}`
358 .quiet()
359 .nothrow();
360 return {
361 clean: result.exitCode === 0,
362 output: result.stderr.toString(),
363 };
364 } catch (e) {
365 return { clean: false, output: String(e) };
366 } finally {
367 await $`rm -f ${tmpFile}`.quiet().nothrow();
368 }
369 },
370
371 async applyPatch(
372 name: string,
373 patchContent: string,
374 title: string,
375 description: string,
376 authorName: string,
377 authorEmail: string,
378 committerName: string,
379 committerEmail: string,
380 ): Promise<void> {
381 return withRepoLock(name, async () => {
382 const p = repoPath(name);
383 const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
384 try {
385 await Bun.write(tmpFile, patchContent);
386 // Populate index, apply to index, then create a real commit in the bare repo.
387 await $`git -C ${p} read-tree HEAD`;
388 await $`git -C ${p} apply --cached ${tmpFile}`;
389 const tree = (await $`git -C ${p} write-tree`.text()).trim();
390 const parent = (
391 await $`git -C ${p} rev-parse HEAD`.text()
392 ).trim();
393 const fallback = description.trim()
394 ? `${title}\n\n${description.trim()}`
395 : title;
396 const msg = extractPatchSubject(patchContent) || fallback;
397 const commit = (
398 await $`git -C ${p} commit-tree ${tree} -p ${parent} -m ${msg}`
399 .env({
400 ...process.env,
401 LC_ALL: "C",
402 LANG: "C",
403 GIT_AUTHOR_NAME: authorName,
404 GIT_AUTHOR_EMAIL: authorEmail,
405 GIT_COMMITTER_NAME: committerName,
406 GIT_COMMITTER_EMAIL: committerEmail,
407 })
408 .text()
409 ).trim();
410 const ref = (
411 await $`git -C ${p} symbolic-ref HEAD`.text()
412 ).trim();
413 await $`git -C ${p} update-ref ${ref} ${commit}`;
414 } finally {
415 await $`rm -f ${tmpFile}`.quiet().nothrow();
416 }
417 });
418 },
419
420 async createTag(
421 repoName: string,
422 tagName: string,
423 ref: string,
424 message?: string,
425 taggerName?: string,
426 taggerEmail?: string,
427 ): Promise<"ok" | "already_exists" | "bad_ref" | "error"> {
428 return withRepoLock(repoName, async () => {
429 const p = repoPath(repoName);
430 const result =
431 message !== undefined
432 ? await $`git -C ${p} tag -a ${tagName} ${ref} -m ${message}`
433 .env({
434 ...gitEnv,
435 GIT_COMMITTER_NAME: taggerName!,
436 GIT_COMMITTER_EMAIL: taggerEmail!,
437 })
438 .nothrow()
439 : await $`git -C ${p} tag ${tagName} ${ref}`.nothrow();
440 if (result.exitCode === 0) return "ok";
441 const stderr = result.stderr.toString();
442 if (stderr.includes("already exists")) return "already_exists";
443 if (
444 stderr.includes("not a valid object name") ||
445 stderr.includes("unknown revision") ||
446 stderr.includes("ambiguous argument")
447 )
448 return "bad_ref";
449 return "error";
450 });
451 },
452
453 async setHead(name: string, branch: string): Promise<void> {
454 const p = repoPath(name);
455 await $`git -C ${p} symbolic-ref HEAD refs/heads/${branch}`;
456 },
457
458 async resolveRef(name: string, ref: string): Promise<string | null> {
459 const p = repoPath(name);
460 try {
461 const out = await $`git -C ${p} rev-parse --verify ${ref}`.text();
462 return out.trim() || null;
463 } catch {
464 return null;
465 }
466 },
467
468 async hasCommits(name: string): Promise<boolean> {
469 const p = repoPath(name);
470 try {
471 const out = await $`git -C ${p} log --oneline -1`.quiet().text();
472 return out.trim().length > 0;
473 } catch {
474 return false;
475 }
476 },
477
478 async commitMeta(name: string, sha: string): Promise<CommitMeta | null> {
479 const p = repoPath(name);
480 try {
481 const [metaOut, msgOut] = await Promise.all([
482 $`git -C ${p} show --no-patch --format=%H%x1f%an%x1f%ae%x1f%ai%x1f%cn%x1f%ce%x1f%ci%x1f%P ${sha}`.text(),
483 $`git -C ${p} log --format=%B -1 ${sha}`.text(),
484 ]);
485 const parts = metaOut.trim().split("\x1f");
486 const fullMsg = msgOut.trimEnd();
487 const firstNl = fullMsg.indexOf("\n");
488 const subject = firstNl >= 0 ? fullMsg.slice(0, firstNl) : fullMsg;
489 const body =
490 firstNl >= 0
491 ? fullMsg
492 .slice(firstNl + 1)
493 .trimStart()
494 .trimEnd()
495 : "";
496 return {
497 hash: parts[0] ?? sha,
498 subject,
499 body,
500 author: parts[1] ?? "",
501 email: parts[2] ?? "",
502 date: parts[3] ?? "",
503 committer: parts[4] ?? "",
504 committerEmail: parts[5] ?? "",
505 committerDate: parts[6] ?? "",
506 parents: (parts[7] ?? "").trim().split(/\s+/).filter(Boolean),
507 };
508 } catch {
509 return null;
510 }
511 },
512};
513