repos.tsx
Raw
1import { readFileSync, rmSync } from "node:fs";
2import path from "node:path";
3import { Elysia, t } from "elysia";
4import { fileTypeFromBuffer } from "file-type";
5import { COMMITTER_EMAIL, COMMITTER_NAME } from "../config.ts";
6import {
7 ALLOWED_SIGNERS_PATH,
8 COMMITS_PER_PAGE,
9 REPOS_PER_PAGE,
10 VALID_REPO_NAME_RE,
11} from "../constants.ts";
12import { db } from "../db/index.ts";
13import { redirect } from "../lib/redirect.ts";
14import { requireAdmin, resolveSession } from "../middleware/session.ts";
15import { git, repoPath } from "../services/git.ts";
16import {
17 hasBinaryContent,
18 prepareDiff,
19 serveFile,
20} from "../services/highlightWorker.ts";
21import { renderMarkdown } from "../services/markdown.ts";
22import { ensureRepoRecord, repoDiskExists } from "../services/repoSync.ts";
23import { html } from "../views/render.tsx";
24import { CommitDetail } from "../views/repos/CommitDetail.tsx";
25import { CommitLog } from "../views/repos/CommitLog.tsx";
26import { FileBlob } from "../views/repos/FileBlob.tsx";
27import { FileEdit } from "../views/repos/FileEdit.tsx";
28import { FileTree } from "../views/repos/FileTree.tsx";
29import { NewRepo } from "../views/repos/NewRepo.tsx";
30import { RepoHome } from "../views/repos/RepoHome.tsx";
31import { RepoList } from "../views/repos/RepoList.tsx";
32import { RepoSettings } from "../views/repos/RepoSettings.tsx";
33
34async function getRepo(name: string, isAdmin: boolean) {
35 if (!repoDiskExists(name)) return null;
36 const repo = await ensureRepoRecord(name);
37 if (repo.is_private && !isAdmin) return null;
38 return repo;
39}
40
41async function mimeForContent(
42 filename: string,
43 content: Buffer,
44): Promise<string> {
45 const result = await fileTypeFromBuffer(content);
46 if (result) return result.mime;
47
48 const typeFromName = Bun.file(filename).type;
49 if (typeFromName !== "application/octet-stream") {
50 return typeFromName;
51 }
52
53 return hasBinaryContent(content.subarray(0, 8000))
54 ? "application/octet-stream"
55 : "text/plain; charset=utf-8";
56}
57
58async function readReadme(
59 repo: string,
60 ref: string,
61 dir = "",
62): Promise<{ content: Buffer; filename: string } | null> {
63 const prefix = dir ? `${dir}/` : "";
64 const names = ["README.md", "readme.md", "README", "readme"];
65 const results = await Promise.all(
66 names.map((n) => git.show(repo, ref, `${prefix}${n}`)),
67 );
68 for (let i = 0; i < results.length; i++) {
69 if (results[i]) {
70 return {
71 content: results[i] as Buffer,
72 filename: `${prefix}${names[i]}`,
73 };
74 }
75 }
76 return null;
77}
78
79export const repoRoutes = new Elysia()
80 .get("/allowed_signers", () => {
81 return new Response(readFileSync(ALLOWED_SIGNERS_PATH), {
82 headers: { "Content-Type": "text/plain; charset=utf-8" },
83 });
84 })
85 .guard({
86 cookie: t.Cookie({
87 session: t.Optional(t.String()),
88 repo_sort: t.Optional(t.String()),
89 }),
90 })
91 .post(
92 "/sort",
93 ({ body }) => {
94 const sort = body.sort === "name" ? "name" : "created";
95 return redirect(
96 "/",
97 `repo_sort=${sort}; Path=/; SameSite=Lax; Max-Age=${365 * 24 * 60 * 60}`,
98 );
99 },
100 { body: t.Object({ sort: t.String() }) },
101 )
102 .get(
103 "/",
104 async ({ cookie, query }) => {
105 const user = await resolveSession(cookie.session.value);
106 const search = query.q?.trim() || undefined;
107 const page = Math.max(1, query.page ?? 1);
108 const sort = cookie.repo_sort.value === "name" ? "name" : "created";
109
110 const isAdmin = user?.isAdmin ?? false;
111
112 const countResult = await db
113 .selectFrom("repositories")
114 .select(db.fn.countAll<number>().as("count"))
115 .where((eb) =>
116 isAdmin
117 ? eb.or([
118 eb("is_private", "=", 0),
119 eb("is_private", "=", 1),
120 ])
121 : eb("is_private", "=", 0),
122 )
123 .$if(!!search, (qb) =>
124 qb.where((eb) =>
125 eb.or([
126 eb("name", "like", `%${search}%`),
127 eb("description", "like", `%${search}%`),
128 ]),
129 ),
130 )
131 .executeTakeFirst();
132
133 const totalCount = Number(countResult?.count ?? 0);
134 const totalPages = Math.max(
135 1,
136 Math.ceil(totalCount / REPOS_PER_PAGE),
137 );
138 const safePage = Math.min(page, totalPages);
139
140 const repos = await db
141 .selectFrom("repositories")
142 .selectAll()
143 .where((eb) =>
144 isAdmin
145 ? eb.or([
146 eb("is_private", "=", 0),
147 eb("is_private", "=", 1),
148 ])
149 : eb("is_private", "=", 0),
150 )
151 .$if(!!search, (qb) =>
152 qb.where((eb) =>
153 eb.or([
154 eb("name", "like", `%${search}%`),
155 eb("description", "like", `%${search}%`),
156 ]),
157 ),
158 )
159 .orderBy("is_pinned", "desc")
160 .$if(sort === "name", (qb) => qb.orderBy("name", "asc"))
161 .$if(sort === "created", (qb) =>
162 qb.orderBy("created_at", "desc"),
163 )
164 .limit(REPOS_PER_PAGE)
165 .offset((safePage - 1) * REPOS_PER_PAGE)
166 .execute();
167
168 const searchParam = search
169 ? `&q=${encodeURIComponent(search)}`
170 : "";
171 const pagination = {
172 page: safePage,
173 totalPages,
174 pageUrlTemplate: `/?page={page}${searchParam}`,
175 };
176
177 return html(
178 <RepoList
179 user={user}
180 repos={repos}
181 search={search}
182 sort={sort}
183 pagination={pagination}
184 />,
185 );
186 },
187 {
188 query: t.Object({
189 q: t.Optional(t.String()),
190 page: t.Optional(t.Numeric()),
191 }),
192 },
193 )
194
195 .get("/new", async ({ cookie }) => {
196 const user = await resolveSession(cookie.session.value);
197 const deny = requireAdmin(user);
198 if (deny) return deny;
199 return html(<NewRepo user={user!} />);
200 })
201
202 .post(
203 "/new",
204 async ({ body, cookie }) => {
205 const user = await resolveSession(cookie.session.value);
206 const deny = requireAdmin(user);
207 if (deny) return deny;
208
209 const { name, description, is_private, default_branch } = body;
210
211 if (!VALID_REPO_NAME_RE.test(name)) {
212 return html(
213 <NewRepo user={user!} error="Invalid repository name" />,
214 );
215 }
216
217 const branch = (default_branch?.trim() || "main").replace(
218 /[^a-zA-Z0-9._/-]/g,
219 "",
220 );
221
222 const existing = await db
223 .selectFrom("repositories")
224 .select("id")
225 .where("name", "=", name)
226 .executeTakeFirst();
227 if (existing) {
228 return html(
229 <NewRepo
230 user={user!}
231 error="Repository name already taken"
232 />,
233 );
234 }
235
236 const now = new Date().toISOString();
237 await db
238 .insertInto("repositories")
239 .values({
240 name,
241 description: description || null,
242 is_private: is_private === "1" ? 1 : 0,
243 default_branch: branch,
244 created_at: now,
245 })
246 .execute();
247
248 // Initialise the git repo after the DB record is committed. If
249 // git.init fails we roll back the DB record so the two stay in sync.
250 try {
251 await git.init(name, branch);
252 } catch (err) {
253 await db
254 .deleteFrom("repositories")
255 .where("name", "=", name)
256 .execute();
257 throw err;
258 }
259 return new Response(null, {
260 status: 302,
261 headers: { Location: `/${name}` },
262 });
263 },
264 {
265 body: t.Object({
266 name: t.String(),
267 description: t.Optional(t.String()),
268 is_private: t.Optional(t.String()),
269 default_branch: t.Optional(t.String()),
270 }),
271 },
272 )
273
274 .get("/:repo", async ({ params, cookie }) => {
275 const user = await resolveSession(cookie.session.value);
276 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
277 if (!repo) return new Response("Not found", { status: 404 });
278
279 const hasContent = await git.hasCommits(repo.name);
280 let readmeHtml: string | null = null;
281 let readmePath: string | undefined;
282 let entries: Awaited<ReturnType<typeof git.lsTree>> = [];
283 let branches: string[] = [];
284
285 if (hasContent) {
286 const [lsResult, branchResult, resolved] = await Promise.all([
287 git.lsTree(repo.name, repo.default_branch),
288 git.branches(repo.name),
289 git.resolveRef(repo.name, repo.default_branch),
290 ]);
291 entries = lsResult;
292 branches = branchResult;
293 const readme = await readReadme(repo.name, repo.default_branch);
294 if (readme) {
295 const key = resolved
296 ? `readme:${repo.name}:${resolved}:`
297 : undefined;
298 readmeHtml = renderMarkdown(readme.content.toString("utf-8"), key, {
299 repo: repo.name,
300 ref: repo.default_branch,
301 dir: "",
302 });
303 readmePath = readme.filename;
304 }
305 }
306
307 return html(
308 <RepoHome
309 user={user}
310 repo={repo}
311 entries={entries}
312 readmeHtml={readmeHtml}
313 readmePath={readmePath}
314 hasContent={hasContent}
315 branches={branches}
316 />,
317 );
318 })
319
320 .get(
321 "/:repo/branch-switch",
322 async ({ params, query, cookie }) => {
323 const user = await resolveSession(cookie.session.value);
324 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
325 if (!repo) return new Response("Not found", { status: 404 });
326
327 const ref = query.rev?.trim();
328 if (!ref)
329 return new Response(null, {
330 status: 302,
331 headers: { Location: `/${repo.name}` },
332 });
333
334 const view = query.view;
335 const subpath = query.path ?? "";
336
337 if (view === "commits") {
338 return new Response(null, {
339 status: 302,
340 headers: { Location: `/${repo.name}/commits/${ref}` },
341 });
342 }
343 if (view === "blob" && subpath) {
344 return new Response(null, {
345 status: 302,
346 headers: {
347 Location: `/${repo.name}/blob/${ref}/${subpath}`,
348 },
349 });
350 }
351 const location = subpath
352 ? `/${repo.name}/tree/${ref}/${subpath}`
353 : `/${repo.name}/tree/${ref}`;
354 return new Response(null, {
355 status: 302,
356 headers: { Location: location },
357 });
358 },
359 {
360 query: t.Object({
361 rev: t.Optional(t.String()),
362 view: t.Optional(t.String()),
363 path: t.Optional(t.String()),
364 }),
365 },
366 )
367
368 .get("/:repo/tree/:ref", async ({ params, cookie }) => {
369 const user = await resolveSession(cookie.session.value);
370 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
371 if (!repo) return new Response("Not found", { status: 404 });
372
373 const resolved = await git.resolveRef(repo.name, params.ref);
374 if (!resolved) return new Response("Not found", { status: 404 });
375
376 const [entries, branches] = await Promise.all([
377 git.lsTree(repo.name, params.ref),
378 git.branches(repo.name),
379 ]);
380 const readme = await readReadme(repo.name, params.ref);
381 const readmeHtml = readme
382 ? renderMarkdown(
383 readme.content.toString("utf-8"),
384 `readme:${repo.name}:${resolved}:`,
385 { repo: repo.name, ref: params.ref, dir: "" },
386 )
387 : null;
388 return html(
389 <FileTree
390 user={user}
391 repo={repo}
392 ref={params.ref}
393 subpath=""
394 entries={entries}
395 branches={branches}
396 readmeHtml={readmeHtml}
397 readmePath={readme?.filename}
398 />,
399 );
400 })
401
402 .get("/:repo/tree/:ref/*", async ({ params, cookie }) => {
403 const user = await resolveSession(cookie.session.value);
404 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
405 if (!repo) return new Response("Not found", { status: 404 });
406
407 const resolved = await git.resolveRef(repo.name, params.ref);
408 if (!resolved) return new Response("Not found", { status: 404 });
409
410 const subpath = decodeURIComponent(params["*"]);
411 const [entries, branches] = await Promise.all([
412 git.lsTree(repo.name, params.ref, subpath),
413 git.branches(repo.name),
414 ]);
415 if (entries.length === 0) {
416 // Could be a file — redirect to blob
417 return new Response(null, {
418 status: 302,
419 headers: {
420 Location: `/${repo.name}/blob/${params.ref}/${subpath}`,
421 },
422 });
423 }
424 const readme = await readReadme(repo.name, params.ref, subpath);
425 const readmeHtml = readme
426 ? renderMarkdown(
427 readme.content.toString("utf-8"),
428 `readme:${repo.name}:${resolved}:${subpath}`,
429 { repo: repo.name, ref: params.ref, dir: subpath },
430 )
431 : null;
432 return html(
433 <FileTree
434 user={user}
435 repo={repo}
436 ref={params.ref}
437 subpath={subpath}
438 entries={entries}
439 branches={branches}
440 readmeHtml={readmeHtml}
441 readmePath={readme?.filename}
442 />,
443 );
444 })
445
446 .get("/:repo/blob/:ref/*", async ({ params, cookie }) => {
447 const user = await resolveSession(cookie.session.value);
448 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
449 if (!repo) return new Response("Not found", { status: 404 });
450
451 const filePath = decodeURIComponent(params["*"]);
452 const [content, branches, commitSHA] = await Promise.all([
453 git.show(repo.name, params.ref, filePath),
454 git.branches(repo.name),
455 git.resolveRef(repo.name, params.ref),
456 ]);
457 if (!content || !commitSHA)
458 return new Response("Not found", { status: 404 });
459
460 const filename = path.basename(filePath);
461 const [view, markdownHtml] = await Promise.all([
462 serveFile(
463 content,
464 filename,
465 `${repo.name}:${commitSHA}:${filePath}`,
466 ),
467 /\.mdx?$/i.test(filename)
468 ? Promise.resolve(
469 renderMarkdown(
470 content.toString("utf-8"),
471 `${repo.name}:${commitSHA}:${filePath}`,
472 {
473 repo: repo.name,
474 ref: params.ref,
475 dir: path.dirname(filePath) === "."
476 ? ""
477 : path.dirname(filePath),
478 },
479 ),
480 )
481 : Promise.resolve(undefined),
482 ]);
483 return html(
484 <FileBlob
485 user={user}
486 repo={repo}
487 ref={params.ref}
488 filePath={filePath}
489 view={view}
490 branches={branches}
491 markdownHtml={markdownHtml}
492 />,
493 );
494 })
495
496 .get("/:repo/raw/:ref/*", async ({ params, cookie, request }) => {
497 const user = await resolveSession(cookie.session.value);
498 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
499 if (!repo) return new Response("Not found", { status: 404 });
500
501 const filePath = decodeURIComponent(params["*"]);
502 const content = await git.show(repo.name, params.ref, filePath);
503 if (!content) return new Response("Not found", { status: 404 });
504
505 const filename = path.basename(filePath);
506 const contentType = await mimeForContent(filename, content);
507 const total = content.length;
508
509 const rangeHeader = request.headers.get("Range");
510 if (rangeHeader) {
511 const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
512 if (match) {
513 const start = match[1] ? parseInt(match[1], 10) : 0;
514 const end = match[2] ? parseInt(match[2], 10) : total - 1;
515 const clampedEnd = Math.min(end, total - 1);
516 return new Response(content.subarray(start, clampedEnd + 1), {
517 status: 206,
518 headers: {
519 "Content-Type": contentType,
520 "Content-Range": `bytes ${start}-${clampedEnd}/${total}`,
521 "Accept-Ranges": "bytes",
522 "Content-Length": String(clampedEnd - start + 1),
523 },
524 });
525 }
526 }
527
528 return new Response(content, {
529 headers: {
530 "Content-Type": contentType,
531 "Content-Disposition": `inline; filename="${filename}"`,
532 "Content-Length": String(total),
533 "Accept-Ranges": "bytes",
534 },
535 });
536 })
537
538 .get("/:repo/edit/:ref/*", async ({ params, cookie }) => {
539 const user = await resolveSession(cookie.session.value);
540 const deny = requireAdmin(user);
541 if (deny) return deny;
542 const repo = await getRepo(params.repo, true);
543 if (!repo) return new Response("Not found", { status: 404 });
544
545 const filePath = decodeURIComponent(params["*"]);
546 const branches = await git.branches(repo.name);
547 if (!branches.includes(params.ref))
548 return new Response("Not found", { status: 404 });
549
550 const content = await git.show(repo.name, params.ref, filePath);
551 if (!content) return new Response("Not found", { status: 404 });
552
553 if (hasBinaryContent(content.subarray(0, 8000)))
554 return new Response("Not found", { status: 404 });
555
556 return html(
557 <FileEdit
558 user={user!}
559 repo={repo}
560 ref={params.ref}
561 filePath={filePath}
562 content={content.toString("utf-8")}
563 />,
564 );
565 })
566
567 .post(
568 "/:repo/edit/:ref/*",
569 async ({ params, body, cookie }) => {
570 const user = await resolveSession(cookie.session.value);
571 const deny = requireAdmin(user);
572 if (deny) return deny;
573 const repo = await getRepo(params.repo, true);
574 if (!repo) return new Response("Not found", { status: 404 });
575
576 const filePath = decodeURIComponent(params["*"]);
577 const branches = await git.branches(repo.name);
578 if (!branches.includes(params.ref))
579 return new Response("Not found", { status: 404 });
580
581 const message =
582 body.message?.trim() || `Edited ${path.basename(filePath)}`;
583 const content = (body.content ?? "").replaceAll("\r\n", "\n");
584
585 const commit = await git.editFile(
586 repo.name,
587 params.ref,
588 filePath,
589 content,
590 message,
591 COMMITTER_NAME,
592 COMMITTER_EMAIL,
593 );
594
595 return new Response(null, {
596 status: 302,
597 headers: {
598 Location: `/${repo.name}/commit/${commit}`,
599 },
600 });
601 },
602 {
603 body: t.Object({
604 content: t.Optional(t.String()),
605 message: t.Optional(t.String()),
606 }),
607 },
608 )
609
610 .get(
611 "/:repo/commits/:ref",
612 async ({ params, cookie, query }) => {
613 const user = await resolveSession(cookie.session.value);
614 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
615 if (!repo) return new Response("Not found", { status: 404 });
616
617 // Cursor-based pagination — O(1) regardless of history depth.
618 // `after` = SHA of the last commit on the previous page (resume cursor).
619 // `prev` = the `after` value used on the page that linked here, so we can
620 // reconstruct a "← Newer" link without a full history traversal.
621 const after = query.after?.trim() || null;
622 const prev = query.prev?.trim() || null;
623
624 const [rawCommits, branches] = await Promise.all([
625 // When `after` is set: start at that SHA and skip it (--skip=1 is O(1)),
626 // then fetch LIMIT+1 to detect whether another page exists.
627 after
628 ? git.log(repo.name, after, COMMITS_PER_PAGE + 1, 1)
629 : git.log(repo.name, params.ref, COMMITS_PER_PAGE + 1, 0),
630 git.branches(repo.name),
631 ]);
632
633 const hasNext = rawCommits.length > COMMITS_PER_PAGE;
634 const commits = rawCommits.slice(0, COMMITS_PER_PAGE);
635
636 // Build cursor URLs.
637 // "Older" advances past the last commit on this page.
638 // "Newer" goes back one page using the `prev` cursor saved in the URL,
639 // or to the first page if we're on page 2.
640 const base = `/${repo.name}/commits/${params.ref}`;
641 const olderUrl = hasNext
642 ? `${base}?after=${commits[commits.length - 1]?.hash}&prev=${after ?? ""}`
643 : null;
644 const newerUrl = after
645 ? prev
646 ? `${base}?after=${prev}`
647 : base
648 : null;
649
650 return html(
651 <CommitLog
652 user={user}
653 repo={repo}
654 ref={params.ref}
655 commits={commits}
656 branches={branches}
657 olderUrl={olderUrl}
658 newerUrl={newerUrl}
659 />,
660 );
661 },
662 {
663 query: t.Object({
664 after: t.Optional(t.String()),
665 prev: t.Optional(t.String()),
666 }),
667 },
668 )
669
670 .get("/:repo/commit/:sha", async ({ params, cookie }) => {
671 const user = await resolveSession(cookie.session.value);
672 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
673 if (!repo) return new Response("Not found", { status: 404 });
674
675 const [meta, rawDiff] = await Promise.all([
676 git.commitMeta(repo.name, params.sha),
677 git.diff(repo.name, params.sha),
678 ]);
679 if (!meta) return new Response("Commit not found", { status: 404 });
680 const files = await prepareDiff(
681 rawDiff,
682 `commit:${repo.name}:${params.sha}`,
683 repo.name,
684 );
685 return html(
686 <CommitDetail
687 user={user}
688 repo={repo}
689 sha={params.sha}
690 meta={meta}
691 files={files}
692 />,
693 );
694 })
695
696 .get("/:repo/settings", async ({ params, cookie }) => {
697 const user = await resolveSession(cookie.session.value);
698 const deny = requireAdmin(user);
699 if (deny) return deny;
700 const repo = await getRepo(params.repo, true);
701 if (!repo) return new Response("Not found", { status: 404 });
702 const branches = await git.branches(repo.name);
703 return html(
704 <RepoSettings user={user!} repo={repo} branches={branches} />,
705 );
706 })
707
708 .post(
709 "/:repo/settings",
710 async ({ params, body, cookie }) => {
711 const user = await resolveSession(cookie.session.value);
712 const deny = requireAdmin(user);
713 if (deny) return deny;
714 const repo = await getRepo(params.repo, true);
715 if (!repo) return new Response("Not found", { status: 404 });
716
717 const {
718 description,
719 is_private,
720 is_pinned,
721 default_branch,
722 issue_template,
723 patch_template,
724 } = body;
725
726 const branches = await git.branches(repo.name);
727 const newBranch = default_branch?.trim() || repo.default_branch;
728
729 // Validate the selected branch exists (only if repo has commits)
730 if (branches.length > 0 && !branches.includes(newBranch)) {
731 return html(
732 <RepoSettings
733 user={user!}
734 repo={repo}
735 branches={branches}
736 error={`Branch "${newBranch}" does not exist.`}
737 />,
738 );
739 }
740
741 await db
742 .updateTable("repositories")
743 .set({
744 description: description?.trim() || null,
745 is_private: is_private === "1" ? 1 : 0,
746 is_pinned: is_pinned === "1" ? 1 : 0,
747 default_branch: newBranch,
748 issue_template: issue_template?.trim() || null,
749 patch_template: patch_template?.trim() || null,
750 })
751 .where("id", "=", repo.id)
752 .execute();
753
754 // Keep git HEAD in sync if the branch actually exists
755 if (branches.includes(newBranch)) {
756 await git.setHead(repo.name, newBranch).catch(() => {});
757 }
758
759 const updated = await db
760 .selectFrom("repositories")
761 .selectAll()
762 .where("id", "=", repo.id)
763 .executeTakeFirstOrThrow();
764 return html(
765 <RepoSettings
766 user={user!}
767 repo={updated}
768 branches={branches}
769 success="Settings saved."
770 />,
771 );
772 },
773 {
774 body: t.Object({
775 description: t.Optional(t.String()),
776 is_private: t.Optional(t.String()),
777 is_pinned: t.Optional(t.String()),
778 default_branch: t.Optional(t.String()),
779 issue_template: t.Optional(t.String()),
780 patch_template: t.Optional(t.String()),
781 }),
782 },
783 )
784
785 .post("/:repo/settings/delete", async ({ params, cookie }) => {
786 const user = await resolveSession(cookie.session.value);
787 const deny = requireAdmin(user);
788 if (deny) return deny;
789 const repo = await getRepo(params.repo, true);
790 if (!repo) return new Response("Not found", { status: 404 });
791
792 // Remove the on-disk repo first. If this fails (e.g. permission error),
793 // we abort before touching the DB so the repo remains accessible.
794 rmSync(repoPath(repo.name), { recursive: true, force: true });
795 await db.deleteFrom("repositories").where("id", "=", repo.id).execute();
796
797 return new Response(null, { status: 302, headers: { Location: "/" } });
798 });
799