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