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 branches(name: string): Promise<string[]> {
259 const p = repoPath(name);
260 try {
261 // %(refname:short) must be a variable — Bun Shell parses bare `()` as subshell syntax
262 const fmt = "%(refname:short)";
263 const out = await $`git -C ${p} branch --format=${fmt}`.text();
264 return out.split("\n").filter(Boolean);
265 } catch {
266 return [];
267 }
268 },
269
270 async defaultBranch(name: string): Promise<string> {
271 const p = repoPath(name);
272 try {
273 const fmt = "%(refname:short)";
274 const branchesOut =
275 await $`git -C ${p} branch --format=${fmt}`.text();
276 const branches = branchesOut.split("\n").filter(Boolean);
277
278 // Read what HEAD points to (may be an unborn branch).
279 let headBranch: string | null = null;
280 try {
281 const out =
282 await $`git -C ${p} symbolic-ref --short HEAD`.text();
283 headBranch = out.trim();
284 } catch {
285 // detached HEAD — fall through
286 }
287
288 // Only trust HEAD if it names a branch that actually exists.
289 if (headBranch && branches.includes(headBranch)) {
290 return headBranch;
291 }
292
293 // HEAD points to an unborn branch or is detached — prefer "main",
294 // then "master", then whatever branch exists first.
295 return (
296 branches.find((b) => b === "main") ??
297 branches.find((b) => b === "master") ??
298 branches[0] ??
299 "main"
300 );
301 } catch {
302 return "main";
303 }
304 },
305
306 async getFileSize(
307 name: string,
308 ref: string,
309 filePath: string,
310 ): Promise<number | null> {
311 const p = repoPath(name);
312 try {
313 const out =
314 await $`git -C ${p} cat-file -s ${`${ref}:${filePath}`}`.text();
315 return parseInt(out.trim(), 10);
316 } catch {
317 return null;
318 }
319 },
320
321 async checkPatch(
322 name: string,
323 patchContent: string,
324 ): Promise<{ clean: boolean; output: string }> {
325 const p = repoPath(name);
326 const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
327 try {
328 await Bun.write(tmpFile, patchContent);
329 // Bare repos have no working tree; populate the index from HEAD so we can
330 // check against git objects (--cached) rather than the filesystem.
331 await $`git -C ${p} read-tree HEAD`.quiet();
332 const result =
333 await $`git -C ${p} apply --check --cached ${tmpFile}`
334 .quiet()
335 .nothrow();
336 return {
337 clean: result.exitCode === 0,
338 output: result.stderr.toString(),
339 };
340 } catch (e) {
341 return { clean: false, output: String(e) };
342 } finally {
343 await $`rm -f ${tmpFile}`.quiet().nothrow();
344 }
345 },
346
347 async applyPatch(
348 name: string,
349 patchContent: string,
350 title: string,
351 description?: string,
352 ): Promise<void> {
353 return withRepoLock(name, async () => {
354 const p = repoPath(name);
355 const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
356 try {
357 await Bun.write(tmpFile, patchContent);
358 // Populate index, apply to index, then create a real commit in the bare repo.
359 await $`git -C ${p} read-tree HEAD`;
360 await $`git -C ${p} apply --cached ${tmpFile}`;
361 const tree = (await $`git -C ${p} write-tree`.text()).trim();
362 const parent = (
363 await $`git -C ${p} rev-parse HEAD`.text()
364 ).trim();
365 const fallback = description?.trim()
366 ? `${title}\n\n${description.trim()}`
367 : title;
368 const msg = extractPatchSubject(patchContent) || fallback;
369 const commit = (
370 await $`git -C ${p} commit-tree ${tree} -p ${parent} -m ${msg}`.text()
371 ).trim();
372 const ref = (
373 await $`git -C ${p} symbolic-ref HEAD`.text()
374 ).trim();
375 await $`git -C ${p} update-ref ${ref} ${commit}`;
376 } finally {
377 await $`rm -f ${tmpFile}`.quiet().nothrow();
378 }
379 });
380 },
381
382 async setHead(name: string, branch: string): Promise<void> {
383 const p = repoPath(name);
384 await $`git -C ${p} symbolic-ref HEAD refs/heads/${branch}`;
385 },
386
387 async resolveRef(name: string, ref: string): Promise<string | null> {
388 const p = repoPath(name);
389 try {
390 const out = await $`git -C ${p} rev-parse --verify ${ref}`.text();
391 return out.trim() || null;
392 } catch {
393 return null;
394 }
395 },
396
397 async hasCommits(name: string): Promise<boolean> {
398 const p = repoPath(name);
399 try {
400 const out = await $`git -C ${p} log --oneline -1`.quiet().text();
401 return out.trim().length > 0;
402 } catch {
403 return false;
404 }
405 },
406
407 async commitMeta(name: string, sha: string): Promise<CommitMeta | null> {
408 const p = repoPath(name);
409 try {
410 const [metaOut, msgOut] = await Promise.all([
411 $`git -C ${p} show --no-patch --format=%H%x1f%an%x1f%ae%x1f%ai%x1f%P ${sha}`.text(),
412 $`git -C ${p} log --format=%B -1 ${sha}`.text(),
413 ]);
414 const parts = metaOut.trim().split("\x1f");
415 const fullMsg = msgOut.trimEnd();
416 const firstNl = fullMsg.indexOf("\n");
417 const subject = firstNl >= 0 ? fullMsg.slice(0, firstNl) : fullMsg;
418 const body =
419 firstNl >= 0
420 ? fullMsg
421 .slice(firstNl + 1)
422 .trimStart()
423 .trimEnd()
424 : "";
425 return {
426 hash: parts[0] ?? sha,
427 subject,
428 body,
429 author: parts[1] ?? "",
430 email: parts[2] ?? "",
431 date: parts[3] ?? "",
432 parents: (parts[4] ?? "").trim().split(/\s+/).filter(Boolean),
433 };
434 } catch {
435 return null;
436 }
437 },
438};
439