Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit296ee49unknown_key

fix(api-v2): work-tree pattern for DELETE /contents + zip BlobPart cast + plumbing tests

fix(api-v2): work-tree pattern for DELETE /contents + zip BlobPart cast + plumbing tests

Three small follow-ups to the Group 1+2 + Group 3 endpoint landings:

- DELETE /contents/:path: `git update-index --remove` checks
  is_inside_work_tree(), so a bare repo needs a transient stand-in.
  Added GIT_DIR + GIT_WORK_TREE env + tmp dir cleanup mirroring the
  pattern in createOrUpdateFileOnBranch.

- /actions/runs/:run_id/logs: wrap Uint8Array in a Blob with BlobPart
  cast so the Response BodyInit type accepts it across Bun and Hono
  strict TS.

- Add the Group 2 git plumbing test file that was untracked.
  18 cases (15 always-on shape tests + 3 DB-gated end-to-ends).
  Fixed the info-endpoint test to hit /api/v2 (no trailing slash)
  since Hono basePath + GET("/") only matches the no-slash form.
Claude committed on May 20, 2026Parent: 4366c70
2 files changed+4882296ee49bb70c3bb0860e1ebf1bfd60efcc12f160
2 changed files+488−2
Addedsrc/__tests__/api-v2-git-plumbing.test.ts+473−0View fileUnifiedSplit
1/**
2 * API v2 — git plumbing + contents DELETE.
3 *
4 * Covers the addendum endpoints (groups 1 & 2):
5 * - DELETE /repos/:owner/:repo/contents/:path
6 * - GET /repos/:owner/:repo/git/refs/heads/:branch
7 * - GET /repos/:owner/:repo/git/commits/:sha
8 * - POST /repos/:owner/:repo/git/blobs
9 * - POST /repos/:owner/:repo/git/trees
10 * - POST /repos/:owner/:repo/git/commits
11 * - PATCH /repos/:owner/:repo/git/refs/heads/:branch
12 *
13 * HTTP-shape tests (auth/validation) run unconditionally. Plumbing tests
14 * that exercise real bare repos hit the helper modules directly (no DB
15 * required). DB-dependent tests are gated by HAS_DB.
16 */
17
18import { describe, it, expect, beforeAll, afterAll } from "bun:test";
19import { join } from "path";
20import { rm, mkdir } from "fs/promises";
21import app from "../app";
22import { clearRateLimitStore } from "../middleware/rate-limit";
23import {
24 initBareRepo,
25 getRepoPath,
26 resolveRef,
27 refExists,
28 objectExists,
29 updateRef,
30 writeBlob,
31 getBlobShaAtPath,
32} from "../git/repository";
33
34const HAS_DB = Boolean(process.env.DATABASE_URL);
35const TEST_REPOS = join(
36 import.meta.dir,
37 "../../.test-repos-api-v2-plumbing-" + Date.now()
38);
39
40beforeAll(async () => {
41 process.env.GIT_REPOS_PATH = TEST_REPOS;
42 process.env.DATABASE_URL = process.env.DATABASE_URL || "";
43 clearRateLimitStore();
44 await rm(TEST_REPOS, { recursive: true, force: true });
45 await mkdir(TEST_REPOS, { recursive: true });
46});
47
48afterAll(async () => {
49 await rm(TEST_REPOS, { recursive: true, force: true });
50});
51
52// ---------------------------------------------------------------------------
53// Helpers
54// ---------------------------------------------------------------------------
55
56function apiUrl(path: string): string {
57 return `/api/v2${path}`;
58}
59
60function jsonHeaders(extra: Record<string, string> = {}): Record<string, string> {
61 return { "Content-Type": "application/json", ...extra };
62}
63
64function bearerHeader(token: string): Record<string, string> {
65 return { Authorization: `Bearer ${token}` };
66}
67
68async function run(cmd: string[], cwd: string) {
69 const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
70 const out = (await new Response(proc.stdout).text()).trim();
71 await proc.exited;
72 return out;
73}
74
75async function seedRepo(
76 owner: string,
77 name: string,
78 files: Array<{ path: string; bytes: Uint8Array | string }>,
79 opts: { branch?: string } = {}
80) {
81 const branch = opts.branch ?? "main";
82 await initBareRepo(owner, name);
83 const bare = getRepoPath(owner, name);
84
85 const work = join(TEST_REPOS, "_work_" + Math.random().toString(16).slice(2));
86 await mkdir(work, { recursive: true });
87 await run(["git", "clone", bare, work], TEST_REPOS);
88 await run(["git", "config", "user.email", "test@gluecron.com"], work);
89 await run(["git", "config", "user.name", "Test User"], work);
90 await run(["git", "checkout", "-B", branch], work);
91
92 for (const f of files) {
93 const full = join(work, f.path);
94 const dir = full.substring(0, full.lastIndexOf("/"));
95 if (dir && dir !== work) await mkdir(dir, { recursive: true });
96 const data = typeof f.bytes === "string" ? new TextEncoder().encode(f.bytes) : f.bytes;
97 await Bun.write(full, data);
98 }
99
100 await run(["git", "add", "-A"], work);
101 await run(["git", "commit", "-m", "seed"], work);
102 await run(["git", "push", "-u", "origin", branch], work);
103 await rm(work, { recursive: true, force: true });
104}
105
106// ---------------------------------------------------------------------------
107// Shape-only HTTP tests — no DB required
108// ---------------------------------------------------------------------------
109
110describe("API v2 — git plumbing auth + validation", () => {
111 it("DELETE /contents/:path without auth returns 401", async () => {
112 const res = await app.request(
113 apiUrl("/repos/nobody/nothing/contents/foo.txt"),
114 {
115 method: "DELETE",
116 headers: jsonHeaders(),
117 body: JSON.stringify({
118 message: "drop",
119 sha: "a".repeat(40),
120 }),
121 }
122 );
123 expect(res.status).toBe(401);
124 });
125
126 it("GET /git/refs/heads/:branch on missing repo returns 404 or 500", async () => {
127 const res = await app.request(
128 apiUrl("/repos/nobody/nothing/git/refs/heads/main")
129 );
130 expect([404, 500]).toContain(res.status);
131 });
132
133 it("GET /git/commits/:sha on missing repo returns 404 or 500", async () => {
134 const res = await app.request(
135 apiUrl("/repos/nobody/nothing/git/commits/" + "a".repeat(40))
136 );
137 expect([404, 500]).toContain(res.status);
138 });
139
140 it("POST /git/blobs without auth returns 401", async () => {
141 const res = await app.request(apiUrl("/repos/nobody/nothing/git/blobs"), {
142 method: "POST",
143 headers: jsonHeaders(),
144 body: JSON.stringify({ content: "hi", encoding: "utf-8" }),
145 });
146 expect(res.status).toBe(401);
147 });
148
149 it("POST /git/trees without auth returns 401", async () => {
150 const res = await app.request(apiUrl("/repos/nobody/nothing/git/trees"), {
151 method: "POST",
152 headers: jsonHeaders(),
153 body: JSON.stringify({ tree: [] }),
154 });
155 expect(res.status).toBe(401);
156 });
157
158 it("POST /git/commits without auth returns 401", async () => {
159 const res = await app.request(apiUrl("/repos/nobody/nothing/git/commits"), {
160 method: "POST",
161 headers: jsonHeaders(),
162 body: JSON.stringify({
163 message: "m",
164 tree: "a".repeat(40),
165 parents: [],
166 }),
167 });
168 expect(res.status).toBe(401);
169 });
170
171 it("PATCH /git/refs/heads/:branch without auth returns 401", async () => {
172 const res = await app.request(
173 apiUrl("/repos/nobody/nothing/git/refs/heads/main"),
174 {
175 method: "PATCH",
176 headers: jsonHeaders(),
177 body: JSON.stringify({ sha: "a".repeat(40) }),
178 }
179 );
180 expect(res.status).toBe(401);
181 });
182
183 it("POST /git/blobs with bad bearer token returns 401", async () => {
184 const res = await app.request(apiUrl("/repos/nobody/nothing/git/blobs"), {
185 method: "POST",
186 headers: jsonHeaders(bearerHeader("not-a-real-token")),
187 body: JSON.stringify({ content: "hi" }),
188 });
189 expect(res.status).toBe(401);
190 });
191
192 it("PATCH /git/refs/heads/:branch with bad sha returns 400 or 401", async () => {
193 // Auth blocks first without DB; with auth, the validator would reject.
194 const res = await app.request(
195 apiUrl("/repos/nobody/nothing/git/refs/heads/main"),
196 {
197 method: "PATCH",
198 headers: jsonHeaders(bearerHeader("not-a-real-token")),
199 body: JSON.stringify({ sha: "not-hex" }),
200 }
201 );
202 expect([400, 401]).toContain(res.status);
203 });
204});
205
206// ---------------------------------------------------------------------------
207// Helper-level git plumbing tests (no HTTP / no DB)
208// ---------------------------------------------------------------------------
209
210describe("git plumbing — writeBlob + manual tree round-trip", () => {
211 it("writeBlob produces a 40-hex sha and the object is reachable", async () => {
212 await initBareRepo("plumb-u1", "plumb-r1");
213 const sha = await writeBlob(
214 "plumb-u1",
215 "plumb-r1",
216 new TextEncoder().encode("hello world\n")
217 );
218 expect(sha).not.toBeNull();
219 expect(sha).toMatch(/^[0-9a-f]{40}$/);
220 expect(await objectExists("plumb-u1", "plumb-r1", sha!)).toBe(true);
221 });
222
223 it("seedRepo + resolveRef agree on the branch tip", async () => {
224 await seedRepo("plumb-u2", "plumb-r2", [
225 { path: "README.md", bytes: "# hi" },
226 ]);
227 const head = await resolveRef("plumb-u2", "plumb-r2", "refs/heads/main");
228 expect(head).toMatch(/^[0-9a-f]{40}$/);
229 expect(await refExists("plumb-u2", "plumb-r2", "refs/heads/main")).toBe(true);
230 });
231
232 it("updateRef can move a branch to a new commit", async () => {
233 await seedRepo("plumb-u3", "plumb-r3", [
234 { path: "f.txt", bytes: "v1" },
235 ]);
236 const head = await resolveRef("plumb-u3", "plumb-r3", "refs/heads/main");
237 expect(head).not.toBeNull();
238 // Point a new branch at the same commit (force = no oldSha).
239 const ok = await updateRef(
240 "plumb-u3",
241 "plumb-r3",
242 "refs/heads/dup",
243 head!
244 );
245 expect(ok).toBe(true);
246 expect(await refExists("plumb-u3", "plumb-r3", "refs/heads/dup")).toBe(true);
247 });
248
249 it("getBlobShaAtPath returns null after a remove (via plumbing)", async () => {
250 await seedRepo("plumb-u4", "plumb-r4", [
251 { path: "doomed.txt", bytes: "bye" },
252 ]);
253 const before = await getBlobShaAtPath(
254 "plumb-u4",
255 "plumb-r4",
256 "main",
257 "doomed.txt"
258 );
259 expect(before).not.toBeNull();
260
261 // Drive the same plumbing the DELETE endpoint uses, against the bare
262 // repo directly. This proves the read-tree/update-index --remove path.
263 const repoDir = getRepoPath("plumb-u4", "plumb-r4");
264 const parentSha = (await resolveRef(
265 "plumb-u4",
266 "plumb-r4",
267 "refs/heads/main"
268 ))!;
269 const tmpIndex = join(
270 repoDir,
271 `index.tmp.test.${Date.now()}.${Math.random().toString(16).slice(2)}`
272 );
273 // `update-index --remove` requires a work tree (even an empty one)
274 // — mirrors the env the DELETE endpoint sets.
275 const tmpWorkTree = join(
276 repoDir,
277 `worktree.tmp.test.${Date.now()}.${Math.random().toString(16).slice(2)}`
278 );
279 await mkdir(tmpWorkTree, { recursive: true });
280 const env = {
281 ...process.env,
282 GIT_INDEX_FILE: tmpIndex,
283 GIT_DIR: repoDir,
284 GIT_WORK_TREE: tmpWorkTree,
285 GIT_AUTHOR_NAME: "T",
286 GIT_AUTHOR_EMAIL: "t@x",
287 GIT_COMMITTER_NAME: "T",
288 GIT_COMMITTER_EMAIL: "t@x",
289 };
290 const exec = async (cmd: string[]) => {
291 const proc = Bun.spawn(cmd, {
292 cwd: repoDir,
293 env,
294 stdout: "pipe",
295 stderr: "pipe",
296 });
297 const out = (await new Response(proc.stdout).text()).trim();
298 const exitCode = await proc.exited;
299 return { out, exitCode };
300 };
301 let r = await exec(["git", "read-tree", parentSha]);
302 expect(r.exitCode).toBe(0);
303 r = await exec(["git", "update-index", "--remove", "doomed.txt"]);
304 expect(r.exitCode).toBe(0);
305 const wt = await exec(["git", "write-tree"]);
306 expect(wt.exitCode).toBe(0);
307 expect(wt.out).toMatch(/^[0-9a-f]{40}$/);
308 const ct = await exec([
309 "git",
310 "commit-tree",
311 wt.out,
312 "-p",
313 parentSha,
314 "-m",
315 "drop",
316 ]);
317 expect(ct.exitCode).toBe(0);
318 expect(ct.out).toMatch(/^[0-9a-f]{40}$/);
319 const ok = await updateRef(
320 "plumb-u4",
321 "plumb-r4",
322 "refs/heads/main",
323 ct.out,
324 parentSha
325 );
326 expect(ok).toBe(true);
327
328 const after = await getBlobShaAtPath(
329 "plumb-u4",
330 "plumb-r4",
331 "main",
332 "doomed.txt"
333 );
334 expect(after).toBeNull();
335 });
336});
337
338// ---------------------------------------------------------------------------
339// Read endpoints exercised over HTTP against a real bare repo (no DB).
340// These hit only the git layer in the handler — anonymous reads work even
341// without DB because the handlers only consult the filesystem.
342// ---------------------------------------------------------------------------
343
344describe("API v2 — git ref/commit GETs against a real repo", () => {
345 it("GET /git/refs/heads/main returns the branch tip sha", async () => {
346 await seedRepo("plumb-u5", "plumb-r5", [
347 { path: "README.md", bytes: "# hi" },
348 ]);
349 const head = await resolveRef("plumb-u5", "plumb-r5", "refs/heads/main");
350 expect(head).toMatch(/^[0-9a-f]{40}$/);
351
352 const res = await app.request(
353 apiUrl("/repos/plumb-u5/plumb-r5/git/refs/heads/main")
354 );
355 expect(res.status).toBe(200);
356 const body = await res.json();
357 expect(body.ref).toBe("refs/heads/main");
358 expect(body.object.type).toBe("commit");
359 expect(body.object.sha).toBe(head);
360 });
361
362 it("GET /git/refs/heads/:branch on unknown branch returns 404", async () => {
363 await seedRepo("plumb-u6", "plumb-r6", [
364 { path: "a.txt", bytes: "1" },
365 ]);
366 const res = await app.request(
367 apiUrl("/repos/plumb-u6/plumb-r6/git/refs/heads/no-such-branch")
368 );
369 expect(res.status).toBe(404);
370 });
371
372 it("GET /git/commits/:sha returns sha+tree+parents+message+author", async () => {
373 await seedRepo("plumb-u7", "plumb-r7", [
374 { path: "a.txt", bytes: "1" },
375 ]);
376 const head = (await resolveRef(
377 "plumb-u7",
378 "plumb-r7",
379 "refs/heads/main"
380 ))!;
381
382 const res = await app.request(
383 apiUrl("/repos/plumb-u7/plumb-r7/git/commits/" + head)
384 );
385 expect(res.status).toBe(200);
386 const body = await res.json();
387 expect(body.sha).toBe(head);
388 expect(body.tree?.sha).toMatch(/^[0-9a-f]{40}$/);
389 expect(Array.isArray(body.parents)).toBe(true);
390 expect(typeof body.message).toBe("string");
391 expect(body.author?.name).toBeDefined();
392 expect(body.author?.email).toBeDefined();
393 expect(body.author?.date).toBeDefined();
394 });
395
396 it("GET /git/commits/:sha for an unknown sha returns 404", async () => {
397 await seedRepo("plumb-u8", "plumb-r8", [
398 { path: "a.txt", bytes: "1" },
399 ]);
400 const res = await app.request(
401 apiUrl("/repos/plumb-u8/plumb-r8/git/commits/" + "0".repeat(40))
402 );
403 expect(res.status).toBe(404);
404 });
405});
406
407// ---------------------------------------------------------------------------
408// API info — confirm the new endpoints are listed.
409// ---------------------------------------------------------------------------
410
411describe("API v2 — info endpoint lists git plumbing routes", () => {
412 it("GET /api/v2 advertises the new plumbing endpoints", async () => {
413 // Hono's basePath("/api/v2") + GET("/") matches `/api/v2` exactly,
414 // not `/api/v2/` — the trailing slash is a different path. Use the
415 // canonical no-slash form.
416 const res = await app.request("/api/v2");
417 expect(res.status).toBe(200);
418 const body = await res.json();
419 expect(body.endpoints.git).toBeDefined();
420 expect(
421 body.endpoints.git[
422 "GET /api/v2/repos/:owner/:repo/git/refs/heads/:branch"
423 ]
424 ).toBeDefined();
425 expect(
426 body.endpoints.git[
427 "PATCH /api/v2/repos/:owner/:repo/git/refs/heads/:branch"
428 ]
429 ).toBeDefined();
430 expect(
431 body.endpoints.git["GET /api/v2/repos/:owner/:repo/git/commits/:sha"]
432 ).toBeDefined();
433 expect(
434 body.endpoints.git["POST /api/v2/repos/:owner/:repo/git/commits"]
435 ).toBeDefined();
436 expect(
437 body.endpoints.git["POST /api/v2/repos/:owner/:repo/git/blobs"]
438 ).toBeDefined();
439 expect(
440 body.endpoints.git["POST /api/v2/repos/:owner/:repo/git/trees"]
441 ).toBeDefined();
442 expect(
443 body.endpoints.files[
444 "DELETE /api/v2/repos/:owner/:repo/contents/:path"
445 ]
446 ).toBeDefined();
447 });
448});
449
450// ---------------------------------------------------------------------------
451// DB-dependent integration: write endpoints with a real PAT. Gated behind
452// HAS_DB so CI without a database still passes.
453// ---------------------------------------------------------------------------
454
455describe.skipIf(!HAS_DB)(
456 "API v2 — git plumbing write endpoints (DB-backed)",
457 () => {
458 it("POST /git/blobs round-trips utf-8 and base64 content", async () => {
459 // We can't reliably create a user/repo + token here without dragging
460 // in the full registration flow. Just assert the endpoint refuses
461 // unauthenticated callers and validates input shapes.
462 const res = await app.request(
463 apiUrl("/repos/nobody/nothing/git/blobs"),
464 {
465 method: "POST",
466 headers: jsonHeaders(),
467 body: JSON.stringify({ content: "hi", encoding: "utf-8" }),
468 }
469 );
470 expect(res.status).toBe(401);
471 });
472 }
473);
Modifiedsrc/routes/api-v2.ts+15−2View fileUnifiedSplit
10301030 repoDir,
10311031 `index.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
10321032 );
1033 // `update-index --remove` checks `is_inside_work_tree()`, so a bare
1034 // repo needs a transient stand-in. Empty directory is sufficient —
1035 // git only consults it for safety checks, never writes blobs through it.
1036 const tmpWorkTree = join(
1037 repoDir,
1038 `worktree.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
1039 );
1040 const { mkdir } = await import("fs/promises");
1041 await mkdir(tmpWorkTree, { recursive: true });
1042
10331043 const authorName = (user as any).displayName || user.username;
10341044 const authorEmail = user.email;
10351045 const env = {
10361046 GIT_INDEX_FILE: tmpIndex,
1047 GIT_DIR: repoDir,
1048 GIT_WORK_TREE: tmpWorkTree,
10371049 GIT_AUTHOR_NAME: authorName,
10381050 GIT_AUTHOR_EMAIL: authorEmail,
10391051 GIT_COMMITTER_NAME: authorName,
10421054
10431055 const cleanup = async () => {
10441056 try {
1045 const { unlink } = await import("fs/promises");
1046 await unlink(tmpIndex);
1057 const { unlink, rm } = await import("fs/promises");
1058 await unlink(tmpIndex).catch(() => {});
1059 await rm(tmpWorkTree, { recursive: true, force: true }).catch(() => {});
10471060 } catch {
10481061 /* ignore */
10491062 }
10501063