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 committer: string;
119 committerEmail: string;
120 committerDate: string;
121 parents: string[];
122}
123
124export interface TreeEntry {
125 mode: string;
126 type: "blob" | "tree";
127 hash: string;
128 size: string;
129 name: string;
130}
131
132function parseLog(out: string): CommitEntry[] {
133 return out
134 .split("\n")
135 .filter(Boolean)
136 .map((line) => {
137 const parts = line.split("\x1f");
138 return {
139 hash: parts[0] ?? "",
140 subject: parts[1] ?? "",
141 author: parts[2] ?? "",
142 date: parts[3] ?? "",
143 };
144 });
145}
146
147function parseLsTree(out: string): TreeEntry[] {
148 return out
149 .split("\n")
150 .filter(Boolean)
151 .map((line) => {
152 // format: <mode> SP <type> SP <object> SP <object size> TAB <file>
153 const tabIdx = line.indexOf("\t");
154 const name = line.slice(tabIdx + 1);
155 const meta = line.slice(0, tabIdx).trim().split(/\s+/);
156 return {
157 mode: meta[0] ?? "",
158 type: (meta[1] ?? "blob") as "blob" | "tree",
159 hash: meta[2] ?? "",
160 size: meta[3] ?? "-",
161 name,
162 };
163 });
164}
165
166function extractPatchSubject(patch: string): string {
167 for (const line of patch.split("\n").slice(0, 30)) {
168 if (line.startsWith("Subject: ")) {
169 // Strip "[PATCH ...] " prefix added by git format-patch
170 return line.slice(9).replace(/^\[PATCH[^\]]*\]\s*/, "");
171 }
172 }
173 return "";
174}
175
176export const git = {
177 async init(name: string, branch = "main") {
178 return withRepoLock(name, async () => {
179 const p = repoPath(name);
180 await $`git init --bare --initial-branch=${branch} ${p}`;
181 });
182 },
183
184 async log(
185 name: string,
186 ref = "HEAD",
187 limit = 30,
188 skip = 0,
189 ): Promise<CommitEntry[]> {
190 const p = repoPath(name);
191 try {
192 const out =
193 await $`git -C ${p} log ${ref} --format=%H%x1f%s%x1f%an%x1f%ai --max-count=${limit} --skip=${skip}`.text();
194 return parseLog(out);
195 } catch {
196 return [];
197 }
198 },
199
200 async lsTree(
201 name: string,
202 ref: string,
203 subpath = "",
204 ): Promise<TreeEntry[]> {
205 const p = repoPath(name);
206 try {
207 const args = subpath
208 ? [
209 "git",
210 "-C",
211 p,
212 "ls-tree",
213 "--long",
214 ref,
215 "--",
216 `${subpath}/`,
217 ]
218 : ["git", "-C", p, "ls-tree", "--long", ref];
219 const out = await $`${args}`.text();
220 const entries = parseLsTree(out);
221 if (subpath) {
222 // git ls-tree returns full paths like "subpath/name" — strip the prefix
223 const prefix = `${subpath}/`;
224 return entries.map((e) => ({
225 ...e,
226 name: e.name.startsWith(prefix)
227 ? e.name.slice(prefix.length)
228 : e.name,
229 }));
230 }
231 return entries;
232 } catch {
233 return [];
234 }
235 },
236
237 async show(
238 name: string,
239 ref: string,
240 filePath: string,
241 ): Promise<Buffer | null> {
242 const p = repoPath(name);
243 try {
244 const buf =
245 await $`git -C ${p} show ${`${ref}:${filePath}`}`.arrayBuffer();
246 return Buffer.from(buf);
247 } catch {
248 return null;
249 }
250 },
251
252 async diff(name: string, sha: string): Promise<string> {
253 const p = repoPath(name);
254 try {
255 return await $`git -C ${p} diff-tree --no-commit-id -r -p --root ${sha}`.text();
256 } catch {
257 return "";
258 }
259 },
260
261 async blobSize(name: string, hash: string): Promise<number> {
262 if (/^0+$/.test(hash)) return 0;
263 const p = repoPath(name);
264 try {
265 const out = await $`git -C ${p} cat-file -s ${hash}`.text();
266 return parseInt(out.trim(), 10) || 0;
267 } catch {
268 return 0;
269 }
270 },
271
272 async branches(name: string): Promise<string[]> {
273 const p = repoPath(name);
274 try {
275 // %(refname:short) must be a variable — Bun Shell parses bare `()` as subshell syntax
276 const fmt = "%(refname:short)";
277 const out = await $`git -C ${p} branch --format=${fmt}`.text();
278 return out.split("\n").filter(Boolean);
279 } catch {
280 return [];
281 }
282 },
283
284 async defaultBranch(name: string): Promise<string> {
285 const p = repoPath(name);
286 try {
287 const fmt = "%(refname:short)";
288 const branchesOut =
289 await $`git -C ${p} branch --format=${fmt}`.text();
290 const branches = branchesOut.split("\n").filter(Boolean);
291
292 // Read what HEAD points to (may be an unborn branch).
293 let headBranch: string | null = null;
294 try {
295 const out =
296 await $`git -C ${p} symbolic-ref --short HEAD`.text();
297 headBranch = out.trim();
298 } catch {
299 // detached HEAD — fall through
300 }
301
302 // Only trust HEAD if it names a branch that actually exists.
303 if (headBranch && branches.includes(headBranch)) {
304 return headBranch;
305 }
306
307 // HEAD points to an unborn branch or is detached — prefer "main",
308 // then "master", then whatever branch exists first.
309 return (
310 branches.find((b) => b === "main") ??
311 branches.find((b) => b === "master") ??
312 branches[0] ??
313 "main"
314 );
315 } catch {
316 return "main";
317 }
318 },
319
320 async getFileSize(
321 name: string,
322 ref: string,
323 filePath: string,
324 ): Promise<number | null> {
325 const p = repoPath(name);
326 try {
327 const out =
328 await $`git -C ${p} cat-file -s ${`${ref}:${filePath}`}`.text();
329 return parseInt(out.trim(), 10);
330 } catch {
331 return null;
332 }
333 },
334
335 async checkPatch(
336 name: string,
337 patchContent: string,
338 ): Promise<{ clean: boolean; output: string }> {
339 const p = repoPath(name);
340 const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
341 try {
342 await Bun.write(tmpFile, patchContent);
343 // Bare repos have no working tree; populate the index from HEAD so we can
344 // check against git objects (--cached) rather than the filesystem.
345 await $`git -C ${p} read-tree HEAD`.quiet();
346 const result =
347 await $`git -C ${p} apply --check --cached ${tmpFile}`
348 .quiet()
349 .nothrow();
350 return {
351 clean: result.exitCode === 0,
352 output: result.stderr.toString(),
353 };
354 } catch (e) {
355 return { clean: false, output: String(e) };
356 } finally {
357 await $`rm -f ${tmpFile}`.quiet().nothrow();
358 }
359 },
360
361 async applyPatch(
362 name: string,
363 patchContent: string,
364 title: string,
365 description: string,
366 authorName: string,
367 authorEmail: string,
368 committerName: string,
369 committerEmail: string,
370 ): Promise<void> {
371 return withRepoLock(name, async () => {
372 const p = repoPath(name);
373 const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
374 try {
375 await Bun.write(tmpFile, patchContent);
376 // Populate index, apply to index, then create a real commit in the bare repo.
377 await $`git -C ${p} read-tree HEAD`;
378 await $`git -C ${p} apply --cached ${tmpFile}`;
379 const tree = (await $`git -C ${p} write-tree`.text()).trim();
380 const parent = (
381 await $`git -C ${p} rev-parse HEAD`.text()
382 ).trim();
383 const fallback = description.trim()
384 ? `${title}\n\n${description.trim()}`
385 : title;
386 const msg = extractPatchSubject(patchContent) || fallback;
387 const commit = (
388 await $`git -C ${p} commit-tree ${tree} -p ${parent} -m ${msg}`
389 .env({
390 ...process.env,
391 LC_ALL: "C",
392 LANG: "C",
393 GIT_AUTHOR_NAME: authorName,
394 GIT_AUTHOR_EMAIL: authorEmail,
395 GIT_COMMITTER_NAME: committerName,
396 GIT_COMMITTER_EMAIL: committerEmail,
397 })
398 .text()
399 ).trim();
400 const ref = (
401 await $`git -C ${p} symbolic-ref HEAD`.text()
402 ).trim();
403 await $`git -C ${p} update-ref ${ref} ${commit}`;
404 } finally {
405 await $`rm -f ${tmpFile}`.quiet().nothrow();
406 }
407 });
408 },
409
410 async setHead(name: string, branch: string): Promise<void> {
411 const p = repoPath(name);
412 await $`git -C ${p} symbolic-ref HEAD refs/heads/${branch}`;
413 },
414
415 async resolveRef(name: string, ref: string): Promise<string | null> {
416 const p = repoPath(name);
417 try {
418 const out = await $`git -C ${p} rev-parse --verify ${ref}`.text();
419 return out.trim() || null;
420 } catch {
421 return null;
422 }
423 },
424
425 async hasCommits(name: string): Promise<boolean> {
426 const p = repoPath(name);
427 try {
428 const out = await $`git -C ${p} log --oneline -1`.quiet().text();
429 return out.trim().length > 0;
430 } catch {
431 return false;
432 }
433 },
434
435 async commitMeta(name: string, sha: string): Promise<CommitMeta | null> {
436 const p = repoPath(name);
437 try {
438 const [metaOut, msgOut] = await Promise.all([
439 $`git -C ${p} show --no-patch --format=%H%x1f%an%x1f%ae%x1f%ai%x1f%cn%x1f%ce%x1f%ci%x1f%P ${sha}`.text(),
440 $`git -C ${p} log --format=%B -1 ${sha}`.text(),
441 ]);
442 const parts = metaOut.trim().split("\x1f");
443 const fullMsg = msgOut.trimEnd();
444 const firstNl = fullMsg.indexOf("\n");
445 const subject = firstNl >= 0 ? fullMsg.slice(0, firstNl) : fullMsg;
446 const body =
447 firstNl >= 0
448 ? fullMsg
449 .slice(firstNl + 1)
450 .trimStart()
451 .trimEnd()
452 : "";
453 return {
454 hash: parts[0] ?? sha,
455 subject,
456 body,
457 author: parts[1] ?? "",
458 email: parts[2] ?? "",
459 date: parts[3] ?? "",
460 committer: parts[4] ?? "",
461 committerEmail: parts[5] ?? "",
462 committerDate: parts[6] ?? "",
463 parents: (parts[7] ?? "").trim().split(/\s+/).filter(Boolean),
464 };
465 } catch {
466 return null;
467 }
468 },
469};
470