add single file edit
MREADME.md
| @@ -94,7 +94,7 @@ bun run test # Playwright E2E tests (don't use bun test, it doesn't res | |||
|---|---|---|---|
| 94 | 94 | ## Roadmap | |
| 95 | 95 | - Use [git-bug](https://github.com/git-bug/git-bug) for issue tracking instead of custom implementation | |
| 96 | 96 | - Issue labels | |
| 97 | - | - Repository list reordering (e.g. last committed) and starring | |
| 98 | 97 | - redirect image urls in readme | |
| 99 | - | - simple file editor | |
| 100 | - | - generic diff viewer (show diff for a given path between two refs) | |
| 98 | + | - registration queue | |
| 99 | + | - edit patches | |
| 100 | + | - show ^M in diffs | |
Msrc/routes/repos.tsx
| @@ -8,6 +8,7 @@ import { | |||
|---|---|---|---|
| 8 | 8 | REPOS_PER_PAGE, | |
| 9 | 9 | VALID_REPO_NAME_RE, | |
| 10 | 10 | } from "../constants.ts"; | |
| 11 | + | import { COMMITTER_EMAIL, COMMITTER_NAME } from "../config.ts"; | |
| 11 | 12 | import { db } from "../db/index.ts"; | |
| 12 | 13 | import { requireAdmin, resolveSession } from "../middleware/session.ts"; | |
| 13 | 14 | import { git, repoPath } from "../services/git.ts"; | |
| @@ -23,6 +24,7 @@ import { html } from "../views/render.tsx"; | |||
|---|---|---|---|
| 23 | 24 | import { CommitDetail } from "../views/repos/CommitDetail.tsx"; | |
| 24 | 25 | import { CommitLog } from "../views/repos/CommitLog.tsx"; | |
| 25 | 26 | import { FileBlob } from "../views/repos/FileBlob.tsx"; | |
| 27 | + | import { FileEdit } from "../views/repos/FileEdit.tsx"; | |
| 26 | 28 | import { FileTree } from "../views/repos/FileTree.tsx"; | |
| 27 | 29 | import { NewRepo } from "../views/repos/NewRepo.tsx"; | |
| 28 | 30 | import { RepoHome } from "../views/repos/RepoHome.tsx"; | |
| @@ -490,6 +492,77 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 490 | 492 | }); | |
| 491 | 493 | }) | |
| 492 | 494 | ||
| 495 | + | .get("/:repo/edit/:ref/*", async ({ params, cookie }) => { | |
| 496 | + | const user = await resolveSession(cookie.session.value); | |
| 497 | + | const deny = requireAdmin(user); | |
| 498 | + | if (deny) return deny; | |
| 499 | + | const repo = await getRepo(params.repo, true); | |
| 500 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 501 | + | ||
| 502 | + | const filePath = decodeURIComponent(params["*"]); | |
| 503 | + | const branches = await git.branches(repo.name); | |
| 504 | + | if (!branches.includes(params.ref)) | |
| 505 | + | return new Response("Not found", { status: 404 }); | |
| 506 | + | ||
| 507 | + | const content = await git.show(repo.name, params.ref, filePath); | |
| 508 | + | if (!content) return new Response("Not found", { status: 404 }); | |
| 509 | + | ||
| 510 | + | if (hasBinaryContent(content.subarray(0, 8000))) | |
| 511 | + | return new Response("Not found", { status: 404 }); | |
| 512 | + | ||
| 513 | + | return html( | |
| 514 | + | <FileEdit | |
| 515 | + | user={user!} | |
| 516 | + | repo={repo} | |
| 517 | + | ref={params.ref} | |
| 518 | + | filePath={filePath} | |
| 519 | + | content={content.toString("utf-8")} | |
| 520 | + | />, | |
| 521 | + | ); | |
| 522 | + | }) | |
| 523 | + | ||
| 524 | + | .post( | |
| 525 | + | "/:repo/edit/:ref/*", | |
| 526 | + | async ({ params, body, cookie }) => { | |
| 527 | + | const user = await resolveSession(cookie.session.value); | |
| 528 | + | const deny = requireAdmin(user); | |
| 529 | + | if (deny) return deny; | |
| 530 | + | const repo = await getRepo(params.repo, true); | |
| 531 | + | if (!repo) return new Response("Not found", { status: 404 }); | |
| 532 | + | ||
| 533 | + | const filePath = decodeURIComponent(params["*"]); | |
| 534 | + | const branches = await git.branches(repo.name); | |
| 535 | + | if (!branches.includes(params.ref)) | |
| 536 | + | return new Response("Not found", { status: 404 }); | |
| 537 | + | ||
| 538 | + | const message = body.message?.trim() || `Edited ${path.basename(filePath)}`; | |
| 539 | + | const content = (body.content ?? "").replaceAll("\r\n", "\n"); | |
| 540 | + | ||
| 541 | + | const commit = await git.editFile( | |
| 542 | + | repo.name, | |
| 543 | + | params.ref, | |
| 544 | + | filePath, | |
| 545 | + | content, | |
| 546 | + | message, | |
| 547 | + | COMMITTER_NAME, | |
| 548 | + | COMMITTER_EMAIL, | |
| 549 | + | ); | |
| 550 | + | ||
| 551 | + | return new Response(null, { | |
| 552 | + | status: 302, | |
| 553 | + | headers: { | |
| 554 | + | Location: `/${repo.name}/commit/${commit}`, | |
| 555 | + | }, | |
| 556 | + | }); | |
| 557 | + | }, | |
| 558 | + | { | |
| 559 | + | body: t.Object({ | |
| 560 | + | content: t.Optional(t.String()), | |
| 561 | + | message: t.Optional(t.String()), | |
| 562 | + | }), | |
| 563 | + | }, | |
| 564 | + | ) | |
| 565 | + | ||
| 493 | 566 | .get( | |
| 494 | 567 | "/:repo/commits/:ref", | |
| 495 | 568 | async ({ params, cookie, query }) => { | |
Msrc/services/git.ts
| @@ -470,12 +470,7 @@ export const git = { | |||
|---|---|---|---|
| 470 | 470 | const commit = ( | |
| 471 | 471 | await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${msg}` | |
| 472 | 472 | .env({ | |
| 473 | - | ...process.env, | |
| 474 | - | LC_ALL: "C", | |
| 475 | - | LANG: "C", | |
| 476 | - | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 477 | - | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 478 | - | GIT_CONFIG_COUNT: "0", | |
| 473 | + | ...gitEnv, | |
| 479 | 474 | GIT_AUTHOR_NAME: authorName, | |
| 480 | 475 | GIT_AUTHOR_EMAIL: authorEmail, | |
| 481 | 476 | GIT_COMMITTER_NAME: committerName, | |
| @@ -493,6 +488,56 @@ export const git = { | |||
|---|---|---|---|
| 493 | 488 | }); | |
| 494 | 489 | }, | |
| 495 | 490 | ||
| 491 | + | async editFile( | |
| 492 | + | name: string, | |
| 493 | + | branch: string, | |
| 494 | + | filePath: string, | |
| 495 | + | content: string, | |
| 496 | + | message: string, | |
| 497 | + | committerName: string, | |
| 498 | + | committerEmail: string, | |
| 499 | + | ): Promise<string> { | |
| 500 | + | return withRepoLock(name, async () => { | |
| 501 | + | const p = repoPath(name); | |
| 502 | + | const tmpFile = `/tmp/hf-edit-${Date.now()}-${Math.random().toString(36).slice(2)}`; | |
| 503 | + | try { | |
| 504 | + | await Bun.write(tmpFile, content); | |
| 505 | + | await $`git -C ${p} read-tree refs/heads/${branch}`; | |
| 506 | + | const blobHash = ( | |
| 507 | + | await $`git -C ${p} hash-object -w ${tmpFile}`.text() | |
| 508 | + | ).trim(); | |
| 509 | + | await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${filePath}`; | |
| 510 | + | const tree = ( | |
| 511 | + | await $`git -C ${p} write-tree`.text() | |
| 512 | + | ).trim(); | |
| 513 | + | const parent = ( | |
| 514 | + | await $`git -C ${p} rev-parse refs/heads/${branch}`.text() | |
| 515 | + | ).trim(); | |
| 516 | + | const sigArgs = [ | |
| 517 | + | "-c", | |
| 518 | + | "gpg.format=ssh", | |
| 519 | + | "-c", | |
| 520 | + | `user.signingKey=${SSH_HOST_KEY_PATH}`, | |
| 521 | + | ]; | |
| 522 | + | const commit = ( | |
| 523 | + | await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${message}` | |
| 524 | + | .env({ | |
| 525 | + | ...gitEnv, | |
| 526 | + | GIT_AUTHOR_NAME: committerName, | |
| 527 | + | GIT_AUTHOR_EMAIL: committerEmail, | |
| 528 | + | GIT_COMMITTER_NAME: committerName, | |
| 529 | + | GIT_COMMITTER_EMAIL: committerEmail, | |
| 530 | + | }) | |
| 531 | + | .text() | |
| 532 | + | ).trim(); | |
| 533 | + | await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`; | |
| 534 | + | return commit; | |
| 535 | + | } finally { | |
| 536 | + | await $`rm -f ${tmpFile}`.quiet().nothrow(); | |
| 537 | + | } | |
| 538 | + | }); | |
| 539 | + | }, | |
| 540 | + | ||
| 496 | 541 | async createTag( | |
| 497 | 542 | repoName: string, | |
| 498 | 543 | tagName: string, | |
Msrc/styles/main.css
| @@ -874,6 +874,7 @@ | |||
|---|---|---|---|
| 874 | 874 | background: var(--color-bg-subtle); | |
| 875 | 875 | border: 1px solid var(--color-border); | |
| 876 | 876 | border-radius: var(--radius-lg) var(--radius-lg) 0 0; | |
| 877 | + | margin-top: var(--space-4); | |
| 877 | 878 | } | |
| 878 | 879 | .file-blob-name { | |
| 879 | 880 | font-size: var(--text-sm); | |
| @@ -885,6 +886,24 @@ | |||
|---|---|---|---|
| 885 | 886 | border-radius: 0 0 var(--radius-lg) var(--radius-lg); | |
| 886 | 887 | overflow: auto; | |
| 887 | 888 | } | |
| 889 | + | .file-edit-textarea { | |
| 890 | + | display: block; | |
| 891 | + | width: 100%; | |
| 892 | + | box-sizing: border-box; | |
| 893 | + | font-family: var(--font-mono); | |
| 894 | + | font-size: var(--text-sm); | |
| 895 | + | line-height: 1.6; | |
| 896 | + | padding: var(--space-4); | |
| 897 | + | background: var(--color-bg); | |
| 898 | + | color: var(--color-text); | |
| 899 | + | border: none; | |
| 900 | + | resize: vertical; | |
| 901 | + | min-height: 400px; | |
| 902 | + | } | |
| 903 | + | .file-edit-textarea:focus { | |
| 904 | + | outline: 2px solid var(--color-accent); | |
| 905 | + | outline-offset: -2px; | |
| 906 | + | } | |
| 888 | 907 | /* Blob line-number table */ | |
| 889 | 908 | .blob-table { | |
| 890 | 909 | min-width: 100%; | |
Msrc/views/DiffView.tsx
| @@ -154,7 +154,7 @@ export function DiffView({ files, repo, sha }: DiffViewProps) { | |||
|---|---|---|---|
| 154 | 154 | ||
| 155 | 155 | {files.length === 0 ? ( | |
| 156 | 156 | <p class="text-muted" style="margin-top: var(--space-6)"> | |
| 157 | - | No diff available. | |
| 157 | + | Empty diff or no diff available. | |
| 158 | 158 | </p> | |
| 159 | 159 | ) : ( | |
| 160 | 160 | <div class="commit-layout"> | |
Msrc/views/repos/FileBlob.tsx
| @@ -73,6 +73,16 @@ export function FileBlob({ | |||
|---|---|---|---|
| 73 | 73 | > | |
| 74 | 74 | Raw | |
| 75 | 75 | </a> | |
| 76 | + | {view.type === "inline" && | |
| 77 | + | branches.includes(blobRef) && | |
| 78 | + | user?.isAdmin && ( | |
| 79 | + | <a | |
| 80 | + | href={`/${repo.name}/edit/${blobRef}/${filePath}`} | |
| 81 | + | class="btn btn-sm btn-primary" | |
| 82 | + | > | |
| 83 | + | Edit | |
| 84 | + | </a> | |
| 85 | + | )} | |
| 76 | 86 | </div> | |
| 77 | 87 | </div> | |
| 78 | 88 | <div class="file-blob-body"> | |
Asrc/views/repos/FileEdit.tsx
| @@ -0,0 +1,116 @@ | |||
|---|---|---|---|
| 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 FileEditProps { | |
| 8 | + | user: SessionUser; | |
| 9 | + | repo: RepositoryRow; | |
| 10 | + | ref: string; | |
| 11 | + | filePath: string; | |
| 12 | + | content: string; | |
| 13 | + | error?: string; | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | export function FileEdit({ | |
| 17 | + | user, | |
| 18 | + | repo, | |
| 19 | + | ref: editRef, | |
| 20 | + | filePath, | |
| 21 | + | content, | |
| 22 | + | error, | |
| 23 | + | }: FileEditProps) { | |
| 24 | + | const parts = filePath.split("/"); | |
| 25 | + | const filename = parts[parts.length - 1] ?? filePath; | |
| 26 | + | return ( | |
| 27 | + | <Layout user={user} title={`Edit ${repo.name}/${filePath}`}> | |
| 28 | + | <div class="container"> | |
| 29 | + | <RepoHeader repo={repo} /> | |
| 30 | + | <RepoNav repo={repo} active="code" user={user} /> | |
| 31 | + | <div class="breadcrumb"> | |
| 32 | + | <a href={`/${repo.name}/tree/${editRef}`}>{repo.name}</a> | |
| 33 | + | {parts.map((part, i) => { | |
| 34 | + | const partPath = parts.slice(0, i + 1).join("/"); | |
| 35 | + | const isLast = i === parts.length - 1; | |
| 36 | + | return ( | |
| 37 | + | <> | |
| 38 | + | <span class="breadcrumb-sep">/</span> | |
| 39 | + | {isLast ? ( | |
| 40 | + | <span class="breadcrumb-current"> | |
| 41 | + | {part} | |
| 42 | + | </span> | |
| 43 | + | ) : ( | |
| 44 | + | <a | |
| 45 | + | href={`/${repo.name}/tree/${editRef}/${partPath}`} | |
| 46 | + | > | |
| 47 | + | {part} | |
| 48 | + | </a> | |
| 49 | + | )} | |
| 50 | + | </> | |
| 51 | + | ); | |
| 52 | + | })} | |
| 53 | + | </div> | |
| 54 | + | <p class="form-hint">WARNING: Line endings are normalized to LF (\n) on save.</p> | |
| 55 | + | {error && <p class="form-error">{error}</p>} | |
| 56 | + | <form | |
| 57 | + | method="POST" | |
| 58 | + | action={`/${repo.name}/edit/${editRef}/${filePath}`} | |
| 59 | + | > | |
| 60 | + | <div class="file-blob-header"> | |
| 61 | + | <span class="file-blob-name">{filename}</span> | |
| 62 | + | <div class="file-blob-actions"> | |
| 63 | + | <a | |
| 64 | + | href={`/${repo.name}/blob/${editRef}/${filePath}`} | |
| 65 | + | class="btn btn-sm btn-ghost" | |
| 66 | + | > | |
| 67 | + | Cancel | |
| 68 | + | </a> | |
| 69 | + | </div> | |
| 70 | + | </div> | |
| 71 | + | <div class="file-blob-body"> | |
| 72 | + | <textarea | |
| 73 | + | name="content" | |
| 74 | + | class="file-edit-textarea" | |
| 75 | + | rows="30" | |
| 76 | + | spellcheck="false" | |
| 77 | + | autocomplete="off" | |
| 78 | + | autocorrect="off" | |
| 79 | + | autocapitalize="off" | |
| 80 | + | > | |
| 81 | + | {content} | |
| 82 | + | </textarea> | |
| 83 | + | </div> | |
| 84 | + | <div class="form-card"> | |
| 85 | + | <p class="form-hint" style="margin-bottom: var(--space-4);"> | |
| 86 | + | Committing directly to{" "} | |
| 87 | + | <strong>{editRef}</strong> | |
| 88 | + | </p> | |
| 89 | + | <div class="form-group"> | |
| 90 | + | <label for="message">Commit message</label> | |
| 91 | + | <textarea | |
| 92 | + | id="message" | |
| 93 | + | name="message" | |
| 94 | + | rows="3" | |
| 95 | + | required | |
| 96 | + | > | |
| 97 | + | {`Edited ${filename}`} | |
| 98 | + | </textarea> | |
| 99 | + | </div> | |
| 100 | + | <div class="form-actions"> | |
| 101 | + | <button type="submit" class="btn btn-primary"> | |
| 102 | + | Commit changes | |
| 103 | + | </button> | |
| 104 | + | <a | |
| 105 | + | href={`/${repo.name}/blob/${editRef}/${filePath}`} | |
| 106 | + | class="btn btn-ghost" | |
| 107 | + | > | |
| 108 | + | Cancel | |
| 109 | + | </a> | |
| 110 | + | </div> | |
| 111 | + | </div> | |
| 112 | + | </form> | |
| 113 | + | </div> | |
| 114 | + | </Layout> | |
| 115 | + | ); | |
| 116 | + | } | |
Mtests/e2e.test.ts
| @@ -2590,3 +2590,125 @@ describe('repo sorting and pinning', () => { | |||
|---|---|---|---|
| 2590 | 2590 | } finally { await page.close(); } | |
| 2591 | 2591 | }); | |
| 2592 | 2592 | }); | |
| 2593 | + | ||
| 2594 | + | // ─── File editing ───────────────────────────────────────────────────────────── | |
| 2595 | + | ||
| 2596 | + | describe('file editing', () => { | |
| 2597 | + | let adminCtx: BrowserContext; | |
| 2598 | + | ||
| 2599 | + | beforeAll(async () => { | |
| 2600 | + | adminCtx = await loggedInContext(); | |
| 2601 | + | // Create a dedicated repo so edits don't interfere with other tests | |
| 2602 | + | const page = await adminCtx.newPage(); | |
| 2603 | + | try { | |
| 2604 | + | await page.goto(`${BASE}/new`); | |
| 2605 | + | await page.fill('[name=name]', 'edit-repo'); | |
| 2606 | + | await page.click('form[action="/new"] button[type=submit]'); | |
| 2607 | + | await page.waitForURL(`${BASE}/edit-repo`); | |
| 2608 | + | } finally { await page.close(); } | |
| 2609 | + | await seedRepo('edit-repo'); | |
| 2610 | + | }); | |
| 2611 | + | ||
| 2612 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 2613 | + | ||
| 2614 | + | test('Edit button appears on text file blob when viewing a branch as admin', async () => { | |
| 2615 | + | const page = await adminCtx.newPage(); | |
| 2616 | + | try { | |
| 2617 | + | await page.goto(`${BASE}/edit-repo/blob/main/index.js`); | |
| 2618 | + | const editBtn = page.locator('a[href*="/edit/main/index.js"]'); | |
| 2619 | + | expect(await editBtn.isVisible()).toBe(true); | |
| 2620 | + | expect(await editBtn.textContent()).toBe('Edit'); | |
| 2621 | + | } finally { await page.close(); } | |
| 2622 | + | }); | |
| 2623 | + | ||
| 2624 | + | test('Edit button does not appear when viewing a commit SHA', async () => { | |
| 2625 | + | const sha = await getHeadCommit('edit-repo'); | |
| 2626 | + | const page = await adminCtx.newPage(); | |
| 2627 | + | try { | |
| 2628 | + | await page.goto(`${BASE}/edit-repo/blob/${sha}/index.js`); | |
| 2629 | + | expect(await page.locator('a[href*="/edit/"]').count()).toBe(0); | |
| 2630 | + | } finally { await page.close(); } | |
| 2631 | + | }); | |
| 2632 | + | ||
| 2633 | + | test('Edit button does not appear for unauthenticated visitors', async () => { | |
| 2634 | + | const ctx = await browser.newContext(); | |
| 2635 | + | const page = await ctx.newPage(); | |
| 2636 | + | try { | |
| 2637 | + | await page.goto(`${BASE}/edit-repo/blob/main/index.js`); | |
| 2638 | + | expect(await page.locator('a[href*="/edit/main/"]').count()).toBe(0); | |
| 2639 | + | } finally { | |
| 2640 | + | await page.close(); | |
| 2641 | + | await ctx.close(); | |
| 2642 | + | } | |
| 2643 | + | }); | |
| 2644 | + | ||
| 2645 | + | test('edit page loads with file content pre-filled', async () => { | |
| 2646 | + | const page = await adminCtx.newPage(); | |
| 2647 | + | try { | |
| 2648 | + | await page.goto(`${BASE}/edit-repo/edit/main/index.js`); | |
| 2649 | + | expect(await page.locator('.file-blob-name').textContent()).toBe('index.js'); | |
| 2650 | + | const content = await page.locator('textarea[name=content]').inputValue(); | |
| 2651 | + | expect(content).toContain('hello'); | |
| 2652 | + | const msg = await page.locator('textarea[name=message]').inputValue(); | |
| 2653 | + | expect(msg).toBe('Edited index.js'); | |
| 2654 | + | } finally { await page.close(); } | |
| 2655 | + | }); | |
| 2656 | + | ||
| 2657 | + | test('edit page shows which branch will be committed to', async () => { | |
| 2658 | + | const page = await adminCtx.newPage(); | |
| 2659 | + | try { | |
| 2660 | + | await page.goto(`${BASE}/edit-repo/edit/main/index.js`); | |
| 2661 | + | expect(await page.content()).toContain('main'); | |
| 2662 | + | } finally { await page.close(); } | |
| 2663 | + | }); | |
| 2664 | + | ||
| 2665 | + | test('edit page returns 404 for non-branch ref', async () => { | |
| 2666 | + | const sha = await getHeadCommit('edit-repo'); | |
| 2667 | + | const page = await adminCtx.newPage(); | |
| 2668 | + | try { | |
| 2669 | + | const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`); | |
| 2670 | + | expect(resp.status()).toBe(404); | |
| 2671 | + | } finally { await page.close(); } | |
| 2672 | + | }); | |
| 2673 | + | ||
| 2674 | + | test('submitting edit creates a new commit and redirects to blob view', async () => { | |
| 2675 | + | const page = await adminCtx.newPage(); | |
| 2676 | + | try { | |
| 2677 | + | await page.goto(`${BASE}/edit-repo/edit/main/index.js`); | |
| 2678 | + | await page.fill('textarea[name=content]', 'console.log("edited");\n'); | |
| 2679 | + | await page.fill('textarea[name=message]', 'Update index.js via web editor'); | |
| 2680 | + | await page.locator('.form-actions button[type=submit]').click(); | |
| 2681 | + | await page.waitForURL(/\/edit-repo\/commit\/[0-9a-f]{40}/); | |
| 2682 | + | // The commit detail view should show the commit message | |
| 2683 | + | expect(await page.content()).toContain('Update index.js via web editor'); | |
| 2684 | + | } finally { await page.close(); } | |
| 2685 | + | }); | |
| 2686 | + | ||
| 2687 | + | test('edit commit has a gpgsig header (is signed)', async () => { | |
| 2688 | + | const repoDir = `${process.cwd()}/${DATA_DIR}/repos/edit-repo.git`; | |
| 2689 | + | const hash = ( | |
| 2690 | + | await $`git -C ${repoDir} log --format=%H --grep="Update index.js via web editor" -1`.quiet() | |
| 2691 | + | ).text().trim(); | |
| 2692 | + | expect(hash).toBeTruthy(); | |
| 2693 | + | const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text(); | |
| 2694 | + | expect(obj).toContain('gpgsig'); | |
| 2695 | + | }); | |
| 2696 | + | ||
| 2697 | + | test('edit commit shows verified badge in commit log', async () => { | |
| 2698 | + | const page = await adminCtx.newPage(); | |
| 2699 | + | try { | |
| 2700 | + | await page.goto(`${BASE}/edit-repo/commits/main`); | |
| 2701 | + | const item = page.locator('.commit-item').filter({ hasText: 'Update index.js via web editor' }); | |
| 2702 | + | expect(await item.locator('.sig-badge.verified').isVisible()).toBe(true); | |
| 2703 | + | } finally { await page.close(); } | |
| 2704 | + | }); | |
| 2705 | + | ||
| 2706 | + | test('GET edit page returns 404 for non-branch ref', async () => { | |
| 2707 | + | const sha = await getHeadCommit('edit-repo'); | |
| 2708 | + | const page = await adminCtx.newPage(); | |
| 2709 | + | try { | |
| 2710 | + | const resp = await page.request.get(`${BASE}/edit-repo/edit/${sha}/index.js`); | |
| 2711 | + | expect(resp.status()).toBe(404); | |
| 2712 | + | } finally { await page.close(); } | |
| 2713 | + | }); | |
| 2714 | + | }); | |