branch list & mutations, tag list & mutations, more file mutations

AuthorKonata <konata@posteo.jp>
Date
Commit067e2f886c892e1cf94855e29bfdc606df9ade78
Parent11d9a62
20 files changed, 2172 insertions(+), 57 deletions(-)
Mpackage.json
@@ -1,5 +1,6 @@
11 {
22 "name": "hearthforge",
3+ "version": "1.0",
34 "module": "src/index.tsx",
45 "scripts": {
56 "dev": "bun scripts/dev.ts",
Msrc/constants.ts
@@ -32,6 +32,8 @@ export const COMMITS_PER_PAGE = 20;
3232 export const ISSUES_PER_PAGE = 20;
3333 export const PATCHES_PER_PAGE = 20;
3434 export const RELEASES_PER_PAGE = 20;
35+export const BRANCHES_PER_PAGE = 30;
36+export const TAGS_PER_PAGE = 30;
3537
3638 // Cache sizes
3739 export const MAX_MD_CACHE = 50;
Msrc/routes/repos.tsx
@@ -5,9 +5,11 @@ import { fileTypeFromBuffer } from "file-type";
55 import { sql } from "kysely";
66 import config from "../config.ts";
77 import {
8+ BRANCHES_PER_PAGE,
89 COMMITS_PER_PAGE,
910 paths,
1011 REPOS_PER_PAGE,
12+ TAGS_PER_PAGE,
1113 VALID_REPO_NAME_RE,
1214 } from "../constants.ts";
1315 import { db } from "../db/index.ts";
@@ -22,15 +24,18 @@ import {
2224 import { renderMarkdown } from "../services/markdown.ts";
2325 import { ensureRepoRecord, repoDiskExists } from "../services/repoSync.ts";
2426 import { html } from "../views/render.tsx";
27+import { BranchList } from "../views/repos/BranchList.tsx";
2528 import { CommitDetail } from "../views/repos/CommitDetail.tsx";
2629 import { CommitLog } from "../views/repos/CommitLog.tsx";
2730 import { FileBlob } from "../views/repos/FileBlob.tsx";
2831 import { FileEdit } from "../views/repos/FileEdit.tsx";
2932 import { FileTree } from "../views/repos/FileTree.tsx";
33+import { NewFileForm } from "../views/repos/NewFileForm.tsx";
3034 import { NewRepo } from "../views/repos/NewRepo.tsx";
3135 import { RepoHome } from "../views/repos/RepoHome.tsx";
3236 import { RepoList } from "../views/repos/RepoList.tsx";
3337 import { RepoSettings } from "../views/repos/RepoSettings.tsx";
38+import { TagList } from "../views/repos/TagList.tsx";
3439
3540 async function getRepo(name: string, isAdmin: boolean) {
3641 if (!repoDiskExists(name)) return null;
@@ -280,15 +285,19 @@ export const repoRoutes = new Elysia()
280285 let readmePath: string | undefined;
281286 let entries: Awaited<ReturnType<typeof git.lsTree>> = [];
282287 let branches: string[] = [];
288+ let tags: string[] = [];
283289
284290 if (hasContent) {
285- const [lsResult, branchResult, resolved] = await Promise.all([
286- git.lsTree(repo.name, repo.default_branch),
287- git.branches(repo.name),
288- git.resolveRef(repo.name, repo.default_branch),
289- ]);
291+ const [lsResult, branchResult, tagResult, resolved] =
292+ await Promise.all([
293+ git.lsTree(repo.name, repo.default_branch),
294+ git.branches(repo.name),
295+ git.tags(repo.name),
296+ git.resolveRef(repo.name, repo.default_branch),
297+ ]);
290298 entries = lsResult;
291299 branches = branchResult;
300+ tags = tagResult;
292301 const readme = await readReadme(repo.name, repo.default_branch);
293302 if (readme) {
294303 const key = resolved
@@ -316,6 +325,7 @@ export const repoRoutes = new Elysia()
316325 readmePath={readmePath}
317326 hasContent={hasContent}
318327 branches={branches}
328+ tags={tags}
319329 />,
320330 );
321331 })
@@ -376,9 +386,10 @@ export const repoRoutes = new Elysia()
376386 const resolved = await git.resolveRef(repo.name, params.ref);
377387 if (!resolved) return new Response("Not found", { status: 404 });
378388
379- const [entries, branches] = await Promise.all([
389+ const [entries, branches, tags] = await Promise.all([
380390 git.lsTree(repo.name, params.ref),
381391 git.branches(repo.name),
392+ git.tags(repo.name),
382393 ]);
383394 const readme = await readReadme(repo.name, params.ref);
384395 const readmeHtml = readme
@@ -396,6 +407,7 @@ export const repoRoutes = new Elysia()
396407 subpath=""
397408 entries={entries}
398409 branches={branches}
410+ tags={tags}
399411 readmeHtml={readmeHtml}
400412 readmePath={readme?.filename}
401413 />,
@@ -411,9 +423,10 @@ export const repoRoutes = new Elysia()
411423 if (!resolved) return new Response("Not found", { status: 404 });
412424
413425 const subpath = decodeURIComponent(params["*"]);
414- const [entries, branches] = await Promise.all([
426+ const [entries, branches, tags] = await Promise.all([
415427 git.lsTree(repo.name, params.ref, subpath),
416428 git.branches(repo.name),
429+ git.tags(repo.name),
417430 ]);
418431 if (entries.length === 0) {
419432 // Could be a file — redirect to blob
@@ -440,6 +453,7 @@ export const repoRoutes = new Elysia()
440453 subpath={subpath}
441454 entries={entries}
442455 branches={branches}
456+ tags={tags}
443457 readmeHtml={readmeHtml}
444458 readmePath={readme?.filename}
445459 />,
@@ -452,9 +466,10 @@ export const repoRoutes = new Elysia()
452466 if (!repo) return new Response("Not found", { status: 404 });
453467
454468 const filePath = decodeURIComponent(params["*"]);
455- const [content, branches, commitSHA] = await Promise.all([
469+ const [content, branches, tags, commitSHA] = await Promise.all([
456470 git.show(repo.name, params.ref, filePath),
457471 git.branches(repo.name),
472+ git.tags(repo.name),
458473 git.resolveRef(repo.name, params.ref),
459474 ]);
460475 if (!content || !commitSHA)
@@ -492,6 +507,7 @@ export const repoRoutes = new Elysia()
492507 filePath={filePath}
493508 view={view}
494509 branches={branches}
510+ tags={tags}
495511 markdownHtml={markdownHtml}
496512 />,
497513 );
@@ -539,7 +555,7 @@ export const repoRoutes = new Elysia()
539555 });
540556 })
541557
542- .get("/:repo/edit/:ref/*", async ({ params, cookie }) => {
558+ .get("/:repo/edit/:ref/*", async ({ params, query, cookie }) => {
543559 const user = await resolveSession(cookie.session.value);
544560 const deny = requireAdmin(user);
545561 if (deny) return deny;
@@ -557,6 +573,8 @@ export const repoRoutes = new Elysia()
557573 if (hasBinaryContent(content.subarray(0, 8000)))
558574 return new Response("Not found", { status: 404 });
559575
576+ const queryError =
577+ typeof query.error === "string" ? query.error : undefined;
560578 return html(
561579 <FileEdit
562580 user={user!}
@@ -564,6 +582,7 @@ export const repoRoutes = new Elysia()
564582 ref={params.ref}
565583 filePath={filePath}
566584 content={content.toString("utf-8")}
585+ queryError={queryError}
567586 />,
568587 );
569588 })
@@ -582,8 +601,27 @@ export const repoRoutes = new Elysia()
582601 if (!branches.includes(params.ref))
583602 return new Response("Not found", { status: 404 });
584603
585- const message =
586- body.message?.trim() || `Edited ${path.basename(filePath)}`;
604+ const newPath = body.new_path?.trim() || undefined;
605+ const targetPath =
606+ newPath && newPath !== filePath ? newPath : filePath;
607+
608+ if (
609+ newPath &&
610+ newPath !== filePath &&
611+ (newPath.startsWith("/") ||
612+ newPath.includes("..") ||
613+ newPath.includes("\0"))
614+ ) {
615+ return redirect(
616+ `/${repo.name}/edit/${params.ref}/${filePath}?error=${encodeURIComponent("Invalid file path.")}`,
617+ );
618+ }
619+
620+ const defaultMessage =
621+ targetPath !== filePath
622+ ? `Rename ${path.basename(filePath)} to ${path.basename(targetPath)}`
623+ : `Edited ${path.basename(filePath)}`;
624+ const message = body.message?.trim() || defaultMessage;
587625 const content = (body.content ?? "").replaceAll("\r\n", "\n");
588626
589627 const commit = await git.editFile(
@@ -594,6 +632,7 @@ export const repoRoutes = new Elysia()
594632 message,
595633 config.COMMITTER_NAME,
596634 config.COMMITTER_EMAIL,
635+ newPath,
597636 );
598637
599638 return new Response(null, {
@@ -607,6 +646,7 @@ export const repoRoutes = new Elysia()
607646 body: t.Object({
608647 content: t.Optional(t.String()),
609648 message: t.Optional(t.String()),
649+ new_path: t.Optional(t.String()),
610650 }),
611651 },
612652 )
@@ -625,13 +665,14 @@ export const repoRoutes = new Elysia()
625665 const after = query.after?.trim() || null;
626666 const prev = query.prev?.trim() || null;
627667
628- const [rawCommits, branches] = await Promise.all([
668+ const [rawCommits, branches, tags] = await Promise.all([
629669 // When `after` is set: start at that SHA and skip it (--skip=1 is O(1)),
630670 // then fetch LIMIT+1 to detect whether another page exists.
631671 after
632672 ? git.log(repo.name, after, COMMITS_PER_PAGE + 1, 1)
633673 : git.log(repo.name, params.ref, COMMITS_PER_PAGE + 1, 0),
634674 git.branches(repo.name),
675+ git.tags(repo.name),
635676 ]);
636677
637678 const hasNext = rawCommits.length > COMMITS_PER_PAGE;
@@ -658,6 +699,7 @@ export const repoRoutes = new Elysia()
658699 ref={params.ref}
659700 commits={commits}
660701 branches={branches}
702+ tags={tags}
661703 olderUrl={olderUrl}
662704 newerUrl={newerUrl}
663705 />,
@@ -880,4 +922,452 @@ export const repoRoutes = new Elysia()
880922 {
881923 body: t.Object({ id: t.Numeric() }),
882924 },
925+ )
926+
927+ // ── Branches ──────────────────────────────────────────────────────────────
928+
929+ .get("/:repo/branches", async ({ params, query, cookie }) => {
930+ const user = await resolveSession(cookie.session.value);
931+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
932+ if (!repo) return new Response("Not found", { status: 404 });
933+ const allBranches = await git.branchesWithInfo(repo.name);
934+ const page = Math.max(1, parseInt(String(query.page ?? "1"), 10) || 1);
935+ const totalPages = Math.max(
936+ 1,
937+ Math.ceil(allBranches.length / BRANCHES_PER_PAGE),
938+ );
939+ const safePage = Math.min(page, totalPages);
940+ const branches = allBranches.slice(
941+ (safePage - 1) * BRANCHES_PER_PAGE,
942+ safePage * BRANCHES_PER_PAGE,
943+ );
944+ const success =
945+ typeof query.success === "string" ? query.success : undefined;
946+ const error = typeof query.error === "string" ? query.error : undefined;
947+ return html(
948+ <BranchList
949+ user={user}
950+ repo={repo}
951+ branches={branches}
952+ page={safePage}
953+ totalPages={totalPages}
954+ success={success}
955+ error={error}
956+ />,
957+ );
958+ })
959+
960+ .post(
961+ "/:repo/branches/create",
962+ async ({ params, body, cookie }) => {
963+ const user = await resolveSession(cookie.session.value);
964+ const deny = requireAdmin(user);
965+ if (deny) return deny;
966+ const repo = await getRepo(params.repo, true);
967+ if (!repo) return new Response("Not found", { status: 404 });
968+
969+ const name = body.name?.trim() ?? "";
970+ const sourceRef = body.source_ref?.trim() ?? "";
971+
972+ if (
973+ !name ||
974+ !/^[a-zA-Z0-9._][a-zA-Z0-9._\-/]*$/.test(name) ||
975+ name.includes("..") ||
976+ name.length > 255
977+ ) {
978+ return redirect(
979+ `/${repo.name}/branches?error=${encodeURIComponent("Invalid branch name.")}`,
980+ );
981+ }
982+ if (!sourceRef) {
983+ return redirect(
984+ `/${repo.name}/branches?error=${encodeURIComponent("Source ref is required.")}`,
985+ );
986+ }
987+
988+ const result = await git.createBranch(repo.name, name, sourceRef);
989+ if (result === "ok") {
990+ return redirect(
991+ `/${repo.name}/branches?success=${encodeURIComponent(`Branch "${name}" created.`)}`,
992+ );
993+ }
994+ if (result === "already_exists") {
995+ return redirect(
996+ `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${name}" already exists.`)}`,
997+ );
998+ }
999+ if (result === "bad_ref") {
1000+ return redirect(
1001+ `/${repo.name}/branches?error=${encodeURIComponent(`"${sourceRef}" is not a valid ref.`)}`,
1002+ );
1003+ }
1004+ return redirect(
1005+ `/${repo.name}/branches?error=${encodeURIComponent("Failed to create branch.")}`,
1006+ );
1007+ },
1008+ {
1009+ body: t.Object({
1010+ name: t.String(),
1011+ source_ref: t.String(),
1012+ }),
1013+ },
1014+ )
1015+
1016+ .post(
1017+ "/:repo/branches/delete",
1018+ async ({ params, body, cookie }) => {
1019+ const user = await resolveSession(cookie.session.value);
1020+ const deny = requireAdmin(user);
1021+ if (deny) return deny;
1022+ const repo = await getRepo(params.repo, true);
1023+ if (!repo) return new Response("Not found", { status: 404 });
1024+
1025+ const name = body.name?.trim() ?? "";
1026+ if (!name) {
1027+ return redirect(
1028+ `/${repo.name}/branches?error=${encodeURIComponent("Branch name is required.")}`,
1029+ );
1030+ }
1031+ if (name === repo.default_branch) {
1032+ return redirect(
1033+ `/${repo.name}/branches?error=${encodeURIComponent("Cannot delete the default branch.")}`,
1034+ );
1035+ }
1036+
1037+ const result = await git.deleteBranch(repo.name, name);
1038+ if (result === "ok") {
1039+ return redirect(
1040+ `/${repo.name}/branches?success=${encodeURIComponent(`Branch "${name}" deleted.`)}`,
1041+ );
1042+ }
1043+ if (result === "not_found") {
1044+ return redirect(
1045+ `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${name}" not found.`)}`,
1046+ );
1047+ }
1048+ return redirect(
1049+ `/${repo.name}/branches?error=${encodeURIComponent("Failed to delete branch.")}`,
1050+ );
1051+ },
1052+ {
1053+ body: t.Object({ name: t.String() }),
1054+ },
1055+ )
1056+
1057+ .post(
1058+ "/:repo/branches/rename",
1059+ async ({ params, body, cookie }) => {
1060+ const user = await resolveSession(cookie.session.value);
1061+ const deny = requireAdmin(user);
1062+ if (deny) return deny;
1063+ const repo = await getRepo(params.repo, true);
1064+ if (!repo) return new Response("Not found", { status: 404 });
1065+
1066+ const oldName = body.old_name?.trim() ?? "";
1067+ const newName = body.new_name?.trim() ?? "";
1068+
1069+ if (
1070+ !newName ||
1071+ !/^[a-zA-Z0-9._][a-zA-Z0-9._\-/]*$/.test(newName) ||
1072+ newName.includes("..") ||
1073+ newName.length > 255
1074+ ) {
1075+ return redirect(
1076+ `/${repo.name}/branches?error=${encodeURIComponent("Invalid branch name.")}`,
1077+ );
1078+ }
1079+
1080+ const result = await git.renameBranch(repo.name, oldName, newName);
1081+ if (result === "ok") {
1082+ // Keep default_branch in DB in sync if we renamed it
1083+ if (oldName === repo.default_branch) {
1084+ await db
1085+ .updateTable("repositories")
1086+ .set({ default_branch: newName })
1087+ .where("id", "=", repo.id)
1088+ .execute();
1089+ await git.setHead(repo.name, newName).catch(() => {});
1090+ }
1091+ return redirect(
1092+ `/${repo.name}/branches?success=${encodeURIComponent(`Branch renamed to "${newName}".`)}`,
1093+ );
1094+ }
1095+ if (result === "not_found") {
1096+ return redirect(
1097+ `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${oldName}" not found.`)}`,
1098+ );
1099+ }
1100+ if (result === "already_exists") {
1101+ return redirect(
1102+ `/${repo.name}/branches?error=${encodeURIComponent(`Branch "${newName}" already exists.`)}`,
1103+ );
1104+ }
1105+ return redirect(
1106+ `/${repo.name}/branches?error=${encodeURIComponent("Failed to rename branch.")}`,
1107+ );
1108+ },
1109+ {
1110+ body: t.Object({
1111+ old_name: t.String(),
1112+ new_name: t.String(),
1113+ }),
1114+ },
1115+ )
1116+
1117+ // ── Tags ──────────────────────────────────────────────────────────────────
1118+
1119+ .get("/:repo/tags", async ({ params, query, cookie }) => {
1120+ const user = await resolveSession(cookie.session.value);
1121+ const repo = await getRepo(params.repo, user?.isAdmin ?? false);
1122+ if (!repo) return new Response("Not found", { status: 404 });
1123+ const [allTags, releases] = await Promise.all([
1124+ git.tagsWithInfo(repo.name),
1125+ db
1126+ .selectFrom("releases")
1127+ .select(["id", "tag_name"])
1128+ .where("repo_id", "=", repo.id)
1129+ .where("tag_name", "is not", null)
1130+ .execute(),
1131+ ]);
1132+ const tagReleaseMap = new Map<string, number>();
1133+ for (const r of releases) {
1134+ if (r.tag_name) tagReleaseMap.set(r.tag_name, r.id);
1135+ }
1136+ const page = Math.max(1, parseInt(String(query.page ?? "1"), 10) || 1);
1137+ const totalPages = Math.max(
1138+ 1,
1139+ Math.ceil(allTags.length / TAGS_PER_PAGE),
1140+ );
1141+ const safePage = Math.min(page, totalPages);
1142+ const tags = allTags.slice(
1143+ (safePage - 1) * TAGS_PER_PAGE,
1144+ safePage * TAGS_PER_PAGE,
1145+ );
1146+ const success =
1147+ typeof query.success === "string" ? query.success : undefined;
1148+ const error = typeof query.error === "string" ? query.error : undefined;
1149+ return html(
1150+ <TagList
1151+ user={user}
1152+ repo={repo}
1153+ tags={tags}
1154+ tagReleaseMap={tagReleaseMap}
1155+ page={safePage}
1156+ totalPages={totalPages}
1157+ success={success}
1158+ error={error}
1159+ />,
1160+ );
1161+ })
1162+
1163+ .post(
1164+ "/:repo/tags/create",
1165+ async ({ params, body, cookie }) => {
1166+ const user = await resolveSession(cookie.session.value);
1167+ const deny = requireAdmin(user);
1168+ if (deny) return deny;
1169+ const repo = await getRepo(params.repo, true);
1170+ if (!repo) return new Response("Not found", { status: 404 });
1171+
1172+ const tagName = body.name?.trim() ?? "";
1173+ const ref = body.ref?.trim() ?? "";
1174+ const message = body.message?.trim() || undefined;
1175+
1176+ if (!tagName || !/^[a-zA-Z0-9._\-+]+$/.test(tagName)) {
1177+ return redirect(
1178+ `/${repo.name}/tags?error=${encodeURIComponent("Invalid tag name.")}`,
1179+ );
1180+ }
1181+ if (!ref) {
1182+ return redirect(
1183+ `/${repo.name}/tags?error=${encodeURIComponent("Target ref is required.")}`,
1184+ );
1185+ }
1186+
1187+ const result = await git.createTag(
1188+ repo.name,
1189+ tagName,
1190+ ref,
1191+ message,
1192+ message ? config.COMMITTER_NAME : undefined,
1193+ message ? config.COMMITTER_EMAIL : undefined,
1194+ );
1195+ if (result === "ok") {
1196+ return redirect(
1197+ `/${repo.name}/tags?success=${encodeURIComponent(`Tag "${tagName}" created.`)}`,
1198+ );
1199+ }
1200+ if (result === "already_exists") {
1201+ return redirect(
1202+ `/${repo.name}/tags?error=${encodeURIComponent(`Tag "${tagName}" already exists.`)}`,
1203+ );
1204+ }
1205+ if (result === "bad_ref") {
1206+ return redirect(
1207+ `/${repo.name}/tags?error=${encodeURIComponent(`"${ref}" is not a valid ref.`)}`,
1208+ );
1209+ }
1210+ return redirect(
1211+ `/${repo.name}/tags?error=${encodeURIComponent("Failed to create tag.")}`,
1212+ );
1213+ },
1214+ {
1215+ body: t.Object({
1216+ name: t.String(),
1217+ ref: t.String(),
1218+ message: t.Optional(t.String()),
1219+ }),
1220+ },
1221+ )
1222+
1223+ .post(
1224+ "/:repo/tags/delete",
1225+ async ({ params, body, cookie }) => {
1226+ const user = await resolveSession(cookie.session.value);
1227+ const deny = requireAdmin(user);
1228+ if (deny) return deny;
1229+ const repo = await getRepo(params.repo, true);
1230+ if (!repo) return new Response("Not found", { status: 404 });
1231+
1232+ const tagName = body.name?.trim() ?? "";
1233+ if (!tagName) {
1234+ return redirect(
1235+ `/${repo.name}/tags?error=${encodeURIComponent("Tag name is required.")}`,
1236+ );
1237+ }
1238+
1239+ const result = await git.deleteTag(repo.name, tagName);
1240+ if (result === "ok") {
1241+ return redirect(
1242+ `/${repo.name}/tags?success=${encodeURIComponent(`Tag "${tagName}" deleted.`)}`,
1243+ );
1244+ }
1245+ if (result === "not_found") {
1246+ return redirect(
1247+ `/${repo.name}/tags?error=${encodeURIComponent(`Tag "${tagName}" not found.`)}`,
1248+ );
1249+ }
1250+ return redirect(
1251+ `/${repo.name}/tags?error=${encodeURIComponent("Failed to delete tag.")}`,
1252+ );
1253+ },
1254+ {
1255+ body: t.Object({ name: t.String() }),
1256+ },
1257+ )
1258+
1259+ // ── File creation ─────────────────────────────────────────────────────────
1260+
1261+ .get("/:repo/new-file/:ref", async ({ params, query, cookie }) => {
1262+ const user = await resolveSession(cookie.session.value);
1263+ const deny = requireAdmin(user);
1264+ if (deny) return deny;
1265+ const repo = await getRepo(params.repo, true);
1266+ if (!repo) return new Response("Not found", { status: 404 });
1267+ const dir = typeof query.dir === "string" ? query.dir : "";
1268+ const error = typeof query.error === "string" ? query.error : undefined;
1269+ return html(
1270+ <NewFileForm
1271+ user={user!}
1272+ repo={repo}
1273+ ref={params.ref}
1274+ dir={dir}
1275+ error={error}
1276+ />,
1277+ );
1278+ })
1279+
1280+ .post(
1281+ "/:repo/new-file/:ref",
1282+ async ({ params, body, cookie }) => {
1283+ const user = await resolveSession(cookie.session.value);
1284+ const deny = requireAdmin(user);
1285+ if (deny) return deny;
1286+ const repo = await getRepo(params.repo, true);
1287+ if (!repo) return new Response("Not found", { status: 404 });
1288+
1289+ const filePath = body.path?.trim() ?? "";
1290+ const content = body.content ?? "";
1291+ const message = body.message?.trim() || `Add ${filePath}`;
1292+
1293+ if (
1294+ !filePath ||
1295+ filePath.startsWith("/") ||
1296+ filePath.includes("..") ||
1297+ filePath.includes("\0")
1298+ ) {
1299+ return redirect(
1300+ `/${repo.name}/new-file/${params.ref}?error=${encodeURIComponent("Invalid file path.")}`,
1301+ );
1302+ }
1303+
1304+ // Ensure we're on a branch
1305+ const branches = await git.branches(repo.name);
1306+ if (branches.length > 0 && !branches.includes(params.ref)) {
1307+ return redirect(
1308+ `/${repo.name}/new-file/${params.ref}?error=${encodeURIComponent("Can only create files on a branch.")}`,
1309+ );
1310+ }
1311+
1312+ try {
1313+ const commit = await git.createFile(
1314+ repo.name,
1315+ params.ref,
1316+ filePath,
1317+ content,
1318+ message,
1319+ config.COMMITTER_NAME,
1320+ config.COMMITTER_EMAIL,
1321+ );
1322+ return redirect(`/${repo.name}/commit/${commit}`);
1323+ } catch {
1324+ return redirect(
1325+ `/${repo.name}/new-file/${params.ref}?error=${encodeURIComponent("Failed to create file.")}`,
1326+ );
1327+ }
1328+ },
1329+ {
1330+ body: t.Object({
1331+ path: t.String(),
1332+ content: t.Optional(t.String()),
1333+ message: t.Optional(t.String()),
1334+ }),
1335+ },
1336+ )
1337+
1338+ // ── File deletion ─────────────────────────────────────────────────────────
1339+
1340+ .post(
1341+ "/:repo/delete-file/:ref/*",
1342+ async ({ params, body, cookie }) => {
1343+ const user = await resolveSession(cookie.session.value);
1344+ const deny = requireAdmin(user);
1345+ if (deny) return deny;
1346+ const repo = await getRepo(params.repo, true);
1347+ if (!repo) return new Response("Not found", { status: 404 });
1348+
1349+ const filePath = decodeURIComponent(params["*"]);
1350+ const message = body.message?.trim() || `Delete ${filePath}`;
1351+
1352+ try {
1353+ const commit = await git.deleteFile(
1354+ repo.name,
1355+ params.ref,
1356+ filePath,
1357+ message,
1358+ config.COMMITTER_NAME,
1359+ config.COMMITTER_EMAIL,
1360+ );
1361+ return redirect(`/${repo.name}/commit/${commit}`);
1362+ } catch {
1363+ return redirect(
1364+ `/${repo.name}/blob/${params.ref}/${filePath}?error=${encodeURIComponent("Failed to delete file.")}`,
1365+ );
1366+ }
1367+ },
1368+ {
1369+ body: t.Object({
1370+ message: t.Optional(t.String()),
1371+ }),
1372+ },
8831373 );
Msrc/services/git.ts
@@ -193,6 +193,23 @@ export function extractPatchSubject(patch: string): string {
193193 return "";
194194 }
195195
196+export interface BranchInfo {
197+ name: string;
198+ shortHash: string;
199+ subject: string;
200+ authorName: string;
201+ date: string;
202+}
203+
204+export interface TagInfo {
205+ name: string;
206+ shortHash: string;
207+ subject: string;
208+ taggerName: string;
209+ date: string;
210+ isAnnotated: boolean;
211+}
212+
196213 export interface PatchMeta {
197214 subject: string;
198215 body: string;
@@ -330,7 +347,7 @@ export const git = {
330347 async diff(name: string, sha: string): Promise<string> {
331348 const p = repoPath(name);
332349 try {
333- return await $`git -C ${p} diff-tree --no-commit-id -r -p --root ${sha}`.text();
350+ return await $`git -C ${p} diff-tree --no-commit-id -r -p -M --root ${sha}`.text();
334351 } catch {
335352 return "";
336353 }
@@ -359,6 +376,77 @@ export const git = {
359376 }
360377 },
361378
379+ async tags(name: string): Promise<string[]> {
380+ const p = repoPath(name);
381+ try {
382+ const fmt = "%(refname:short)";
383+ const out =
384+ await $`git -C ${p} for-each-ref --format=${fmt} refs/tags/`.text();
385+ return out.split("\n").filter(Boolean);
386+ } catch {
387+ return [];
388+ }
389+ },
390+
391+ async branchesWithInfo(name: string): Promise<BranchInfo[]> {
392+ const p = repoPath(name);
393+ try {
394+ // Use actual unit separator byte (\x1f) — git for-each-ref does not
395+ // support the %x1f hex escape (that is a git-log pretty-format feature).
396+ const sep = "\x1f";
397+ const fmt = `%(refname:short)${sep}%(objectname:short)${sep}%(contents:subject)${sep}%(authorname)${sep}%(authordate:iso8601)`;
398+ const out =
399+ await $`git -C ${p} for-each-ref --format=${fmt} refs/heads/`.text();
400+ return out
401+ .split("\n")
402+ .filter(Boolean)
403+ .map((line) => {
404+ const parts = line.split(sep);
405+ return {
406+ name: parts[0] ?? "",
407+ shortHash: parts[1] ?? "",
408+ subject: parts[2] ?? "",
409+ authorName: parts[3] ?? "",
410+ date: parts[4] ?? "",
411+ };
412+ });
413+ } catch {
414+ return [];
415+ }
416+ },
417+
418+ async tagsWithInfo(name: string): Promise<TagInfo[]> {
419+ const p = repoPath(name);
420+ try {
421+ // Use actual unit separator byte (\x1f) — git for-each-ref does not
422+ // support the %x1f hex escape (that is a git-log pretty-format feature).
423+ // %(*objectname:short) is the dereferenced commit for annotated tags; empty for lightweight.
424+ const sep = "\x1f";
425+ const fmt = `%(refname:short)${sep}%(*objectname:short)${sep}%(objectname:short)${sep}%(contents:subject)${sep}%(taggername)${sep}%(creatordate:iso8601)`;
426+ const out =
427+ await $`git -C ${p} for-each-ref --format=${fmt} refs/tags/`.text();
428+ return out
429+ .split("\n")
430+ .filter(Boolean)
431+ .map((line) => {
432+ const parts = line.split(sep);
433+ const derefHash = (parts[1] ?? "").trim();
434+ const ownHash = (parts[2] ?? "").trim();
435+ const isAnnotated = derefHash.length > 0;
436+ return {
437+ name: parts[0] ?? "",
438+ shortHash: isAnnotated ? derefHash : ownHash,
439+ subject: parts[3] ?? "",
440+ taggerName: parts[4] ?? "",
441+ date: parts[5] ?? "",
442+ isAnnotated,
443+ };
444+ });
445+ } catch {
446+ return [];
447+ }
448+ },
449+
362450 async defaultBranch(name: string): Promise<string> {
363451 const p = repoPath(name);
364452 try {
@@ -492,18 +580,188 @@ export const git = {
492580 message: string,
493581 committerName: string,
494582 committerEmail: string,
583+ newPath?: string,
495584 ): Promise<string> {
496585 return withRepoLock(name, async () => {
586+ const targetPath =
587+ newPath && newPath !== filePath ? newPath : filePath;
588+ const isMove = targetPath !== filePath;
497589 const p = repoPath(name);
498590 const tmpFile = `/tmp/hf-edit-${Date.now()}-${Math.random().toString(36).slice(2)}`;
499591 try {
500592 await Bun.write(tmpFile, content);
501- await $`git -C ${p} read-tree refs/heads/${branch}`;
593+ if (isMove) {
594+ await $`git --work-tree=/tmp -C ${p} read-tree refs/heads/${branch}`;
595+ } else {
596+ await $`git -C ${p} read-tree refs/heads/${branch}`;
597+ }
598+ const blobHash = (
599+ await $`git -C ${p} hash-object -w ${tmpFile}`.text()
600+ ).trim();
601+ if (isMove) {
602+ await $`git --work-tree=/tmp -C ${p} update-index --remove ${filePath}`;
603+ }
604+ await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${targetPath}`;
605+ const tree = isMove
606+ ? (
607+ await $`git --work-tree=/tmp -C ${p} write-tree`.text()
608+ ).trim()
609+ : (await $`git -C ${p} write-tree`.text()).trim();
610+ const parent = (
611+ await $`git -C ${p} rev-parse refs/heads/${branch}`.text()
612+ ).trim();
613+ const sigArgs = [
614+ "-c",
615+ "gpg.format=ssh",
616+ "-c",
617+ `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
618+ ];
619+ const commit = (
620+ await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${message}`
621+ .env({
622+ ...gitEnv,
623+ GIT_AUTHOR_NAME: committerName,
624+ GIT_AUTHOR_EMAIL: committerEmail,
625+ GIT_COMMITTER_NAME: committerName,
626+ GIT_COMMITTER_EMAIL: committerEmail,
627+ })
628+ .text()
629+ ).trim();
630+ await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
631+ return commit;
632+ } finally {
633+ await $`rm -f ${tmpFile}`.quiet().nothrow();
634+ }
635+ });
636+ },
637+
638+ async createFile(
639+ name: string,
640+ branch: string,
641+ filePath: string,
642+ content: string,
643+ message: string,
644+ committerName: string,
645+ committerEmail: string,
646+ ): Promise<string> {
647+ return withRepoLock(name, async () => {
648+ const p = repoPath(name);
649+ const tmpFile = `/tmp/hf-new-${Date.now()}-${Math.random().toString(36).slice(2)}`;
650+ try {
651+ await Bun.write(tmpFile, content);
652+ const parentSha = await git.resolveRef(
653+ name,
654+ `refs/heads/${branch}`,
655+ );
656+ if (parentSha) {
657+ await $`git -C ${p} read-tree refs/heads/${branch}`;
658+ }
502659 const blobHash = (
503660 await $`git -C ${p} hash-object -w ${tmpFile}`.text()
504661 ).trim();
505662 await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${filePath}`;
506663 const tree = (await $`git -C ${p} write-tree`.text()).trim();
664+ const sigArgs = [
665+ "-c",
666+ "gpg.format=ssh",
667+ "-c",
668+ `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
669+ ];
670+ const commitEnv = {
671+ ...gitEnv,
672+ GIT_AUTHOR_NAME: committerName,
673+ GIT_AUTHOR_EMAIL: committerEmail,
674+ GIT_COMMITTER_NAME: committerName,
675+ GIT_COMMITTER_EMAIL: committerEmail,
676+ };
677+ const commit = parentSha
678+ ? (
679+ await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parentSha} -m ${message}`
680+ .env(commitEnv)
681+ .text()
682+ ).trim()
683+ : (
684+ await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -m ${message}`
685+ .env(commitEnv)
686+ .text()
687+ ).trim();
688+ await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
689+ return commit;
690+ } finally {
691+ await $`rm -f ${tmpFile}`.quiet().nothrow();
692+ }
693+ });
694+ },
695+
696+ async deleteFile(
697+ name: string,
698+ branch: string,
699+ filePath: string,
700+ message: string,
701+ committerName: string,
702+ committerEmail: string,
703+ ): Promise<string> {
704+ return withRepoLock(name, async () => {
705+ const p = repoPath(name);
706+ // --work-tree=/tmp is needed because bare repos have no work tree and
707+ // `update-index --remove` requires one (even though it only touches the index).
708+ await $`git --work-tree=/tmp -C ${p} read-tree refs/heads/${branch}`;
709+ await $`git --work-tree=/tmp -C ${p} update-index --remove ${filePath}`;
710+ const tree = (
711+ await $`git --work-tree=/tmp -C ${p} write-tree`.text()
712+ ).trim();
713+ const parent = (
714+ await $`git -C ${p} rev-parse refs/heads/${branch}`.text()
715+ ).trim();
716+ const sigArgs = [
717+ "-c",
718+ "gpg.format=ssh",
719+ "-c",
720+ `user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
721+ ];
722+ const commit = (
723+ await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${message}`
724+ .env({
725+ ...gitEnv,
726+ GIT_AUTHOR_NAME: committerName,
727+ GIT_AUTHOR_EMAIL: committerEmail,
728+ GIT_COMMITTER_NAME: committerName,
729+ GIT_COMMITTER_EMAIL: committerEmail,
730+ })
731+ .text()
732+ ).trim();
733+ await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
734+ return commit;
735+ });
736+ },
737+
738+ async moveFile(
739+ name: string,
740+ branch: string,
741+ oldPath: string,
742+ newPath: string,
743+ message: string,
744+ committerName: string,
745+ committerEmail: string,
746+ ): Promise<string> {
747+ return withRepoLock(name, async () => {
748+ const p = repoPath(name);
749+ const tmpFile = `/tmp/hf-move-${Date.now()}-${Math.random().toString(36).slice(2)}`;
750+ try {
751+ const contentBuf =
752+ await $`git -C ${p} show ${`${branch}:${oldPath}`}`.arrayBuffer();
753+ await Bun.write(tmpFile, contentBuf);
754+ // --work-tree=/tmp is needed because bare repos have no work tree and
755+ // `update-index --remove` requires one (even though it only touches the index).
756+ await $`git --work-tree=/tmp -C ${p} read-tree refs/heads/${branch}`;
757+ const blobHash = (
758+ await $`git -C ${p} hash-object -w ${tmpFile}`.text()
759+ ).trim();
760+ await $`git --work-tree=/tmp -C ${p} update-index --remove ${oldPath}`;
761+ await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${newPath}`;
762+ const tree = (
763+ await $`git --work-tree=/tmp -C ${p} write-tree`.text()
764+ ).trim();
507765 const parent = (
508766 await $`git -C ${p} rev-parse refs/heads/${branch}`.text()
509767 ).trim();
@@ -571,6 +829,93 @@ export const git = {
571829 });
572830 },
573831
832+ async createBranch(
833+ name: string,
834+ branchName: string,
835+ sourceRef: string,
836+ ): Promise<"ok" | "already_exists" | "bad_ref" | "error"> {
837+ return withRepoLock(name, async () => {
838+ const p = repoPath(name);
839+ try {
840+ const sha = await git.resolveRef(name, sourceRef);
841+ if (!sha) return "bad_ref";
842+ const exists = await git.resolveRef(
843+ name,
844+ `refs/heads/${branchName}`,
845+ );
846+ if (exists) return "already_exists";
847+ await $`git -C ${p} update-ref refs/heads/${branchName} ${sha}`;
848+ return "ok";
849+ } catch {
850+ return "error";
851+ }
852+ });
853+ },
854+
855+ async deleteBranch(
856+ name: string,
857+ branchName: string,
858+ ): Promise<"ok" | "not_found" | "error"> {
859+ return withRepoLock(name, async () => {
860+ const p = repoPath(name);
861+ try {
862+ const exists = await git.resolveRef(
863+ name,
864+ `refs/heads/${branchName}`,
865+ );
866+ if (!exists) return "not_found";
867+ await $`git -C ${p} update-ref -d refs/heads/${branchName}`;
868+ return "ok";
869+ } catch {
870+ return "error";
871+ }
872+ });
873+ },
874+
875+ async renameBranch(
876+ name: string,
877+ oldName: string,
878+ newName: string,
879+ ): Promise<"ok" | "not_found" | "already_exists" | "error"> {
880+ return withRepoLock(name, async () => {
881+ const p = repoPath(name);
882+ try {
883+ const sha = await git.resolveRef(name, `refs/heads/${oldName}`);
884+ if (!sha) return "not_found";
885+ const exists = await git.resolveRef(
886+ name,
887+ `refs/heads/${newName}`,
888+ );
889+ if (exists) return "already_exists";
890+ await $`git -C ${p} update-ref refs/heads/${newName} ${sha}`;
891+ await $`git -C ${p} update-ref -d refs/heads/${oldName}`;
892+ return "ok";
893+ } catch {
894+ return "error";
895+ }
896+ });
897+ },
898+
899+ async deleteTag(
900+ name: string,
901+ tagName: string,
902+ ): Promise<"ok" | "not_found" | "error"> {
903+ return withRepoLock(name, async () => {
904+ const p = repoPath(name);
905+ try {
906+ const exists = await git.resolveRef(
907+ name,
908+ `refs/tags/${tagName}`,
909+ );
910+ if (!exists) return "not_found";
911+ await $`git -C ${p} tag -d ${tagName}`;
912+ return "ok";
913+ } catch {
914+ return "error";
915+ }
916+ });
917+ },
918+
574919 async setHead(name: string, branch: string): Promise<void> {
575920 const p = repoPath(name);
576921 await $`git -C ${p} symbolic-ref HEAD refs/heads/${branch}`;
Msrc/styles/code.css
@@ -72,6 +72,8 @@
7272 display: flex;
7373 align-items: center;
7474 justify-content: space-between;
75+ flex-wrap: wrap;
76+ gap: var(--space-2);
7577 padding: var(--space-3) var(--space-4);
7678 background: var(--color-bg-subtle);
7779 border: 1px solid var(--color-border);
@@ -81,12 +83,19 @@
8183 .file-blob-name {
8284 font-size: var(--text-sm);
8385 font-weight: 500;
86+ min-width: 0;
87+ overflow: hidden;
88+ text-overflow: ellipsis;
89+ white-space: nowrap;
8490 }
8591 .file-blob-actions {
8692 display: flex;
8793 align-items: center;
8894 gap: var(--space-2);
8995 }
96+ .file-blob-actions-wrap {
97+ flex-wrap: wrap;
98+ }
9099 .file-blob-body {
91100 border: 1px solid var(--color-border);
92101 border-top: none;
Msrc/styles/components.css
@@ -376,6 +376,14 @@
376376 border-color: var(--color-accent);
377377 background: var(--color-accent-bg);
378378 }
379+ .badge-release {
380+ color: var(--color-success);
381+ border-color: var(--color-success);
382+ background: var(--color-success-bg);
383+ }
384+ .badge-release:hover {
385+ text-decoration: underline;
386+ }
379387 .issue-badge,
380388 .patch-badge {
381389 display: inline-flex;
@@ -444,6 +452,8 @@
444452 .breadcrumb a {
445453 color: var(--color-link);
446454 text-decoration: none;
455+ text-overflow: ellipsis;
456+ overflow: hidden;
447457 }
448458 .breadcrumb a:hover {
449459 text-decoration: underline;
@@ -453,6 +463,8 @@
453463 }
454464 .breadcrumb-current {
455465 color: var(--color-text-muted);
466+ text-overflow: ellipsis;
467+ overflow: hidden;
456468 }
457469
458470 /* --- Issue / Patch list --- */
@@ -1796,4 +1808,121 @@
17961808 display: flex;
17971809 gap: var(--space-2);
17981810 }
1811+
1812+ /* --- list-heading (inside list-header) --- */
1813+ .list-heading {
1814+ font-size: var(--text-lg);
1815+ font-weight: 600;
1816+ margin: 0;
1817+ }
1818+
1819+ /* --- Branch / Tag ref list --- */
1820+ .ref-list {
1821+ border: 1px solid var(--color-border);
1822+ border-top: none;
1823+ border-radius: 0 0 var(--radius-lg) var(--radius-lg);
1824+ }
1825+ .ref-item {
1826+ padding: var(--space-3) var(--space-4);
1827+ border-bottom: 1px solid var(--color-border-muted);
1828+ }
1829+ .ref-item:last-child {
1830+ border-bottom: none;
1831+ }
1832+ .ref-name-row {
1833+ display: flex;
1834+ align-items: center;
1835+ gap: var(--space-2);
1836+ margin-bottom: var(--space-1);
1837+ }
1838+ .ref-name {
1839+ font-size: var(--text-sm);
1840+ font-weight: 600;
1841+ color: var(--color-text);
1842+ text-decoration: none;
1843+ }
1844+ .ref-name:hover {
1845+ color: var(--color-link);
1846+ }
1847+ .ref-meta-row {
1848+ display: flex;
1849+ align-items: center;
1850+ flex-wrap: wrap;
1851+ gap: var(--space-2) var(--space-3);
1852+ font-size: var(--text-xs);
1853+ color: var(--color-text-muted);
1854+ min-width: 0;
1855+ }
1856+ .ref-author {
1857+ white-space: nowrap;
1858+ }
1859+ .ref-hash {
1860+ font-family: var(--font-mono);
1861+ font-size: var(--text-xs);
1862+ color: var(--color-link);
1863+ text-decoration: none;
1864+ background: var(--color-bg-inset);
1865+ padding: 1px var(--space-2);
1866+ border-radius: var(--radius-sm);
1867+ white-space: nowrap;
1868+ flex-shrink: 0;
1869+ }
1870+ .ref-hash:hover {
1871+ text-decoration: underline;
1872+ }
1873+ .ref-subject {
1874+ flex: 1;
1875+ min-width: 60px;
1876+ overflow: hidden;
1877+ text-overflow: ellipsis;
1878+ white-space: nowrap;
1879+ }
1880+ .ref-date {
1881+ white-space: nowrap;
1882+ flex-shrink: 0;
1883+ }
1884+ .ref-actions {
1885+ display: flex;
1886+ align-items: center;
1887+ gap: var(--space-2);
1888+ margin-left: auto;
1889+ flex-shrink: 0;
1890+ }
1891+
1892+ /* --- Inline confirm-details (for row-level actions) --- */
1893+ .confirm-details-inline {
1894+ position: relative;
1895+ display: inline-block;
1896+ }
1897+
1898+ /* --- Popup form (for new branch / new tag popups) --- */
1899+ .confirm-popup-form {
1900+ min-width: 260px;
1901+ }
1902+ .popup-form {
1903+ display: flex;
1904+ flex-direction: column;
1905+ gap: var(--space-3);
1906+ }
1907+ .popup-form .form-group {
1908+ margin-bottom: 0;
1909+ }
1910+ .popup-form input[type="text"] {
1911+ width: 100%;
1912+ box-sizing: border-box;
1913+ }
1914+
1915+ /* --- Confirm warning (tag linked to release) --- */
1916+ .confirm-warning {
1917+ font-size: var(--text-xs);
1918+ color: var(--color-warning);
1919+ margin: 0 0 var(--space-2);
1920+ }
1921+
1922+ /* --- Tree toolbar actions group --- */
1923+ .tree-toolbar-actions {
1924+ display: flex;
1925+ align-items: center;
1926+ gap: var(--space-2);
1927+ }
17991928 }
Msrc/views/issues/IssueDetail.tsx
@@ -79,7 +79,9 @@ export function IssueDetail({
7979 name="title"
8080 value={issue.title}
8181 required
82- maxlength={config.MAX_TITLE_BYTES}
82+ maxlength={
83+ config.MAX_TITLE_BYTES
84+ }
8385 />
8486 <input
8587 type="hidden"
@@ -292,7 +294,9 @@ export function IssueDetail({
292294 id="edit-issue-body"
293295 name="edit_body"
294296 rows="6"
295- maxlength={config.MAX_TEXT_BODY_BYTES}
297+ maxlength={
298+ config.MAX_TEXT_BODY_BYTES
299+ }
296300 >
297301 {issue.body}
298302 </textarea>
@@ -364,7 +368,9 @@ export function IssueDetail({
364368 class="form-input"
365369 name="edit_body"
366370 rows="6"
367- maxlength={config.MAX_TEXT_BODY_BYTES}
371+ maxlength={
372+ config.MAX_TEXT_BODY_BYTES
373+ }
368374 >
369375 {comment.body}
370376 </textarea>
Msrc/views/patches/NewPatch.tsx
@@ -52,7 +52,12 @@ export function NewPatch({
5252 (Markdown supported, optional)
5353 </span>
5454 </label>
55- <textarea id="description" name="description" rows="5" maxlength={config.MAX_TEXT_BODY_BYTES}>
55+ <textarea
56+ id="description"
57+ name="description"
58+ rows="5"
59+ maxlength={config.MAX_TEXT_BODY_BYTES}
60+ >
5661 {template ?? ""}
5762 </textarea>
5863 </div>
Msrc/views/patches/PatchDetail.tsx
@@ -100,7 +100,9 @@ export function PatchDetail({
100100 name="title"
101101 value={patch.title}
102102 required
103- maxlength={config.MAX_TITLE_BYTES}
103+ maxlength={
104+ config.MAX_TITLE_BYTES
105+ }
104106 />
105107 <input
106108 type="hidden"
@@ -374,7 +376,9 @@ export function PatchDetail({
374376 id="edit-patch-desc"
375377 name="edit_description"
376378 rows="6"
377- maxlength={config.MAX_TEXT_BODY_BYTES}
379+ maxlength={
380+ config.MAX_TEXT_BODY_BYTES
381+ }
378382 >
379383 {patch.description}
380384 </textarea>
@@ -474,7 +478,9 @@ export function PatchDetail({
474478 class="form-input"
475479 name="edit_body"
476480 rows="6"
477- maxlength={config.MAX_TEXT_BODY_BYTES}
481+ maxlength={
482+ config.MAX_TEXT_BODY_BYTES
483+ }
478484 >
479485 {comment.body}
480486 </textarea>
@@ -515,7 +521,9 @@ export function PatchDetail({
515521 <textarea
516522 name="body"
517523 rows="6"
518- maxlength={config.MAX_TEXT_BODY_BYTES}
524+ maxlength={
525+ config.MAX_TEXT_BODY_BYTES
526+ }
519527 placeholder="Leave a comment (Markdown supported)"
520528 required
521529 />
Asrc/views/repos/BranchList.tsx
@@ -0,0 +1,213 @@
1+import type { RepositoryRow } from "../../db/index.ts";
2+import { formatDateTime } from "../../lib/formatDate.ts";
3+import type { SessionUser } from "../../middleware/session.ts";
4+import type { BranchInfo } from "../../services/git.ts";
5+import { Layout } from "../layout.tsx";
6+import { Pagination } from "../Pagination.tsx";
7+import { RepoHeader } from "./RepoHeader.tsx";
8+import { RepoNav } from "./RepoNav.tsx";
9+
10+interface BranchListProps {
11+ user: SessionUser | null;
12+ repo: RepositoryRow;
13+ branches: BranchInfo[];
14+ page: number;
15+ totalPages: number;
16+ success?: string;
17+ error?: string;
18+}
19+
20+export function BranchList({
21+ user,
22+ repo,
23+ branches,
24+ page,
25+ totalPages,
26+ success,
27+ error,
28+}: BranchListProps) {
29+ return (
30+ <Layout user={user} title={`Branches — ${repo.name}`}>
31+ <div class="container">
32+ <RepoHeader repo={repo} />
33+ <RepoNav repo={repo} active="branches" user={user} />
34+ {success && <p class="form-success">{success}</p>}
35+ {error && <p class="form-error">{error}</p>}
36+ <div class="list-header">
37+ <h2 class="list-heading">Branches</h2>
38+ {user?.isAdmin && (
39+ <details class="confirm-details">
40+ <summary class="btn btn-primary btn-sm">
41+ New branch
42+ </summary>
43+ <div class="confirm-popup confirm-popup-form">
44+ <form
45+ method="POST"
46+ action={`/${repo.name}/branches/create`}
47+ class="popup-form"
48+ >
49+ <div class="form-group">
50+ <label for="new-branch-name">
51+ Branch name
52+ </label>
53+ <input
54+ id="new-branch-name"
55+ name="name"
56+ type="text"
57+ required
58+ placeholder="feature/my-branch"
59+ maxlength="255"
60+ />
61+ </div>
62+ <div class="form-group">
63+ <label for="new-branch-source">
64+ From
65+ </label>
66+ <input
67+ id="new-branch-source"
68+ name="source_ref"
69+ type="text"
70+ required
71+ value={repo.default_branch}
72+ placeholder="branch, tag, or commit"
73+ maxlength="255"
74+ />
75+ </div>
76+ <button
77+ type="submit"
78+ class="btn btn-primary btn-sm"
79+ >
80+ Create branch
81+ </button>
82+ </form>
83+ </div>
84+ </details>
85+ )}
86+ </div>
87+ {branches.length === 0 ? (
88+ <div class="empty-state">
89+ <p>No branches yet.</p>
90+ </div>
91+ ) : (
92+ <ul class="ref-list">
93+ {branches.map((b) => (
94+ <li class="ref-item">
95+ <div class="ref-name-row">
96+ <a
97+ href={`/${repo.name}/tree/${b.name}`}
98+ class="ref-name"
99+ >
100+ {b.name}
101+ </a>
102+ {b.name === repo.default_branch && (
103+ <span class="badge">default</span>
104+ )}
105+ </div>
106+ <div class="ref-meta-row">
107+ {b.authorName && (
108+ <span class="ref-author">
109+ {b.authorName}
110+ </span>
111+ )}
112+ {b.shortHash && (
113+ <a
114+ href={`/${repo.name}/commit/${b.shortHash}`}
115+ class="ref-hash mono"
116+ >
117+ {b.shortHash}
118+ </a>
119+ )}
120+ {b.subject && (
121+ <span class="ref-subject">
122+ {b.subject}
123+ </span>
124+ )}
125+ {b.date && (
126+ <time
127+ class="ref-date"
128+ datetime={b.date}
129+ >
130+ {formatDateTime(b.date)}
131+ </time>
132+ )}
133+ {user?.isAdmin && (
134+ <span class="ref-actions">
135+ <details class="confirm-details confirm-details-inline">
136+ <summary class="btn btn-sm">
137+ Rename
138+ </summary>
139+ <div class="confirm-popup">
140+ <form
141+ method="POST"
142+ action={`/${repo.name}/branches/rename`}
143+ class="popup-form"
144+ >
145+ <input
146+ type="hidden"
147+ name="old_name"
148+ value={b.name}
149+ />
150+ <input
151+ name="new_name"
152+ type="text"
153+ required
154+ placeholder="new-name"
155+ value={b.name}
156+ maxlength="255"
157+ />
158+ <button
159+ type="submit"
160+ class="btn btn-sm btn-primary"
161+ >
162+ Rename
163+ </button>
164+ </form>
165+ </div>
166+ </details>
167+ {b.name !== repo.default_branch && (
168+ <details class="confirm-details confirm-details-inline">
169+ <summary class="btn btn-sm btn-danger">
170+ Delete
171+ </summary>
172+ <div class="confirm-popup">
173+ Delete branch{" "}
174+ <strong>
175+ {b.name}
176+ </strong>
177+ ?
178+ <form
179+ method="POST"
180+ action={`/${repo.name}/branches/delete`}
181+ class="inline-form"
182+ >
183+ <input
184+ type="hidden"
185+ name="name"
186+ value={b.name}
187+ />
188+ <button
189+ type="submit"
190+ class="btn btn-sm btn-danger"
191+ >
192+ Yes, delete
193+ </button>
194+ </form>
195+ </div>
196+ </details>
197+ )}
198+ </span>
199+ )}
200+ </div>
201+ </li>
202+ ))}
203+ </ul>
204+ )}
205+ <Pagination
206+ page={page}
207+ totalPages={totalPages}
208+ pageUrlTemplate={`/${repo.name}/branches?page={page}`}
209+ />
210+ </div>
211+ </Layout>
212+ );
213+}
Msrc/views/repos/BranchSelector.tsx
@@ -1,6 +1,7 @@
11 interface BranchSelectorProps {
22 repoName: string;
33 branches: string[];
4+ tags?: string[];
45 currentRef: string;
56 view: "tree" | "commits" | "blob";
67 /** subpath for tree, full file path for blob */
@@ -10,12 +11,14 @@ interface BranchSelectorProps {
1011 export function BranchSelector({
1112 repoName,
1213 branches,
14+ tags = [],
1315 currentRef,
1416 view,
1517 path,
1618 }: BranchSelectorProps) {
17- if (branches.length === 0) return "";
18- const isDetached = !branches.includes(currentRef);
19+ if (branches.length === 0 && tags.length === 0) return "";
20+ const isDetached =
21+ !branches.includes(currentRef) && !tags.includes(currentRef);
1922 const shortRef =
2023 isDetached && currentRef.length > 8
2124 ? currentRef.slice(0, 8)
@@ -39,14 +42,28 @@ export function BranchSelector({
3942 {shortRef} (detached)
4043 </option>
4144 )}
42- {branches.map((b) => (
43- <option
44- value={b}
45- selected={b === currentRef ? true : undefined}
46- >
47- {b}
48- </option>
49- ))}
45+ (
46+ <optgroup label="Branches">
47+ {branches.map((b) => (
48+ <option
49+ value={b}
50+ selected={b === currentRef ? true : undefined}
51+ >
52+ {b}
53+ </option>
54+ ))}
55+ </optgroup>
56+ <optgroup label="Tags">
57+ {tags.map((t) => (
58+ <option
59+ value={t}
60+ selected={t === currentRef ? true : undefined}
61+ >
62+ {t}
63+ </option>
64+ ))}
65+ </optgroup>
66+ )
5067 </select>
5168 <noscript>
5269 <button type="submit" class="btn btn-sm">
Msrc/views/repos/CommitLog.tsx
@@ -14,6 +14,7 @@ interface CommitLogProps {
1414 ref: string;
1515 commits: CommitEntry[];
1616 branches: string[];
17+ tags?: string[];
1718 /** URL for the next (older) page, or null if this is the last page. */
1819 olderUrl: string | null;
1920 /** URL for the previous (newer) page, or null if this is the first page. */
@@ -26,6 +27,7 @@ export function CommitLog({
2627 ref: logRef,
2728 commits,
2829 branches,
30+ tags,
2931 olderUrl,
3032 newerUrl,
3133 }: CommitLogProps) {
@@ -39,6 +41,7 @@ export function CommitLog({
3941 <BranchSelector
4042 repoName={repo.name}
4143 branches={branches}
44+ tags={tags}
4245 currentRef={logRef}
4346 view="commits"
4447 />
Msrc/views/repos/FileBlob.tsx
@@ -13,6 +13,7 @@ interface FileBlobProps {
1313 filePath: string;
1414 view: FileView;
1515 branches: string[];
16+ tags?: string[];
1617 markdownHtml?: string;
1718 }
1819
@@ -23,6 +24,7 @@ export function FileBlob({
2324 filePath,
2425 view,
2526 branches,
27+ tags,
2628 markdownHtml,
2729 }: FileBlobProps) {
2830 const parts = filePath.split("/");
@@ -61,10 +63,11 @@ export function FileBlob({
6163 </div>
6264 <div class="file-blob-header">
6365 <span class="file-blob-name">{filename}</span>
64- <div class="file-blob-actions">
66+ <div class="file-blob-actions file-blob-actions-wrap">
6567 <BranchSelector
6668 repoName={repo.name}
6769 branches={branches}
70+ tags={tags}
6871 currentRef={blobRef}
6972 view="blob"
7073 path={filePath}
@@ -85,6 +88,37 @@ export function FileBlob({
8588 Edit
8689 </a>
8790 )}
91+ {branches.includes(blobRef) && user?.isAdmin && (
92+ <details class="confirm-details">
93+ <summary class="btn btn-sm btn-danger">
94+ Delete
95+ </summary>
96+ <div class="confirm-popup">
97+ Delete <strong>{filename}</strong>?
98+ <form
99+ method="POST"
100+ action={`/${repo.name}/delete-file/${blobRef}/${filePath}`}
101+ class="popup-form"
102+ style="margin-top: var(--space-2);"
103+ >
104+ <div class="form-group">
105+ <input
106+ name="message"
107+ type="text"
108+ placeholder={`Delete ${filename}`}
109+ maxlength="500"
110+ />
111+ </div>
112+ <button
113+ type="submit"
114+ class="btn btn-sm btn-danger"
115+ >
116+ Yes, delete
117+ </button>
118+ </form>
119+ </div>
120+ </details>
121+ )}
88122 </div>
89123 </div>
90124 <div class="file-blob-body">
Msrc/views/repos/FileEdit.tsx
@@ -11,6 +11,7 @@ interface FileEditProps {
1111 filePath: string;
1212 content: string;
1313 error?: string;
14+ queryError?: string;
1415 }
1516
1617 export function FileEdit({
@@ -20,6 +21,7 @@ export function FileEdit({
2021 filePath,
2122 content,
2223 error,
24+ queryError,
2325 }: FileEditProps) {
2426 const parts = filePath.split("/");
2527 const filename = parts[parts.length - 1] ?? filePath;
@@ -54,7 +56,9 @@ export function FileEdit({
5456 <p class="form-hint">
5557 WARNING: Line endings are normalized to LF (\n) on save.
5658 </p>
57- {error && <p class="form-error">{error}</p>}
59+ {(error || queryError) && (
60+ <p class="form-error">{error ?? queryError}</p>
61+ )}
5862 <form
5963 method="POST"
6064 action={`/${repo.name}/edit/${editRef}/${filePath}`}
@@ -90,6 +94,17 @@ export function FileEdit({
9094 >
9195 Committing directly to <strong>{editRef}</strong>
9296 </p>
97+ <div class="form-group">
98+ <label for="file-path">File path</label>
99+ <input
100+ id="file-path"
101+ name="new_path"
102+ type="text"
103+ value={filePath}
104+ class="mono"
105+ maxlength="1000"
106+ />
107+ </div>
93108 <div class="form-group">
94109 <label for="message">Commit message</label>
95110 <textarea
Msrc/views/repos/FileTree.tsx
@@ -14,6 +14,7 @@ interface FileTreeProps {
1414 subpath: string;
1515 entries: TreeEntry[];
1616 branches: string[];
17+ tags?: string[];
1718 readmeHtml?: string | null;
1819 readmePath?: string;
1920 }
@@ -25,6 +26,7 @@ export function FileTree({
2526 subpath,
2627 entries,
2728 branches,
29+ tags,
2830 readmeHtml,
2931 readmePath,
3032 }: FileTreeProps) {
@@ -41,10 +43,19 @@ export function FileTree({
4143 <BranchSelector
4244 repoName={repo.name}
4345 branches={branches}
46+ tags={tags}
4447 currentRef={treeRef}
4548 view="tree"
4649 path={subpath}
4750 />
51+ {branches.includes(treeRef) && user?.isAdmin && (
52+ <a
53+ href={`/${repo.name}/new-file/${treeRef}${subpath ? `?dir=${encodeURIComponent(subpath)}` : ""}`}
54+ class="btn btn-sm btn-primary"
55+ >
56+ New file
57+ </a>
58+ )}
4859 </div>
4960 {subpath && (
5061 <div class="breadcrumb">
Asrc/views/repos/NewFileForm.tsx
@@ -0,0 +1,98 @@
1+import type { RepositoryRow } from "../../db/index.ts";
2+import type { SessionUser } from "../../middleware/session.ts";
3+import { Layout } from "../layout.tsx";
4+import { RepoHeader } from "../repos/RepoHeader.tsx";
5+import { RepoNav } from "./RepoNav.tsx";
6+
7+interface NewFileFormProps {
8+ user: SessionUser;
9+ repo: RepositoryRow;
10+ ref: string;
11+ dir?: string;
12+ error?: string;
13+}
14+
15+export function NewFileForm({
16+ user,
17+ repo,
18+ ref: treeRef,
19+ dir,
20+ error,
21+}: NewFileFormProps) {
22+ const defaultPath = dir ? `${dir}/` : "";
23+ const cancelHref = dir
24+ ? `/${repo.name}/tree/${treeRef}/${dir}`
25+ : `/${repo.name}/tree/${treeRef}`;
26+ return (
27+ <Layout user={user} title={`New file — ${repo.name}`}>
28+ <div class="container">
29+ <RepoHeader repo={repo} />
30+ <RepoNav repo={repo} active="code" user={user} />
31+ {error && <p class="form-error">{error}</p>}
32+ <form
33+ method="POST"
34+ action={`/${repo.name}/new-file/${treeRef}`}
35+ >
36+ <div class="file-blob-header">
37+ <span class="file-blob-name">New file</span>
38+ <div class="file-blob-actions">
39+ <a href={cancelHref} class="btn btn-sm">
40+ Cancel
41+ </a>
42+ </div>
43+ </div>
44+ <div class="file-blob-body">
45+ <textarea
46+ name="content"
47+ class="file-edit-textarea"
48+ rows="20"
49+ spellcheck="false"
50+ autocorrect="off"
51+ autocapitalize="off"
52+ {...{ autocomplete: "off" }}
53+ placeholder="File contents..."
54+ />
55+ </div>
56+ <div class="form-card">
57+ <p
58+ class="form-hint"
59+ style="margin-bottom: var(--space-4);"
60+ >
61+ Creating file on <strong>{treeRef}</strong>
62+ </p>
63+ <div class="form-group">
64+ <label for="file-path">File path</label>
65+ <input
66+ id="file-path"
67+ name="path"
68+ type="text"
69+ required
70+ value={defaultPath}
71+ placeholder="path/to/file.txt"
72+ class="mono"
73+ maxlength="1000"
74+ />
75+ </div>
76+ <div class="form-group">
77+ <label for="message">Commit message</label>
78+ <textarea
79+ id="message"
80+ name="message"
81+ rows="3"
82+ placeholder="Add new file"
83+ />
84+ </div>
85+ <div class="form-actions">
86+ <button type="submit" class="btn btn-primary">
87+ Create file
88+ </button>
89+ <a href={cancelHref} class="btn">
90+ Cancel
91+ </a>
92+ </div>
93+ </div>
94+ </form>
95+ </div>
96+ </Layout>
97+ );
98+}
Msrc/views/repos/RepoHome.tsx
@@ -16,6 +16,7 @@ interface RepoHomeProps {
1616 readmePath?: string;
1717 hasContent: boolean;
1818 branches: string[];
19+ tags?: string[];
1920 }
2021
2122 export function RepoHome({
@@ -26,6 +27,7 @@ export function RepoHome({
2627 readmePath,
2728 hasContent,
2829 branches,
30+ tags,
2931 }: RepoHomeProps) {
3032 return (
3133 <Layout user={user} title={repo.name}>
@@ -54,40 +56,52 @@ git push origin main`}</code>
5456 <BranchSelector
5557 repoName={repo.name}
5658 branches={branches}
59+ tags={tags}
5760 currentRef={repo.default_branch}
5861 view="tree"
5962 />
60- <details class="clone-popup-details">
61- <summary class="btn btn-sm btn-primary">
62- Clone
63- </summary>
64- <div class="clone-popup">
65- <div class="clone-url-row">
66- <span class="clone-url-label">
67- HTTP
68- </span>
69- <input
70- type="text"
71- readonly
72- value={httpUrl(repo.name)}
73- class="clone-url-input"
74- />
75- </div>
76- {!config.SSH_DISABLED && (
63+ <div class="tree-toolbar-actions">
64+ {branches.includes(repo.default_branch) &&
65+ user?.isAdmin && (
66+ <a
67+ href={`/${repo.name}/new-file/${repo.default_branch}`}
68+ class="btn btn-sm btn-primary"
69+ >
70+ New file
71+ </a>
72+ )}
73+ <details class="clone-popup-details">
74+ <summary class="btn btn-sm btn-primary">
75+ Clone
76+ </summary>
77+ <div class="clone-popup">
7778 <div class="clone-url-row">
7879 <span class="clone-url-label">
79- SSH
80+ HTTP
8081 </span>
8182 <input
8283 type="text"
8384 readonly
84- value={sshUrl(repo.name)}
85+ value={httpUrl(repo.name)}
8586 class="clone-url-input"
8687 />
8788 </div>
88- )}
89- </div>
90- </details>
89+ {!config.SSH_DISABLED && (
90+ <div class="clone-url-row">
91+ <span class="clone-url-label">
92+ SSH
93+ </span>
94+ <input
95+ type="text"
96+ readonly
97+ value={sshUrl(repo.name)}
98+ class="clone-url-input"
99+ />
100+ </div>
101+ )}
102+ </div>
103+ </details>
104+ </div>
91105 </div>
92106 <FileTreeTable
93107 repoName={repo.name}
Msrc/views/repos/RepoNav.tsx
@@ -3,13 +3,23 @@ import type { SessionUser } from "../../middleware/session.ts";
33
44 interface RepoNavProps {
55 repo: RepositoryRow;
6- active: "code" | "commits" | "issues" | "patches" | "releases" | "settings";
6+ active:
7+ | "code"
8+ | "branches"
9+ | "tags"
10+ | "commits"
11+ | "issues"
12+ | "patches"
13+ | "releases"
14+ | "settings";
715 user?: SessionUser | null;
816 }
917
1018 export function RepoNav({ repo, active, user }: RepoNavProps) {
1119 const tabs = [
1220 { key: "code", label: "Code", href: `/${repo.name}` },
21+ { key: "branches", label: "Branches", href: `/${repo.name}/branches` },
22+ { key: "tags", label: "Tags", href: `/${repo.name}/tags` },
1323 {
1424 key: "commits",
1525 label: "Commits",
Asrc/views/repos/TagList.tsx
@@ -0,0 +1,224 @@
1+import type { RepositoryRow } from "../../db/index.ts";
2+import { formatDateTime } from "../../lib/formatDate.ts";
3+import type { SessionUser } from "../../middleware/session.ts";
4+import type { TagInfo } from "../../services/git.ts";
5+import { Layout } from "../layout.tsx";
6+import { Pagination } from "../Pagination.tsx";
7+import { RepoHeader } from "./RepoHeader.tsx";
8+import { RepoNav } from "./RepoNav.tsx";
9+
10+interface TagListProps {
11+ user: SessionUser | null;
12+ repo: RepositoryRow;
13+ tags: TagInfo[];
14+ tagReleaseMap: Map<string, number>;
15+ page: number;
16+ totalPages: number;
17+ success?: string;
18+ error?: string;
19+}
20+
21+export function TagList({
22+ user,
23+ repo,
24+ tags,
25+ tagReleaseMap,
26+ page,
27+ totalPages,
28+ success,
29+ error,
30+}: TagListProps) {
31+ return (
32+ <Layout user={user} title={`Tags — ${repo.name}`}>
33+ <div class="container">
34+ <RepoHeader repo={repo} />
35+ <RepoNav repo={repo} active="tags" user={user} />
36+ {success && <p class="form-success">{success}</p>}
37+ {error && <p class="form-error">{error}</p>}
38+ <div class="list-header">
39+ <h2 class="list-heading">Tags</h2>
40+ {user?.isAdmin && (
41+ <details class="confirm-details">
42+ <summary class="btn btn-primary btn-sm">
43+ New tag
44+ </summary>
45+ <div class="confirm-popup confirm-popup-form">
46+ <form
47+ method="POST"
48+ action={`/${repo.name}/tags/create`}
49+ class="popup-form"
50+ >
51+ <div class="form-group">
52+ <label for="new-tag-name">
53+ Tag name
54+ </label>
55+ <input
56+ id="new-tag-name"
57+ name="name"
58+ type="text"
59+ required
60+ placeholder="v1.0.0"
61+ maxlength="255"
62+ />
63+ </div>
64+ <div class="form-group">
65+ <label for="new-tag-ref">Target</label>
66+ <input
67+ id="new-tag-ref"
68+ name="ref"
69+ type="text"
70+ required
71+ value={repo.default_branch}
72+ placeholder="branch, tag, or commit"
73+ maxlength="255"
74+ />
75+ </div>
76+ <div class="form-group">
77+ <label for="new-tag-message">
78+ Message{" "}
79+ <span class="text-muted">
80+ (blank = lightweight)
81+ </span>
82+ </label>
83+ <input
84+ id="new-tag-message"
85+ name="message"
86+ type="text"
87+ placeholder="Optional tag message"
88+ maxlength="500"
89+ />
90+ </div>
91+ <button
92+ type="submit"
93+ class="btn btn-primary btn-sm"
94+ >
95+ Create tag
96+ </button>
97+ </form>
98+ </div>
99+ </details>
100+ )}
101+ </div>
102+ {tags.length === 0 ? (
103+ <div class="empty-state">
104+ <p>No tags yet.</p>
105+ </div>
106+ ) : (
107+ <ul class="ref-list">
108+ {tags.map((tag) => {
109+ const releaseId = tagReleaseMap.get(tag.name);
110+ return (
111+ <li class="ref-item">
112+ <div class="ref-name-row">
113+ <a
114+ href={`/${repo.name}/tree/${tag.name}`}
115+ class="ref-name"
116+ >
117+ {tag.name}
118+ </a>
119+ {releaseId && (
120+ <a
121+ href={`/${repo.name}/releases/${releaseId}`}
122+ class="badge badge-release"
123+ >
124+ release
125+ </a>
126+ )}
127+ </div>
128+ <div class="ref-meta-row">
129+ {tag.taggerName && (
130+ <span class="ref-author">
131+ {tag.taggerName}
132+ </span>
133+ )}
134+ {tag.shortHash && (
135+ <a
136+ href={`/${repo.name}/commit/${tag.shortHash}`}
137+ class="ref-hash mono"
138+ >
139+ {tag.shortHash}
140+ </a>
141+ )}
142+ {tag.subject && (
143+ <span class="ref-subject">
144+ {tag.subject}
145+ </span>
146+ )}
147+ {tag.date && (
148+ <time
149+ class="ref-date"
150+ datetime={tag.date}
151+ >
152+ {formatDateTime(tag.date)}
153+ </time>
154+ )}
155+ {user?.isAdmin && (
156+ <span class="ref-actions">
157+ <details class="confirm-details confirm-details-inline">
158+ <summary class="btn btn-sm btn-danger">
159+ Delete
160+ </summary>
161+ <div class="confirm-popup">
162+ {releaseId ? (
163+ <p class="confirm-warning">
164+ Tag{" "}
165+ <strong>
166+ {tag.name}
167+ </strong>{" "}
168+ is linked to{" "}
169+ <a
170+ href={`/${repo.name}/releases/${releaseId}`}
171+ >
172+ a release
173+ </a>
174+ . Only the git
175+ tag will be
176+ deleted; the
177+ release record
178+ will remain.
179+ </p>
180+ ) : (
181+ <>
182+ Delete tag{" "}
183+ <strong>
184+ {tag.name}
185+ </strong>
186+ ?
187+ </>
188+ )}
189+ <form
190+ method="POST"
191+ action={`/${repo.name}/tags/delete`}
192+ class="inline-form"
193+ >
194+ <input
195+ type="hidden"
196+ name="name"
197+ value={tag.name}
198+ />
199+ <button
200+ type="submit"
201+ class="btn btn-sm btn-danger"
202+ >
203+ Yes, delete
204+ </button>
205+ </form>
206+ </div>
207+ </details>
208+ </span>
209+ )}
210+ </div>
211+ </li>
212+ );
213+ })}
214+ </ul>
215+ )}
216+ <Pagination
217+ page={page}
218+ totalPages={totalPages}
219+ pageUrlTemplate={`/${repo.name}/tags?page={page}`}
220+ />
221+ </div>
222+ </Layout>
223+ );
224+}
Atests/e2e.git-ops.test.ts
@@ -0,0 +1,481 @@
1+import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
2+import { chromium } from 'playwright';
3+import type { Browser, BrowserContext } from 'playwright';
4+import {
5+ ADMIN_PASS,
6+ BASE,
7+ DATA_DIR,
8+ getHeadCommit,
9+ gitOutput,
10+ killServer,
11+ login,
12+ seedBranch,
13+ seedRepo,
14+ setupTestEnv,
15+ spawnServer,
16+} from './helpers.ts';
17+
18+let browser: Browser;
19+let server: Awaited<ReturnType<typeof spawnServer>>;
20+let adminCtx: BrowserContext;
21+
22+beforeAll(async () => {
23+ await setupTestEnv();
24+ server = await spawnServer();
25+ browser = await chromium.launch();
26+
27+ // Single admin context shared across all describes
28+ adminCtx = await browser.newContext();
29+ const page = await adminCtx.newPage();
30+ await login(page, 'admin', ADMIN_PASS);
31+ await page.close();
32+
33+ // Create all repos upfront
34+ for (const name of ['branch-repo', 'tag-repo', 'selector-repo', 'newfile-repo', 'delfile-repo', 'movefile-repo']) {
35+ const p = await adminCtx.newPage();
36+ try {
37+ await p.goto(`${BASE}/new`);
38+ await p.fill('[name=name]', name);
39+ await p.click('form[action="/new"] button[type=submit]');
40+ await p.waitForURL(`${BASE}/${name}`);
41+ } finally { await p.close(); }
42+ }
43+
44+ // Seed repos
45+ await seedRepo('branch-repo');
46+ seedBranch('branch-repo', 'feature-a');
47+
48+ await seedRepo('tag-repo');
49+ gitOutput(['-C', repoDir('tag-repo'), 'tag', 'v0.1.0', 'HEAD']);
50+
51+ await seedRepo('selector-repo');
52+ gitOutput(['-C', repoDir('selector-repo'), 'tag', 'stable', 'HEAD']);
53+
54+ await seedRepo('newfile-repo');
55+ await seedRepo('delfile-repo');
56+ await seedRepo('movefile-repo');
57+});
58+
59+afterAll(async () => {
60+ await adminCtx.close();
61+ await browser.close();
62+ await killServer(server);
63+});
64+
65+function repoDir(name: string) {
66+ return `${process.cwd()}/${DATA_DIR}/repos/${name}.git`;
67+}
68+
69+// ─── Branch management ────────────────────────────────────────────────────────
70+
71+describe('branches', () => {
72+ test('Branches tab appears in repo nav', async () => {
73+ const page = await adminCtx.newPage();
74+ try {
75+ await page.goto(`${BASE}/branch-repo`);
76+ const tab = page.locator('a.repo-tab', { hasText: 'Branches' });
77+ expect(await tab.isVisible()).toBe(true);
78+ } finally { await page.close(); }
79+ });
80+
81+ test('branches page lists all branches', async () => {
82+ const page = await adminCtx.newPage();
83+ try {
84+ await page.goto(`${BASE}/branch-repo/branches`);
85+ const content = await page.content();
86+ expect(content).toContain('main');
87+ expect(content).toContain('feature-a');
88+ } finally { await page.close(); }
89+ });
90+
91+ test('default branch has a "default" badge', async () => {
92+ const page = await adminCtx.newPage();
93+ try {
94+ await page.goto(`${BASE}/branch-repo/branches`);
95+ expect(await page.locator('.badge', { hasText: 'default' }).count()).toBeGreaterThan(0);
96+ } finally { await page.close(); }
97+ });
98+
99+ test('branch list shows last commit hash and subject', async () => {
100+ const sha = getHeadCommit('branch-repo').slice(0, 7);
101+ const page = await adminCtx.newPage();
102+ try {
103+ await page.goto(`${BASE}/branch-repo/branches`);
104+ const content = await page.content();
105+ expect(content).toContain(sha);
106+ expect(content).toContain('Initial commit');
107+ } finally { await page.close(); }
108+ });
109+
110+ test('branches page returns 404 for non-existent repo', async () => {
111+ const ctx = await browser.newContext();
112+ const page = await ctx.newPage();
113+ try {
114+ const resp = await page.request.get(`${BASE}/does-not-exist/branches`);
115+ expect(resp.status()).toBe(404);
116+ } finally {
117+ await page.close();
118+ await ctx.close();
119+ }
120+ });
121+
122+ test('create a new branch', async () => {
123+ const page = await adminCtx.newPage();
124+ try {
125+ await page.goto(`${BASE}/branch-repo/branches`);
126+ await page.click('details:has(summary:text("New branch")) summary');
127+ await page.fill('[name=name]', 'new-feature');
128+ await page.fill('[name=source_ref]', 'main');
129+ await page.click('form[action*="branches/create"] button[type=submit]');
130+ await page.waitForURL(/branches/);
131+ expect(await page.locator('.ref-item').filter({ hasText: 'new-feature' }).count()).toBeGreaterThan(0);
132+ } finally { await page.close(); }
133+ });
134+
135+ test('creating branch with invalid name shows error', async () => {
136+ const resp = await adminCtx.request.post(`${BASE}/branch-repo/branches/create`, {
137+ form: { name: '--invalid', source_ref: 'main' },
138+ });
139+ expect(resp.url()).toContain('error');
140+ });
141+
142+ test('creating branch from non-existent ref shows error', async () => {
143+ const resp = await adminCtx.request.post(`${BASE}/branch-repo/branches/create`, {
144+ form: { name: 'bad-branch', source_ref: 'does-not-exist' },
145+ });
146+ expect(resp.url()).toContain('error');
147+ });
148+
149+ test('rename a branch', async () => {
150+ const page = await adminCtx.newPage();
151+ try {
152+ await page.goto(`${BASE}/branch-repo/branches`);
153+ const row = page.locator('.ref-item').filter({ hasText: 'feature-a' });
154+ await row.locator('details:has(summary:text("Rename")) summary').click();
155+ await row.locator('[name=new_name]').fill('feature-renamed');
156+ await row.locator('form[action*="branches/rename"] button[type=submit]').click();
157+ await page.waitForURL(/branches/);
158+ expect(await page.locator('.ref-item').filter({ hasText: 'feature-renamed' }).count()).toBeGreaterThan(0);
159+ expect(await page.locator('.ref-item').filter({ hasText: 'feature-a' }).count()).toBe(0);
160+ } finally { await page.close(); }
161+ });
162+
163+ test('delete a non-default branch', async () => {
164+ const page = await adminCtx.newPage();
165+ try {
166+ await page.goto(`${BASE}/branch-repo/branches`);
167+ const row = page.locator('.ref-item').filter({ hasText: 'new-feature' });
168+ await row.locator('details:has(summary:text("Delete")) summary').click();
169+ await row.locator('form[action*="branches/delete"] button[type=submit]').click();
170+ await page.waitForURL(/branches/);
171+ expect(await page.locator('.ref-item').filter({ hasText: 'new-feature' }).count()).toBe(0);
172+ } finally { await page.close(); }
173+ });
174+
175+ test('cannot delete the default branch (no Delete button on main row)', async () => {
176+ const page = await adminCtx.newPage();
177+ try {
178+ await page.goto(`${BASE}/branch-repo/branches`);
179+ const mainRow = page.locator('.ref-item').filter({ hasText: 'main' });
180+ expect(await mainRow.locator('summary:text("Delete")').count()).toBe(0);
181+ } finally { await page.close(); }
182+ });
183+
184+ test('renaming default branch updates it in repo', async () => {
185+ const page = await adminCtx.newPage();
186+ try {
187+ await page.goto(`${BASE}/branch-repo/branches`);
188+ const row = page.locator('.ref-item').filter({ hasText: 'main' });
189+ await row.locator('details:has(summary:text("Rename")) summary').click();
190+ await row.locator('[name=new_name]').fill('trunk');
191+ await row.locator('form[action*="branches/rename"] button[type=submit]').click();
192+ await page.waitForURL(/branches/);
193+ expect(await page.locator('.ref-item').filter({ hasText: 'trunk' }).locator('.badge', { hasText: 'default' }).count()).toBeGreaterThan(0);
194+ // Rename back
195+ await page.goto(`${BASE}/branch-repo/branches`);
196+ const trunkRow = page.locator('.ref-item').filter({ hasText: 'trunk' });
197+ await trunkRow.locator('details:has(summary:text("Rename")) summary').click();
198+ await trunkRow.locator('[name=new_name]').fill('main');
199+ await trunkRow.locator('form[action*="branches/rename"] button[type=submit]').click();
200+ await page.waitForURL(/branches/);
201+ } finally { await page.close(); }
202+ });
203+});
204+
205+// ─── Tag management ───────────────────────────────────────────────────────────
206+
207+describe('tags', () => {
208+ test('Tags tab appears in repo nav', async () => {
209+ const page = await adminCtx.newPage();
210+ try {
211+ await page.goto(`${BASE}/tag-repo`);
212+ const tab = page.locator('a.repo-tab', { hasText: 'Tags' });
213+ expect(await tab.isVisible()).toBe(true);
214+ } finally { await page.close(); }
215+ });
216+
217+ test('tags page lists existing tags', async () => {
218+ const page = await adminCtx.newPage();
219+ try {
220+ await page.goto(`${BASE}/tag-repo/tags`);
221+ expect(await page.content()).toContain('v0.1.0');
222+ } finally { await page.close(); }
223+ });
224+
225+ test('tag links to correct tree view', async () => {
226+ const page = await adminCtx.newPage();
227+ try {
228+ await page.goto(`${BASE}/tag-repo/tags`);
229+ const link = page.locator(`a[href="/${['tag-repo', 'tree', 'v0.1.0'].join('/')}"]`);
230+ expect(await link.count()).toBeGreaterThan(0);
231+ } finally { await page.close(); }
232+ });
233+
234+ test('create a new tag', async () => {
235+ const page = await adminCtx.newPage();
236+ try {
237+ await page.goto(`${BASE}/tag-repo/tags`);
238+ await page.click('details:has(summary:text("New tag")) summary');
239+ await page.fill('[name=name]', 'v1.0.0');
240+ await page.click('form[action*="tags/create"] button[type=submit]');
241+ await page.waitForURL(/tags/);
242+ expect(await page.locator('.ref-item').filter({ hasText: 'v1.0.0' }).count()).toBeGreaterThan(0);
243+ } finally { await page.close(); }
244+ });
245+
246+ test('create annotated tag with message', async () => {
247+ const page = await adminCtx.newPage();
248+ try {
249+ await page.goto(`${BASE}/tag-repo/tags`);
250+ await page.click('details:has(summary:text("New tag")) summary');
251+ await page.fill('[name=name]', 'v1.1.0-annotated');
252+ await page.fill('[name=message]', 'Annotated release tag');
253+ await page.click('form[action*="tags/create"] button[type=submit]');
254+ await page.waitForURL(/tags/);
255+ expect(await page.locator('.ref-item').filter({ hasText: 'v1.1.0-annotated' }).count()).toBeGreaterThan(0);
256+ } finally { await page.close(); }
257+ });
258+
259+ test('delete a tag', async () => {
260+ const page = await adminCtx.newPage();
261+ try {
262+ await page.goto(`${BASE}/tag-repo/tags`);
263+ const row = page.locator('.ref-item').filter({ hasText: 'v1.0.0' });
264+ await row.locator('details:has(summary:text("Delete")) summary').click();
265+ await row.locator('form[action*="tags/delete"] button[type=submit]').click();
266+ await page.waitForURL(/tags/);
267+ expect(await page.locator('.ref-item').filter({ hasText: 'v1.0.0' }).count()).toBe(0);
268+ expect(await page.locator('.ref-item').filter({ hasText: 'v0.1.0' }).count()).toBeGreaterThan(0);
269+ } finally { await page.close(); }
270+ });
271+
272+ test('tag linked to release shows release badge and warning on delete', async () => {
273+ const resp = await adminCtx.request.post(`${BASE}/tag-repo/releases`, {
274+ multipart: { name: 'Linked Release', create_tag: 'on', tag_name: 'v-linked', revision: 'main' },
275+ maxRedirects: 0,
276+ });
277+ expect(resp.status()).toBe(302);
278+
279+ const page = await adminCtx.newPage();
280+ try {
281+ await page.goto(`${BASE}/tag-repo/tags`);
282+ const row = page.locator('.ref-item').filter({ hasText: 'v-linked' });
283+ expect(await row.locator('.badge-release').count()).toBeGreaterThan(0);
284+ await row.locator('details:has(summary:text("Delete")) summary').click();
285+ expect(await row.locator('.confirm-warning').count()).toBeGreaterThan(0);
286+ } finally { await page.close(); }
287+ });
288+});
289+
290+// ─── BranchSelector with tags ─────────────────────────────────────────────────
291+
292+describe('BranchSelector tags integration', () => {
293+ test('branch selector shows Tags optgroup when tags exist', async () => {
294+ const page = await adminCtx.newPage();
295+ try {
296+ await page.goto(`${BASE}/selector-repo`);
297+ expect(await page.locator('optgroup[label="Tags"]').count()).toBe(1);
298+ expect(await page.locator('option', { hasText: 'stable' }).count()).toBeGreaterThan(0);
299+ } finally { await page.close(); }
300+ });
301+
302+ test('branch selector shows Branches optgroup when tags exist', async () => {
303+ const page = await adminCtx.newPage();
304+ try {
305+ await page.goto(`${BASE}/selector-repo`);
306+ expect(await page.locator('optgroup[label="Branches"]').count()).toBe(1);
307+ } finally { await page.close(); }
308+ });
309+
310+ test('branch selector on blob view includes tag optgroup', async () => {
311+ const page = await adminCtx.newPage();
312+ try {
313+ await page.goto(`${BASE}/selector-repo/blob/main/README.md`);
314+ expect(await page.locator('optgroup[label="Tags"]').count()).toBe(1);
315+ } finally { await page.close(); }
316+ });
317+});
318+
319+// ─── File creation ────────────────────────────────────────────────────────────
320+
321+describe('file creation', () => {
322+ test('New file button appears in tree toolbar for admin on a branch', async () => {
323+ const page = await adminCtx.newPage();
324+ try {
325+ await page.goto(`${BASE}/newfile-repo/tree/main`);
326+ expect(await page.locator('a[href*="/new-file/main"]').count()).toBeGreaterThan(0);
327+ } finally { await page.close(); }
328+ });
329+
330+ test('New file button not visible on commit SHA view', async () => {
331+ const sha = getHeadCommit('newfile-repo');
332+ const page = await adminCtx.newPage();
333+ try {
334+ await page.goto(`${BASE}/newfile-repo/tree/${sha}`);
335+ expect(await page.locator('a[href*="/new-file/"]').count()).toBe(0);
336+ } finally { await page.close(); }
337+ });
338+
339+ test('New file button not visible to unauthenticated user', async () => {
340+ const ctx = await browser.newContext();
341+ const page = await ctx.newPage();
342+ try {
343+ await page.goto(`${BASE}/newfile-repo/tree/main`);
344+ expect(await page.locator('a[href*="/new-file/main"]').count()).toBe(0);
345+ } finally {
346+ await page.close();
347+ await ctx.close();
348+ }
349+ });
350+
351+ test('new file form pre-fills dir when ?dir= query param is provided', async () => {
352+ const page = await adminCtx.newPage();
353+ try {
354+ await page.goto(`${BASE}/newfile-repo/new-file/main?dir=src`);
355+ const pathInput = await page.locator('[name=path]').inputValue();
356+ expect(pathInput).toBe('src/');
357+ } finally { await page.close(); }
358+ });
359+
360+ test('creating a new file creates a commit and redirects to commit view', async () => {
361+ const resp = await adminCtx.request.post(`${BASE}/newfile-repo/new-file/main`, {
362+ form: { path: 'hello.txt', content: 'Hello, world!\n', message: 'Add hello.txt' },
363+ maxRedirects: 0,
364+ });
365+ expect(resp.status()).toBe(302);
366+ expect(resp.headers()['location']).toMatch(/\/newfile-repo\/commit\/[0-9a-f]{40}/);
367+ });
368+
369+ test('new file appears in tree after creation', async () => {
370+ const page = await adminCtx.newPage();
371+ try {
372+ await page.goto(`${BASE}/newfile-repo/tree/main`);
373+ expect(await page.content()).toContain('hello.txt');
374+ } finally { await page.close(); }
375+ });
376+
377+ test('new file commit is signed', async () => {
378+ const hash = gitOutput(['-C', repoDir('newfile-repo'), 'log', '--format=%H', '--grep=Add hello.txt', '-1']);
379+ expect(hash).toBeTruthy();
380+ const obj = gitOutput(['-C', repoDir('newfile-repo'), 'cat-file', '-p', hash]);
381+ expect(obj).toContain('gpgsig');
382+ });
383+
384+ test('creating file with invalid path shows error', async () => {
385+ const resp = await adminCtx.request.post(`${BASE}/newfile-repo/new-file/main`, {
386+ form: { path: '../escape', content: '', message: 'bad' },
387+ });
388+ expect(resp.url()).toContain('error');
389+ });
390+});
391+
392+// ─── File deletion ────────────────────────────────────────────────────────────
393+
394+describe('file deletion', () => {
395+ test('Delete button appears in file blob for admin on a branch', async () => {
396+ const page = await adminCtx.newPage();
397+ try {
398+ await page.goto(`${BASE}/delfile-repo/blob/main/index.js`);
399+ expect(await page.locator('details:has(summary:text("Delete"))').count()).toBeGreaterThan(0);
400+ } finally { await page.close(); }
401+ });
402+
403+ test('Delete button not visible to unauthenticated user', async () => {
404+ const ctx = await browser.newContext();
405+ const page = await ctx.newPage();
406+ try {
407+ await page.goto(`${BASE}/delfile-repo/blob/main/index.js`);
408+ expect(await page.locator('details:has(summary:text("Delete"))').count()).toBe(0);
409+ } finally {
410+ await page.close();
411+ await ctx.close();
412+ }
413+ });
414+
415+ test('deleting a file creates a commit and removes it from the tree', async () => {
416+ const resp = await adminCtx.request.post(`${BASE}/delfile-repo/delete-file/main/index.js`, {
417+ form: { message: 'Remove index.js' },
418+ maxRedirects: 0,
419+ });
420+ expect(resp.status()).toBe(302);
421+ expect(resp.headers()['location']).toMatch(/\/delfile-repo\/commit\/[0-9a-f]{40}/);
422+
423+ const page = await adminCtx.newPage();
424+ try {
425+ await page.goto(`${BASE}/delfile-repo/tree/main`);
426+ expect(await page.content()).not.toContain('index.js');
427+ } finally { await page.close(); }
428+ });
429+
430+ test('delete commit is signed', async () => {
431+ const hash = gitOutput(['-C', repoDir('delfile-repo'), 'log', '--format=%H', '--grep=Remove index.js', '-1']);
432+ expect(hash).toBeTruthy();
433+ const obj = gitOutput(['-C', repoDir('delfile-repo'), 'cat-file', '-p', hash]);
434+ expect(obj).toContain('gpgsig');
435+ });
436+});
437+
438+// ─── File rename/move ─────────────────────────────────────────────────────────
439+
440+describe('file rename/move', () => {
441+ test('edit form has new_path input pre-filled with current path', async () => {
442+ const page = await adminCtx.newPage();
443+ try {
444+ await page.goto(`${BASE}/movefile-repo/edit/main/index.js`);
445+ const newPathInput = await page.locator('[name=new_path]').inputValue();
446+ expect(newPathInput).toBe('index.js');
447+ } finally { await page.close(); }
448+ });
449+
450+ test('renaming a file via edit creates a commit and old path is gone', async () => {
451+ const resp = await adminCtx.request.post(`${BASE}/movefile-repo/edit/main/index.js`, {
452+ form: { content: 'console.log("hello");\n', new_path: 'app.js', message: 'Rename index.js to app.js' },
453+ maxRedirects: 0,
454+ });
455+ expect(resp.status()).toBe(302);
456+ expect(resp.headers()['location']).toMatch(/\/movefile-repo\/commit\/[0-9a-f]{40}/);
457+
458+ const page = await adminCtx.newPage();
459+ try {
460+ await page.goto(`${BASE}/movefile-repo/tree/main`);
461+ expect(await page.content()).toContain('app.js');
462+ expect(await page.content()).not.toContain('index.js');
463+ } finally { await page.close(); }
464+ });
465+
466+ test('rename commit is signed', async () => {
467+ const hash = gitOutput(['-C', repoDir('movefile-repo'), 'log', '--format=%H', '--grep=Rename index.js', '-1']);
468+ expect(hash).toBeTruthy();
469+ const obj = gitOutput(['-C', repoDir('movefile-repo'), 'cat-file', '-p', hash]);
470+ expect(obj).toContain('gpgsig');
471+ });
472+
473+ test('renaming to invalid path shows error', async () => {
474+ const resp = await adminCtx.request.post(`${BASE}/movefile-repo/edit/main/README.md`, {
475+ form: { content: '# movefile-repo\n', new_path: '../escape.md', message: 'bad' },
476+ maxRedirects: 0,
477+ });
478+ expect(resp.status()).toBe(302);
479+ expect(resp.headers()['location']).toContain('error');
480+ });
481+});