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