branch list & mutations, tag list & mutations, more file mutations
Mpackage.json
| @@ -1,5 +1,6 @@ | |||
|---|---|---|---|
| 1 | 1 | { | |
| 2 | 2 | "name": "hearthforge", | |
| 3 | + | "version": "1.0", | |
| 3 | 4 | "module": "src/index.tsx", | |
| 4 | 5 | "scripts": { | |
| 5 | 6 | "dev": "bun scripts/dev.ts", | |
Msrc/constants.ts
| @@ -32,6 +32,8 @@ export const COMMITS_PER_PAGE = 20; | |||
|---|---|---|---|
| 32 | 32 | export const ISSUES_PER_PAGE = 20; | |
| 33 | 33 | export const PATCHES_PER_PAGE = 20; | |
| 34 | 34 | export const RELEASES_PER_PAGE = 20; | |
| 35 | + | export const BRANCHES_PER_PAGE = 30; | |
| 36 | + | export const TAGS_PER_PAGE = 30; | |
| 35 | 37 | ||
| 36 | 38 | // Cache sizes | |
| 37 | 39 | export const MAX_MD_CACHE = 50; | |
Msrc/routes/repos.tsx
| @@ -5,9 +5,11 @@ import { fileTypeFromBuffer } from "file-type"; | |||
|---|---|---|---|
| 5 | 5 | import { sql } from "kysely"; | |
| 6 | 6 | import config from "../config.ts"; | |
| 7 | 7 | import { | |
| 8 | + | BRANCHES_PER_PAGE, | |
| 8 | 9 | COMMITS_PER_PAGE, | |
| 9 | 10 | paths, | |
| 10 | 11 | REPOS_PER_PAGE, | |
| 12 | + | TAGS_PER_PAGE, | |
| 11 | 13 | VALID_REPO_NAME_RE, | |
| 12 | 14 | } from "../constants.ts"; | |
| 13 | 15 | import { db } from "../db/index.ts"; | |
| @@ -22,15 +24,18 @@ import { | |||
|---|---|---|---|
| 22 | 24 | import { renderMarkdown } from "../services/markdown.ts"; | |
| 23 | 25 | import { ensureRepoRecord, repoDiskExists } from "../services/repoSync.ts"; | |
| 24 | 26 | import { html } from "../views/render.tsx"; | |
| 27 | + | import { BranchList } from "../views/repos/BranchList.tsx"; | |
| 25 | 28 | import { CommitDetail } from "../views/repos/CommitDetail.tsx"; | |
| 26 | 29 | import { CommitLog } from "../views/repos/CommitLog.tsx"; | |
| 27 | 30 | import { FileBlob } from "../views/repos/FileBlob.tsx"; | |
| 28 | 31 | import { FileEdit } from "../views/repos/FileEdit.tsx"; | |
| 29 | 32 | import { FileTree } from "../views/repos/FileTree.tsx"; | |
| 33 | + | import { NewFileForm } from "../views/repos/NewFileForm.tsx"; | |
| 30 | 34 | import { NewRepo } from "../views/repos/NewRepo.tsx"; | |
| 31 | 35 | import { RepoHome } from "../views/repos/RepoHome.tsx"; | |
| 32 | 36 | import { RepoList } from "../views/repos/RepoList.tsx"; | |
| 33 | 37 | import { RepoSettings } from "../views/repos/RepoSettings.tsx"; | |
| 38 | + | import { TagList } from "../views/repos/TagList.tsx"; | |
| 34 | 39 | ||
| 35 | 40 | async function getRepo(name: string, isAdmin: boolean) { | |
| 36 | 41 | if (!repoDiskExists(name)) return null; | |
| @@ -280,15 +285,19 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 280 | 285 | let readmePath: string | undefined; | |
| 281 | 286 | let entries: Awaited<ReturnType<typeof git.lsTree>> = []; | |
| 282 | 287 | let branches: string[] = []; | |
| 288 | + | let tags: string[] = []; | |
| 283 | 289 | ||
| 284 | 290 | 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 | + | ]); | |
| 290 | 298 | entries = lsResult; | |
| 291 | 299 | branches = branchResult; | |
| 300 | + | tags = tagResult; | |
| 292 | 301 | const readme = await readReadme(repo.name, repo.default_branch); | |
| 293 | 302 | if (readme) { | |
| 294 | 303 | const key = resolved | |
| @@ -316,6 +325,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 316 | 325 | readmePath={readmePath} | |
| 317 | 326 | hasContent={hasContent} | |
| 318 | 327 | branches={branches} | |
| 328 | + | tags={tags} | |
| 319 | 329 | />, | |
| 320 | 330 | ); | |
| 321 | 331 | }) | |
| @@ -376,9 +386,10 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 376 | 386 | const resolved = await git.resolveRef(repo.name, params.ref); | |
| 377 | 387 | if (!resolved) return new Response("Not found", { status: 404 }); | |
| 378 | 388 | ||
| 379 | - | const [entries, branches] = await Promise.all([ | |
| 389 | + | const [entries, branches, tags] = await Promise.all([ | |
| 380 | 390 | git.lsTree(repo.name, params.ref), | |
| 381 | 391 | git.branches(repo.name), | |
| 392 | + | git.tags(repo.name), | |
| 382 | 393 | ]); | |
| 383 | 394 | const readme = await readReadme(repo.name, params.ref); | |
| 384 | 395 | const readmeHtml = readme | |
| @@ -396,6 +407,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 396 | 407 | subpath="" | |
| 397 | 408 | entries={entries} | |
| 398 | 409 | branches={branches} | |
| 410 | + | tags={tags} | |
| 399 | 411 | readmeHtml={readmeHtml} | |
| 400 | 412 | readmePath={readme?.filename} | |
| 401 | 413 | />, | |
| @@ -411,9 +423,10 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 411 | 423 | if (!resolved) return new Response("Not found", { status: 404 }); | |
| 412 | 424 | ||
| 413 | 425 | const subpath = decodeURIComponent(params["*"]); | |
| 414 | - | const [entries, branches] = await Promise.all([ | |
| 426 | + | const [entries, branches, tags] = await Promise.all([ | |
| 415 | 427 | git.lsTree(repo.name, params.ref, subpath), | |
| 416 | 428 | git.branches(repo.name), | |
| 429 | + | git.tags(repo.name), | |
| 417 | 430 | ]); | |
| 418 | 431 | if (entries.length === 0) { | |
| 419 | 432 | // Could be a file — redirect to blob | |
| @@ -440,6 +453,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 440 | 453 | subpath={subpath} | |
| 441 | 454 | entries={entries} | |
| 442 | 455 | branches={branches} | |
| 456 | + | tags={tags} | |
| 443 | 457 | readmeHtml={readmeHtml} | |
| 444 | 458 | readmePath={readme?.filename} | |
| 445 | 459 | />, | |
| @@ -452,9 +466,10 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 452 | 466 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 453 | 467 | ||
| 454 | 468 | const filePath = decodeURIComponent(params["*"]); | |
| 455 | - | const [content, branches, commitSHA] = await Promise.all([ | |
| 469 | + | const [content, branches, tags, commitSHA] = await Promise.all([ | |
| 456 | 470 | git.show(repo.name, params.ref, filePath), | |
| 457 | 471 | git.branches(repo.name), | |
| 472 | + | git.tags(repo.name), | |
| 458 | 473 | git.resolveRef(repo.name, params.ref), | |
| 459 | 474 | ]); | |
| 460 | 475 | if (!content || !commitSHA) | |
| @@ -492,6 +507,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 492 | 507 | filePath={filePath} | |
| 493 | 508 | view={view} | |
| 494 | 509 | branches={branches} | |
| 510 | + | tags={tags} | |
| 495 | 511 | markdownHtml={markdownHtml} | |
| 496 | 512 | />, | |
| 497 | 513 | ); | |
| @@ -539,7 +555,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 539 | 555 | }); | |
| 540 | 556 | }) | |
| 541 | 557 | ||
| 542 | - | .get("/:repo/edit/:ref/*", async ({ params, cookie }) => { | |
| 558 | + | .get("/:repo/edit/:ref/*", async ({ params, query, cookie }) => { | |
| 543 | 559 | const user = await resolveSession(cookie.session.value); | |
| 544 | 560 | const deny = requireAdmin(user); | |
| 545 | 561 | if (deny) return deny; | |
| @@ -557,6 +573,8 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 557 | 573 | if (hasBinaryContent(content.subarray(0, 8000))) | |
| 558 | 574 | return new Response("Not found", { status: 404 }); | |
| 559 | 575 | ||
| 576 | + | const queryError = | |
| 577 | + | typeof query.error === "string" ? query.error : undefined; | |
| 560 | 578 | return html( | |
| 561 | 579 | <FileEdit | |
| 562 | 580 | user={user!} | |
| @@ -564,6 +582,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 564 | 582 | ref={params.ref} | |
| 565 | 583 | filePath={filePath} | |
| 566 | 584 | content={content.toString("utf-8")} | |
| 585 | + | queryError={queryError} | |
| 567 | 586 | />, | |
| 568 | 587 | ); | |
| 569 | 588 | }) | |
| @@ -582,8 +601,27 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 582 | 601 | if (!branches.includes(params.ref)) | |
| 583 | 602 | return new Response("Not found", { status: 404 }); | |
| 584 | 603 | ||
| 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; | |
| 587 | 625 | const content = (body.content ?? "").replaceAll("\r\n", "\n"); | |
| 588 | 626 | ||
| 589 | 627 | const commit = await git.editFile( | |
| @@ -594,6 +632,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 594 | 632 | message, | |
| 595 | 633 | config.COMMITTER_NAME, | |
| 596 | 634 | config.COMMITTER_EMAIL, | |
| 635 | + | newPath, | |
| 597 | 636 | ); | |
| 598 | 637 | ||
| 599 | 638 | return new Response(null, { | |
| @@ -607,6 +646,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 607 | 646 | body: t.Object({ | |
| 608 | 647 | content: t.Optional(t.String()), | |
| 609 | 648 | message: t.Optional(t.String()), | |
| 649 | + | new_path: t.Optional(t.String()), | |
| 610 | 650 | }), | |
| 611 | 651 | }, | |
| 612 | 652 | ) | |
| @@ -625,13 +665,14 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 625 | 665 | const after = query.after?.trim() || null; | |
| 626 | 666 | const prev = query.prev?.trim() || null; | |
| 627 | 667 | ||
| 628 | - | const [rawCommits, branches] = await Promise.all([ | |
| 668 | + | const [rawCommits, branches, tags] = await Promise.all([ | |
| 629 | 669 | // When `after` is set: start at that SHA and skip it (--skip=1 is O(1)), | |
| 630 | 670 | // then fetch LIMIT+1 to detect whether another page exists. | |
| 631 | 671 | after | |
| 632 | 672 | ? git.log(repo.name, after, COMMITS_PER_PAGE + 1, 1) | |
| 633 | 673 | : git.log(repo.name, params.ref, COMMITS_PER_PAGE + 1, 0), | |
| 634 | 674 | git.branches(repo.name), | |
| 675 | + | git.tags(repo.name), | |
| 635 | 676 | ]); | |
| 636 | 677 | ||
| 637 | 678 | const hasNext = rawCommits.length > COMMITS_PER_PAGE; | |
| @@ -658,6 +699,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 658 | 699 | ref={params.ref} | |
| 659 | 700 | commits={commits} | |
| 660 | 701 | branches={branches} | |
| 702 | + | tags={tags} | |
| 661 | 703 | olderUrl={olderUrl} | |
| 662 | 704 | newerUrl={newerUrl} | |
| 663 | 705 | />, | |
| @@ -880,4 +922,452 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 880 | 922 | { | |
| 881 | 923 | body: t.Object({ id: t.Numeric() }), | |
| 882 | 924 | }, | |
| 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 | + | }, | |
| 883 | 1373 | ); | |
Msrc/services/git.ts
| @@ -193,6 +193,23 @@ export function extractPatchSubject(patch: string): string { | |||
|---|---|---|---|
| 193 | 193 | return ""; | |
| 194 | 194 | } | |
| 195 | 195 | ||
| 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 | + | ||
| 196 | 213 | export interface PatchMeta { | |
| 197 | 214 | subject: string; | |
| 198 | 215 | body: string; | |
| @@ -330,7 +347,7 @@ export const git = { | |||
|---|---|---|---|
| 330 | 347 | async diff(name: string, sha: string): Promise<string> { | |
| 331 | 348 | const p = repoPath(name); | |
| 332 | 349 | 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(); | |
| 334 | 351 | } catch { | |
| 335 | 352 | return ""; | |
| 336 | 353 | } | |
| @@ -359,6 +376,77 @@ export const git = { | |||
|---|---|---|---|
| 359 | 376 | } | |
| 360 | 377 | }, | |
| 361 | 378 | ||
| 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 | + | ||
| 362 | 450 | async defaultBranch(name: string): Promise<string> { | |
| 363 | 451 | const p = repoPath(name); | |
| 364 | 452 | try { | |
| @@ -492,18 +580,188 @@ export const git = { | |||
|---|---|---|---|
| 492 | 580 | message: string, | |
| 493 | 581 | committerName: string, | |
| 494 | 582 | committerEmail: string, | |
| 583 | + | newPath?: string, | |
| 495 | 584 | ): Promise<string> { | |
| 496 | 585 | return withRepoLock(name, async () => { | |
| 586 | + | const targetPath = | |
| 587 | + | newPath && newPath !== filePath ? newPath : filePath; | |
| 588 | + | const isMove = targetPath !== filePath; | |
| 497 | 589 | const p = repoPath(name); | |
| 498 | 590 | const tmpFile = `/tmp/hf-edit-${Date.now()}-${Math.random().toString(36).slice(2)}`; | |
| 499 | 591 | try { | |
| 500 | 592 | 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 | + | } | |
| 502 | 659 | const blobHash = ( | |
| 503 | 660 | await $`git -C ${p} hash-object -w ${tmpFile}`.text() | |
| 504 | 661 | ).trim(); | |
| 505 | 662 | await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${filePath}`; | |
| 506 | 663 | 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(); | |
| 507 | 765 | const parent = ( | |
| 508 | 766 | await $`git -C ${p} rev-parse refs/heads/${branch}`.text() | |
| 509 | 767 | ).trim(); | |
| @@ -571,6 +829,93 @@ export const git = { | |||
|---|---|---|---|
| 571 | 829 | }); | |
| 572 | 830 | }, | |
| 573 | 831 | ||
| 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 | + | ||
| 574 | 919 | async setHead(name: string, branch: string): Promise<void> { | |
| 575 | 920 | const p = repoPath(name); | |
| 576 | 921 | await $`git -C ${p} symbolic-ref HEAD refs/heads/${branch}`; | |
Msrc/styles/code.css
| @@ -72,6 +72,8 @@ | |||
|---|---|---|---|
| 72 | 72 | display: flex; | |
| 73 | 73 | align-items: center; | |
| 74 | 74 | justify-content: space-between; | |
| 75 | + | flex-wrap: wrap; | |
| 76 | + | gap: var(--space-2); | |
| 75 | 77 | padding: var(--space-3) var(--space-4); | |
| 76 | 78 | background: var(--color-bg-subtle); | |
| 77 | 79 | border: 1px solid var(--color-border); | |
| @@ -81,12 +83,19 @@ | |||
|---|---|---|---|
| 81 | 83 | .file-blob-name { | |
| 82 | 84 | font-size: var(--text-sm); | |
| 83 | 85 | font-weight: 500; | |
| 86 | + | min-width: 0; | |
| 87 | + | overflow: hidden; | |
| 88 | + | text-overflow: ellipsis; | |
| 89 | + | white-space: nowrap; | |
| 84 | 90 | } | |
| 85 | 91 | .file-blob-actions { | |
| 86 | 92 | display: flex; | |
| 87 | 93 | align-items: center; | |
| 88 | 94 | gap: var(--space-2); | |
| 89 | 95 | } | |
| 96 | + | .file-blob-actions-wrap { | |
| 97 | + | flex-wrap: wrap; | |
| 98 | + | } | |
| 90 | 99 | .file-blob-body { | |
| 91 | 100 | border: 1px solid var(--color-border); | |
| 92 | 101 | border-top: none; | |
Msrc/styles/components.css
| @@ -376,6 +376,14 @@ | |||
|---|---|---|---|
| 376 | 376 | border-color: var(--color-accent); | |
| 377 | 377 | background: var(--color-accent-bg); | |
| 378 | 378 | } | |
| 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 | + | } | |
| 379 | 387 | .issue-badge, | |
| 380 | 388 | .patch-badge { | |
| 381 | 389 | display: inline-flex; | |
| @@ -444,6 +452,8 @@ | |||
|---|---|---|---|
| 444 | 452 | .breadcrumb a { | |
| 445 | 453 | color: var(--color-link); | |
| 446 | 454 | text-decoration: none; | |
| 455 | + | text-overflow: ellipsis; | |
| 456 | + | overflow: hidden; | |
| 447 | 457 | } | |
| 448 | 458 | .breadcrumb a:hover { | |
| 449 | 459 | text-decoration: underline; | |
| @@ -453,6 +463,8 @@ | |||
|---|---|---|---|
| 453 | 463 | } | |
| 454 | 464 | .breadcrumb-current { | |
| 455 | 465 | color: var(--color-text-muted); | |
| 466 | + | text-overflow: ellipsis; | |
| 467 | + | overflow: hidden; | |
| 456 | 468 | } | |
| 457 | 469 | ||
| 458 | 470 | /* --- Issue / Patch list --- */ | |
| @@ -1796,4 +1808,121 @@ | |||
|---|---|---|---|
| 1796 | 1808 | display: flex; | |
| 1797 | 1809 | gap: var(--space-2); | |
| 1798 | 1810 | } | |
| 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 | + | } | |
| 1799 | 1928 | } | |
Msrc/views/issues/IssueDetail.tsx
| @@ -79,7 +79,9 @@ export function IssueDetail({ | |||
|---|---|---|---|
| 79 | 79 | name="title" | |
| 80 | 80 | value={issue.title} | |
| 81 | 81 | required | |
| 82 | - | maxlength={config.MAX_TITLE_BYTES} | |
| 82 | + | maxlength={ | |
| 83 | + | config.MAX_TITLE_BYTES | |
| 84 | + | } | |
| 83 | 85 | /> | |
| 84 | 86 | <input | |
| 85 | 87 | type="hidden" | |
| @@ -292,7 +294,9 @@ export function IssueDetail({ | |||
|---|---|---|---|
| 292 | 294 | id="edit-issue-body" | |
| 293 | 295 | name="edit_body" | |
| 294 | 296 | rows="6" | |
| 295 | - | maxlength={config.MAX_TEXT_BODY_BYTES} | |
| 297 | + | maxlength={ | |
| 298 | + | config.MAX_TEXT_BODY_BYTES | |
| 299 | + | } | |
| 296 | 300 | > | |
| 297 | 301 | {issue.body} | |
| 298 | 302 | </textarea> | |
| @@ -364,7 +368,9 @@ export function IssueDetail({ | |||
|---|---|---|---|
| 364 | 368 | class="form-input" | |
| 365 | 369 | name="edit_body" | |
| 366 | 370 | rows="6" | |
| 367 | - | maxlength={config.MAX_TEXT_BODY_BYTES} | |
| 371 | + | maxlength={ | |
| 372 | + | config.MAX_TEXT_BODY_BYTES | |
| 373 | + | } | |
| 368 | 374 | > | |
| 369 | 375 | {comment.body} | |
| 370 | 376 | </textarea> | |
Msrc/views/patches/NewPatch.tsx
| @@ -52,7 +52,12 @@ export function NewPatch({ | |||
|---|---|---|---|
| 52 | 52 | (Markdown supported, optional) | |
| 53 | 53 | </span> | |
| 54 | 54 | </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 | + | > | |
| 56 | 61 | {template ?? ""} | |
| 57 | 62 | </textarea> | |
| 58 | 63 | </div> | |
Msrc/views/patches/PatchDetail.tsx
| @@ -100,7 +100,9 @@ export function PatchDetail({ | |||
|---|---|---|---|
| 100 | 100 | name="title" | |
| 101 | 101 | value={patch.title} | |
| 102 | 102 | required | |
| 103 | - | maxlength={config.MAX_TITLE_BYTES} | |
| 103 | + | maxlength={ | |
| 104 | + | config.MAX_TITLE_BYTES | |
| 105 | + | } | |
| 104 | 106 | /> | |
| 105 | 107 | <input | |
| 106 | 108 | type="hidden" | |
| @@ -374,7 +376,9 @@ export function PatchDetail({ | |||
|---|---|---|---|
| 374 | 376 | id="edit-patch-desc" | |
| 375 | 377 | name="edit_description" | |
| 376 | 378 | rows="6" | |
| 377 | - | maxlength={config.MAX_TEXT_BODY_BYTES} | |
| 379 | + | maxlength={ | |
| 380 | + | config.MAX_TEXT_BODY_BYTES | |
| 381 | + | } | |
| 378 | 382 | > | |
| 379 | 383 | {patch.description} | |
| 380 | 384 | </textarea> | |
| @@ -474,7 +478,9 @@ export function PatchDetail({ | |||
|---|---|---|---|
| 474 | 478 | class="form-input" | |
| 475 | 479 | name="edit_body" | |
| 476 | 480 | rows="6" | |
| 477 | - | maxlength={config.MAX_TEXT_BODY_BYTES} | |
| 481 | + | maxlength={ | |
| 482 | + | config.MAX_TEXT_BODY_BYTES | |
| 483 | + | } | |
| 478 | 484 | > | |
| 479 | 485 | {comment.body} | |
| 480 | 486 | </textarea> | |
| @@ -515,7 +521,9 @@ export function PatchDetail({ | |||
|---|---|---|---|
| 515 | 521 | <textarea | |
| 516 | 522 | name="body" | |
| 517 | 523 | rows="6" | |
| 518 | - | maxlength={config.MAX_TEXT_BODY_BYTES} | |
| 524 | + | maxlength={ | |
| 525 | + | config.MAX_TEXT_BODY_BYTES | |
| 526 | + | } | |
| 519 | 527 | placeholder="Leave a comment (Markdown supported)" | |
| 520 | 528 | required | |
| 521 | 529 | /> | |
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 @@ | |||
|---|---|---|---|
| 1 | 1 | interface BranchSelectorProps { | |
| 2 | 2 | repoName: string; | |
| 3 | 3 | branches: string[]; | |
| 4 | + | tags?: string[]; | |
| 4 | 5 | currentRef: string; | |
| 5 | 6 | view: "tree" | "commits" | "blob"; | |
| 6 | 7 | /** subpath for tree, full file path for blob */ | |
| @@ -10,12 +11,14 @@ interface BranchSelectorProps { | |||
|---|---|---|---|
| 10 | 11 | export function BranchSelector({ | |
| 11 | 12 | repoName, | |
| 12 | 13 | branches, | |
| 14 | + | tags = [], | |
| 13 | 15 | currentRef, | |
| 14 | 16 | view, | |
| 15 | 17 | path, | |
| 16 | 18 | }: 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); | |
| 19 | 22 | const shortRef = | |
| 20 | 23 | isDetached && currentRef.length > 8 | |
| 21 | 24 | ? currentRef.slice(0, 8) | |
| @@ -39,14 +42,28 @@ export function BranchSelector({ | |||
|---|---|---|---|
| 39 | 42 | {shortRef} (detached) | |
| 40 | 43 | </option> | |
| 41 | 44 | )} | |
| 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 | + | ) | |
| 50 | 67 | </select> | |
| 51 | 68 | <noscript> | |
| 52 | 69 | <button type="submit" class="btn btn-sm"> | |
Msrc/views/repos/CommitLog.tsx
| @@ -14,6 +14,7 @@ interface CommitLogProps { | |||
|---|---|---|---|
| 14 | 14 | ref: string; | |
| 15 | 15 | commits: CommitEntry[]; | |
| 16 | 16 | branches: string[]; | |
| 17 | + | tags?: string[]; | |
| 17 | 18 | /** URL for the next (older) page, or null if this is the last page. */ | |
| 18 | 19 | olderUrl: string | null; | |
| 19 | 20 | /** URL for the previous (newer) page, or null if this is the first page. */ | |
| @@ -26,6 +27,7 @@ export function CommitLog({ | |||
|---|---|---|---|
| 26 | 27 | ref: logRef, | |
| 27 | 28 | commits, | |
| 28 | 29 | branches, | |
| 30 | + | tags, | |
| 29 | 31 | olderUrl, | |
| 30 | 32 | newerUrl, | |
| 31 | 33 | }: CommitLogProps) { | |
| @@ -39,6 +41,7 @@ export function CommitLog({ | |||
|---|---|---|---|
| 39 | 41 | <BranchSelector | |
| 40 | 42 | repoName={repo.name} | |
| 41 | 43 | branches={branches} | |
| 44 | + | tags={tags} | |
| 42 | 45 | currentRef={logRef} | |
| 43 | 46 | view="commits" | |
| 44 | 47 | /> | |
Msrc/views/repos/FileBlob.tsx
| @@ -13,6 +13,7 @@ interface FileBlobProps { | |||
|---|---|---|---|
| 13 | 13 | filePath: string; | |
| 14 | 14 | view: FileView; | |
| 15 | 15 | branches: string[]; | |
| 16 | + | tags?: string[]; | |
| 16 | 17 | markdownHtml?: string; | |
| 17 | 18 | } | |
| 18 | 19 | ||
| @@ -23,6 +24,7 @@ export function FileBlob({ | |||
|---|---|---|---|
| 23 | 24 | filePath, | |
| 24 | 25 | view, | |
| 25 | 26 | branches, | |
| 27 | + | tags, | |
| 26 | 28 | markdownHtml, | |
| 27 | 29 | }: FileBlobProps) { | |
| 28 | 30 | const parts = filePath.split("/"); | |
| @@ -61,10 +63,11 @@ export function FileBlob({ | |||
|---|---|---|---|
| 61 | 63 | </div> | |
| 62 | 64 | <div class="file-blob-header"> | |
| 63 | 65 | <span class="file-blob-name">{filename}</span> | |
| 64 | - | <div class="file-blob-actions"> | |
| 66 | + | <div class="file-blob-actions file-blob-actions-wrap"> | |
| 65 | 67 | <BranchSelector | |
| 66 | 68 | repoName={repo.name} | |
| 67 | 69 | branches={branches} | |
| 70 | + | tags={tags} | |
| 68 | 71 | currentRef={blobRef} | |
| 69 | 72 | view="blob" | |
| 70 | 73 | path={filePath} | |
| @@ -85,6 +88,37 @@ export function FileBlob({ | |||
|---|---|---|---|
| 85 | 88 | Edit | |
| 86 | 89 | </a> | |
| 87 | 90 | )} | |
| 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 | + | )} | |
| 88 | 122 | </div> | |
| 89 | 123 | </div> | |
| 90 | 124 | <div class="file-blob-body"> | |
Msrc/views/repos/FileEdit.tsx
| @@ -11,6 +11,7 @@ interface FileEditProps { | |||
|---|---|---|---|
| 11 | 11 | filePath: string; | |
| 12 | 12 | content: string; | |
| 13 | 13 | error?: string; | |
| 14 | + | queryError?: string; | |
| 14 | 15 | } | |
| 15 | 16 | ||
| 16 | 17 | export function FileEdit({ | |
| @@ -20,6 +21,7 @@ export function FileEdit({ | |||
|---|---|---|---|
| 20 | 21 | filePath, | |
| 21 | 22 | content, | |
| 22 | 23 | error, | |
| 24 | + | queryError, | |
| 23 | 25 | }: FileEditProps) { | |
| 24 | 26 | const parts = filePath.split("/"); | |
| 25 | 27 | const filename = parts[parts.length - 1] ?? filePath; | |
| @@ -54,7 +56,9 @@ export function FileEdit({ | |||
|---|---|---|---|
| 54 | 56 | <p class="form-hint"> | |
| 55 | 57 | WARNING: Line endings are normalized to LF (\n) on save. | |
| 56 | 58 | </p> | |
| 57 | - | {error && <p class="form-error">{error}</p>} | |
| 59 | + | {(error || queryError) && ( | |
| 60 | + | <p class="form-error">{error ?? queryError}</p> | |
| 61 | + | )} | |
| 58 | 62 | <form | |
| 59 | 63 | method="POST" | |
| 60 | 64 | action={`/${repo.name}/edit/${editRef}/${filePath}`} | |
| @@ -90,6 +94,17 @@ export function FileEdit({ | |||
|---|---|---|---|
| 90 | 94 | > | |
| 91 | 95 | Committing directly to <strong>{editRef}</strong> | |
| 92 | 96 | </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> | |
| 93 | 108 | <div class="form-group"> | |
| 94 | 109 | <label for="message">Commit message</label> | |
| 95 | 110 | <textarea | |
Msrc/views/repos/FileTree.tsx
| @@ -14,6 +14,7 @@ interface FileTreeProps { | |||
|---|---|---|---|
| 14 | 14 | subpath: string; | |
| 15 | 15 | entries: TreeEntry[]; | |
| 16 | 16 | branches: string[]; | |
| 17 | + | tags?: string[]; | |
| 17 | 18 | readmeHtml?: string | null; | |
| 18 | 19 | readmePath?: string; | |
| 19 | 20 | } | |
| @@ -25,6 +26,7 @@ export function FileTree({ | |||
|---|---|---|---|
| 25 | 26 | subpath, | |
| 26 | 27 | entries, | |
| 27 | 28 | branches, | |
| 29 | + | tags, | |
| 28 | 30 | readmeHtml, | |
| 29 | 31 | readmePath, | |
| 30 | 32 | }: FileTreeProps) { | |
| @@ -41,10 +43,19 @@ export function FileTree({ | |||
|---|---|---|---|
| 41 | 43 | <BranchSelector | |
| 42 | 44 | repoName={repo.name} | |
| 43 | 45 | branches={branches} | |
| 46 | + | tags={tags} | |
| 44 | 47 | currentRef={treeRef} | |
| 45 | 48 | view="tree" | |
| 46 | 49 | path={subpath} | |
| 47 | 50 | /> | |
| 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 | + | )} | |
| 48 | 59 | </div> | |
| 49 | 60 | {subpath && ( | |
| 50 | 61 | <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 { | |||
|---|---|---|---|
| 16 | 16 | readmePath?: string; | |
| 17 | 17 | hasContent: boolean; | |
| 18 | 18 | branches: string[]; | |
| 19 | + | tags?: string[]; | |
| 19 | 20 | } | |
| 20 | 21 | ||
| 21 | 22 | export function RepoHome({ | |
| @@ -26,6 +27,7 @@ export function RepoHome({ | |||
|---|---|---|---|
| 26 | 27 | readmePath, | |
| 27 | 28 | hasContent, | |
| 28 | 29 | branches, | |
| 30 | + | tags, | |
| 29 | 31 | }: RepoHomeProps) { | |
| 30 | 32 | return ( | |
| 31 | 33 | <Layout user={user} title={repo.name}> | |
| @@ -54,40 +56,52 @@ git push origin main`}</code> | |||
|---|---|---|---|
| 54 | 56 | <BranchSelector | |
| 55 | 57 | repoName={repo.name} | |
| 56 | 58 | branches={branches} | |
| 59 | + | tags={tags} | |
| 57 | 60 | currentRef={repo.default_branch} | |
| 58 | 61 | view="tree" | |
| 59 | 62 | /> | |
| 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"> | |
| 77 | 78 | <div class="clone-url-row"> | |
| 78 | 79 | <span class="clone-url-label"> | |
| 79 | - | SSH | |
| 80 | + | HTTP | |
| 80 | 81 | </span> | |
| 81 | 82 | <input | |
| 82 | 83 | type="text" | |
| 83 | 84 | readonly | |
| 84 | - | value={sshUrl(repo.name)} | |
| 85 | + | value={httpUrl(repo.name)} | |
| 85 | 86 | class="clone-url-input" | |
| 86 | 87 | /> | |
| 87 | 88 | </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> | |
| 91 | 105 | </div> | |
| 92 | 106 | <FileTreeTable | |
| 93 | 107 | repoName={repo.name} | |
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 | + | }); | |