add patch commit signing, show signatures in commit list and detail views

AuthorKonata <konata@posteo.jp>
Date
Commit7353a64831551c74b9493739f311ffaecbcf4f37
Parent1949c20
15 files changed, 333 insertions(+), 65 deletions(-)
MREADME.md
@@ -13,6 +13,7 @@ Frontend works without any JS at all enabled, just required for WebAuthn (with g
1313 - **Releases** — releases with source archives, extra uploaded assets, and optional tag creation
1414 - **SSH push/pull** — built-in SSH server, no external git daemon needed
1515 - **Auth** — password login or passkeys (WebAuthn/FIDO2)
16+- **Commit signing** — merged patches automatically signed and verification badges are shown in the commit list view
1617 - **Optional registration** — others can create accounts to file issues and patches; can be disabled
1718
1819 ## Stack
@@ -95,4 +96,5 @@ bun run test # Playwright E2E tests (don't use bun test, it doesn't res
9596 - Issue labels
9697 - Repository list reordering (e.g. last committed) and starring
9798 - redirect image urls in readme
98-- commit signing
99+- simple file editor
100+- generic diff viewer (show diff for a given path between two refs)
Msrc/config.ts
@@ -22,3 +22,5 @@ export const COMMITTER_NAME = process.env.COMMITTER_NAME ?? OWNER_DISPLAY_NAME;
2222 export const COMMITTER_EMAIL =
2323 process.env.COMMITTER_EMAIL ??
2424 `${OWNER_DISPLAY_NAME}@${new URL(BASE_URL).hostname}`;
25+export const EXTRA_ALLOWED_SIGNERS_PATH =
26+ process.env.EXTRA_ALLOWED_SIGNERS_PATH ?? null;
Msrc/constants.ts
@@ -28,7 +28,7 @@ export const CHALLENGE_TTL_MS = 5 * 60 * 1000;
2828
2929 // Pagination
3030 export const REPOS_PER_PAGE = 20;
31-export const COMMITS_PER_PAGE = 30;
31+export const COMMITS_PER_PAGE = 20;
3232 export const ISSUES_PER_PAGE = 20;
3333 export const PATCHES_PER_PAGE = 20;
3434 export const RELEASES_PER_PAGE = 20;
@@ -44,4 +44,6 @@ export const DB_PATH = path.join(DATA_DIR, "hearthforge.db");
4444 export const REPOS_DIR = path.join(DATA_DIR, "repos");
4545 export const AVATARS_DIR = path.join(DATA_DIR, "avatars");
4646 export const RELEASES_DIR = path.join(DATA_DIR, "releases");
47-export const SSH_HOST_KEY_PATH = path.join(DATA_DIR, "ssh_host_key");
47+export const SSH_HOST_KEY_PATH =
48+ process.env.SSH_HOST_KEY_PATH ?? path.join(DATA_DIR, "ssh_host_key");
49+export const ALLOWED_SIGNERS_PATH = path.join(DATA_DIR, "allowed_signers");
Msrc/routes/issues.tsx
@@ -108,7 +108,13 @@ export const issueRoutes = new Elysia()
108108 if (deny) return deny;
109109 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
110110 if (!repo) return new Response("Not found", { status: 404 });
111- return html(<NewIssue user={user!} repo={repo} template={repo.issue_template ?? undefined} />);
111+ return html(
112+ <NewIssue
113+ user={user!}
114+ repo={repo}
115+ template={repo.issue_template ?? undefined}
116+ />,
117+ );
112118 })
113119
114120 .post(
Msrc/routes/patches.tsx
@@ -144,7 +144,13 @@ export const patchRoutes = new Elysia()
144144 if (deny) return deny;
145145 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
146146 if (!repo) return new Response("Not found", { status: 404 });
147- return html(<NewPatch user={user!} repo={repo} template={repo.patch_template ?? undefined} />);
147+ return html(
148+ <NewPatch
149+ user={user!}
150+ repo={repo}
151+ template={repo.patch_template ?? undefined}
152+ />,
153+ );
148154 })
149155
150156 .post(
Msrc/routes/repos.tsx
@@ -1,8 +1,9 @@
1-import { rmSync } from "node:fs";
1+import { readFileSync, rmSync } from "node:fs";
22 import path from "node:path";
33 import { Elysia, t } from "elysia";
44 import { fileTypeFromBuffer } from "file-type";
55 import {
6+ ALLOWED_SIGNERS_PATH,
67 COMMITS_PER_PAGE,
78 REPOS_PER_PAGE,
89 VALID_REPO_NAME_RE,
@@ -58,6 +59,11 @@ async function readReadme(
5859 }
5960
6061 export const repoRoutes = new Elysia()
62+ .get("/allowed_signers", () => {
63+ return new Response(readFileSync(ALLOWED_SIGNERS_PATH), {
64+ headers: { "Content-Type": "text/plain; charset=utf-8" },
65+ });
66+ })
6167 .guard({
6268 cookie: t.Cookie({ session: t.Optional(t.String()) }),
6369 })
@@ -569,7 +575,13 @@ export const repoRoutes = new Elysia()
569575 const repo = await getRepo(params.repo, true);
570576 if (!repo) return new Response("Not found", { status: 404 });
571577
572- const { description, is_private, default_branch, issue_template, patch_template } = body;
578+ const {
579+ description,
580+ is_private,
581+ default_branch,
582+ issue_template,
583+ patch_template,
584+ } = body;
573585
574586 const branches = await git.branches(repo.name);
575587 const newBranch = default_branch?.trim() || repo.default_branch;
Msrc/services/git.ts
@@ -1,7 +1,11 @@
11 import path from "node:path";
22 import { $ as _$ } from "bun";
33
4-import { REPOS_DIR } from "../constants.ts";
4+import {
5+ ALLOWED_SIGNERS_PATH,
6+ REPOS_DIR,
7+ SSH_HOST_KEY_PATH,
8+} from "../constants.ts";
59
610 const gitEnv = {
711 ...process.env,
@@ -116,6 +120,7 @@ export interface CommitEntry {
116120 subject: string;
117121 author: string;
118122 date: string;
123+ sigStatus: "good" | "bad" | "none";
119124 }
120125
121126 export interface CommitMeta {
@@ -129,6 +134,7 @@ export interface CommitMeta {
129134 committerEmail: string;
130135 committerDate: string;
131136 parents: string[];
137+ sigStatus: "good" | "bad" | "none";
132138 }
133139
134140 export interface TreeEntry {
@@ -139,6 +145,13 @@ export interface TreeEntry {
139145 name: string;
140146 }
141147
148+function parseSigStatus(code: string): "good" | "bad" | "none" {
149+ if (code === "G" || code === "X" || code === "Y" || code === "R")
150+ return "good";
151+ if (code === "B" || code === "U" || code === "E") return "bad";
152+ return "none";
153+}
154+
142155 function parseLog(out: string): CommitEntry[] {
143156 return out
144157 .split("\n")
@@ -150,6 +163,7 @@ function parseLog(out: string): CommitEntry[] {
150163 subject: parts[1] ?? "",
151164 author: parts[2] ?? "",
152165 date: parts[3] ?? "",
166+ sigStatus: parseSigStatus(parts[4] ?? ""),
153167 };
154168 });
155169 }
@@ -250,9 +264,15 @@ export const git = {
250264 skip = 0,
251265 ): Promise<CommitEntry[]> {
252266 const p = repoPath(name);
267+ const sigArgs = [
268+ "-c",
269+ "gpg.format=ssh",
270+ "-c",
271+ `gpg.ssh.allowedSignersFile=${ALLOWED_SIGNERS_PATH}`,
272+ ];
253273 try {
254274 const out =
255- await $`git -C ${p} log ${ref} --format=%H%x1f%s%x1f%an%x1f%ai --max-count=${limit} --skip=${skip}`.text();
275+ await $`git ${sigArgs} -C ${p} log ${ref} --format=%H%x1f%s%x1f%an%x1f%ai%x1f%G? --max-count=${limit} --skip=${skip}`.text();
256276 return parseLog(out);
257277 } catch {
258278 return [];
@@ -441,12 +461,21 @@ export const git = {
441461 await $`git -C ${p} rev-parse HEAD`.text()
442462 ).trim();
443463 const msg = extractPatchSubject(patchContent);
464+ const sigArgs = [
465+ "-c",
466+ "gpg.format=ssh",
467+ "-c",
468+ `user.signingKey=${SSH_HOST_KEY_PATH}`,
469+ ];
444470 const commit = (
445- await $`git -C ${p} commit-tree ${tree} -p ${parent} -m ${msg}`
471+ await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${msg}`
446472 .env({
447473 ...process.env,
448474 LC_ALL: "C",
449475 LANG: "C",
476+ GIT_CONFIG_GLOBAL: "/dev/null",
477+ GIT_CONFIG_SYSTEM: "/dev/null",
478+ GIT_CONFIG_COUNT: "0",
450479 GIT_AUTHOR_NAME: authorName,
451480 GIT_AUTHOR_EMAIL: authorEmail,
452481 GIT_COMMITTER_NAME: committerName,
@@ -474,9 +503,15 @@ export const git = {
474503 ): Promise<"ok" | "already_exists" | "bad_ref" | "error"> {
475504 return withRepoLock(repoName, async () => {
476505 const p = repoPath(repoName);
506+ const sigArgs = [
507+ "-c",
508+ "gpg.format=ssh",
509+ "-c",
510+ `user.signingKey=${SSH_HOST_KEY_PATH}`,
511+ ];
477512 const result =
478513 message !== undefined
479- ? await $`git -C ${p} tag -a ${tagName} ${ref} -m ${message}`
514+ ? await $`git ${sigArgs} -C ${p} tag -s ${tagName} ${ref} -m ${message}`
480515 .env({
481516 ...gitEnv,
482517 GIT_COMMITTER_NAME: taggerName!,
@@ -524,9 +559,15 @@ export const git = {
524559
525560 async commitMeta(name: string, sha: string): Promise<CommitMeta | null> {
526561 const p = repoPath(name);
562+ const sigArgs = [
563+ "-c",
564+ "gpg.format=ssh",
565+ "-c",
566+ `gpg.ssh.allowedSignersFile=${ALLOWED_SIGNERS_PATH}`,
567+ ];
527568 try {
528569 const [metaOut, msgOut] = await Promise.all([
529- $`git -C ${p} show --no-patch --format=%H%x1f%an%x1f%ae%x1f%ai%x1f%cn%x1f%ce%x1f%ci%x1f%P ${sha}`.text(),
570+ $`git ${sigArgs} -C ${p} show --no-patch --format=%H%x1f%an%x1f%ae%x1f%ai%x1f%cn%x1f%ce%x1f%ci%x1f%P%x1f%G? ${sha}`.text(),
530571 $`git -C ${p} log --format=%B -1 ${sha}`.text(),
531572 ]);
532573 const parts = metaOut.trim().split("\x1f");
@@ -551,6 +592,7 @@ export const git = {
551592 committerEmail: parts[5] ?? "",
552593 committerDate: parts[6] ?? "",
553594 parents: (parts[7] ?? "").trim().split(/\s+/).filter(Boolean),
595+ sigStatus: parseSigStatus(parts[8] ?? ""),
554596 };
555597 } catch {
556598 return null;
Msrc/services/repoSync.ts
@@ -2,12 +2,20 @@ import {
22 type Dirent,
33 existsSync,
44 readdirSync,
5+ readFileSync,
56 renameSync,
67 rmSync,
78 } from "node:fs";
89 import path from "node:path";
910 import { $ } from "bun";
10-import { RELEASES_DIR, REPOS_DIR, VALID_REPO_NAME_RE } from "../constants.ts";
11+import { BASE_URL, EXTRA_ALLOWED_SIGNERS_PATH } from "../config.ts";
12+import {
13+ ALLOWED_SIGNERS_PATH,
14+ RELEASES_DIR,
15+ REPOS_DIR,
16+ SSH_HOST_KEY_PATH,
17+ VALID_REPO_NAME_RE,
18+} from "../constants.ts";
1119 import type { RepositoryRow } from "../db/index.ts";
1220 import { db } from "../db/index.ts";
1321 import { git, repoPath } from "../services/git.ts";
@@ -106,7 +114,67 @@ export async function ensureRepoRecord(name: string): Promise<RepositoryRow> {
106114 .executeTakeFirstOrThrow();
107115 }
108116
117+async function ensureSigningSetup(): Promise<void> {
118+ const hostname = new URL(BASE_URL).hostname;
119+ const pubKeyPath = `${SSH_HOST_KEY_PATH}.pub`;
120+
121+ if (!existsSync(SSH_HOST_KEY_PATH)) {
122+ await Bun.spawn([
123+ "ssh-keygen",
124+ "-t",
125+ "ed25519",
126+ "-N",
127+ "",
128+ "-f",
129+ SSH_HOST_KEY_PATH,
130+ "-C",
131+ hostname,
132+ ]).exited;
133+ console.log("Generated SSH host key at", SSH_HOST_KEY_PATH);
134+ } else {
135+ try {
136+ const existing = readFileSync(pubKeyPath, "utf8").trim();
137+ const keyHostname = existing.split(/\s+/)[2] ?? "";
138+ if (keyHostname !== hostname) {
139+ console.warn(
140+ `Warning: SSH host key comment "${keyHostname}" does not match` +
141+ ` current hostname "${hostname}". The key was likely generated` +
142+ ` for a different BASE_URL. Commit signatures may show an` +
143+ ` unexpected identity.`,
144+ );
145+ }
146+ } catch {
147+ // .pub file missing or unreadable — handled below
148+ }
149+ }
150+
151+ let pubKey: string;
152+ try {
153+ pubKey = readFileSync(pubKeyPath, "utf8").trim();
154+ } catch {
155+ console.warn("Could not read SSH public key at", pubKeyPath);
156+ return;
157+ }
158+
159+ let content = `* namespaces="git" ${pubKey}\n`;
160+
161+ if (EXTRA_ALLOWED_SIGNERS_PATH) {
162+ try {
163+ const extra = readFileSync(EXTRA_ALLOWED_SIGNERS_PATH, "utf8");
164+ content += extra.endsWith("\n") ? extra : `${extra}\n`;
165+ } catch {
166+ console.warn(
167+ "Could not read EXTRA_ALLOWED_SIGNERS_PATH:",
168+ EXTRA_ALLOWED_SIGNERS_PATH,
169+ );
170+ }
171+ }
172+
173+ await Bun.write(ALLOWED_SIGNERS_PATH, content);
174+}
175+
109176 export async function syncStartup(): Promise<void> {
177+ await ensureSigningSetup();
110178 await convertNonBareRepos();
111179 const diskNames = new Set(listDiskRepoNames());
112180 const dbRepos = await db
Msrc/services/sshServer.ts
@@ -1,6 +1,6 @@
11 import { type ChildProcess, spawn } from "node:child_process";
22 import { createHash } from "node:crypto";
3-import { existsSync, readFileSync } from "node:fs";
3+import { readFileSync } from "node:fs";
44 import path from "node:path";
55 import { Server, utils } from "ssh2";
66 import { SSH_PORT } from "../config.ts";
@@ -33,21 +33,6 @@ export function fingerprintFromLine(pubkeyLine: string): string | null {
3333 }
3434
3535 export async function startSshServer() {
36- if (!existsSync(SSH_HOST_KEY_PATH)) {
37- await Bun.spawn([
38- "ssh-keygen",
39- "-t",
40- "ed25519",
41- "-N",
42- "",
43- "-f",
44- SSH_HOST_KEY_PATH,
45- "-C",
46- "hearthforge-host",
47- ]).exited;
48- console.log("Generated SSH host key at", SSH_HOST_KEY_PATH);
49- }
50-
5136 const hostKey = readFileSync(SSH_HOST_KEY_PATH);
5237
5338 const server = new Server({ hostKeys: [hostKey] }, (client) => {
Msrc/styles/main.css
@@ -741,6 +741,22 @@
741741 background: var(--color-merged-bg);
742742 color: var(--color-merged);
743743 }
744+ .sig-badge {
745+ display: inline-flex;
746+ align-items: center;
747+ padding: var(--space-1) var(--space-3);
748+ border-radius: var(--radius-full);
749+ font-size: var(--text-xs);
750+ font-weight: 500;
751+ }
752+ .sig-badge.verified {
753+ background: var(--color-success-bg);
754+ color: var(--color-success);
755+ }
756+ .sig-badge.unverified {
757+ background: var(--color-danger-bg);
758+ color: var(--color-danger);
759+ }
744760
745761 /* --- Empty state --- */
746762 .empty-state {
@@ -939,20 +955,14 @@
939955 }
940956 .commit-item {
941957 display: flex;
942- align-items: flex-start;
943- justify-content: space-between;
958+ flex-direction: column;
944959 padding: var(--space-3) 0;
945960 border-bottom: 1px solid var(--color-border-muted);
946- gap: var(--space-4);
947- flex-wrap: wrap;
961+ gap: var(--space-2);
948962 }
949963 .commit-item:last-child {
950964 border-bottom: none;
951965 }
952- .commit-main {
953- flex: 1;
954- min-width: 0;
955- }
956966 .commit-subject {
957967 color: var(--color-text);
958968 text-decoration: none;
@@ -965,9 +975,15 @@
965975 .commit-meta {
966976 display: flex;
967977 align-items: center;
978+ justify-content: space-between;
968979 gap: var(--space-3);
969980 font-size: var(--text-xs);
970981 color: var(--color-text-muted);
982+ }
983+ .commit-meta-right {
984+ display: flex;
985+ align-items: center;
986+ gap: var(--space-3);
971987 white-space: nowrap;
972988 }
973989 .commit-hash {
@@ -2392,6 +2408,14 @@
23922408 /* ============================================================
23932409 Pagination
23942410 ============================================================ */
2411+.commit-verify-hint {
2412+ margin-bottom: var(--space-4);
2413+ font-size: var(--text-xs);
2414+ color: var(--color-text-muted);
2415+}
2416+.commit-verify-hint code {
2417+ word-break: break-all;
2418+}
23952419 .commit-cursor-nav {
23962420 display: flex;
23972421 justify-content: space-between;
Msrc/views/patches/NewPatch.tsx
@@ -42,11 +42,7 @@ export function NewPatch({ user, repo, error, template }: NewPatchProps) {
4242 (Markdown supported, optional)
4343 </span>
4444 </label>
45- <textarea
46- id="description"
47- name="description"
48- rows="5"
49- >
45+ <textarea id="description" name="description" rows="5">
5046 {template ?? ""}
5147 </textarea>
5248 </div>
Msrc/views/repos/CommitDetail.tsx
@@ -118,6 +118,22 @@ export function CommitDetail({
118118 </span>
119119 </div>
120120 )}
121+ {meta.sigStatus !== "none" && (
122+ <div class="commit-card-meta-row">
123+ <span class="commit-meta-label">Signature</span>
124+ <span class="commit-meta-value">
125+ {meta.sigStatus === "good" ? (
126+ <span class="sig-badge verified">
127+ verified
128+ </span>
129+ ) : (
130+ <span class="sig-badge unverified">
131+ unverified
132+ </span>
133+ )}
134+ </span>
135+ </div>
136+ )}
121137 </div>
122138 </div>
123139
Msrc/views/repos/CommitLog.tsx
@@ -46,34 +46,59 @@ export function CommitLog({
4646 {commits.length === 0 ? (
4747 <p class="text-muted">No commits yet.</p>
4848 ) : (
49- <ul class="commit-list commit-log">
50- {commits.map((c) => (
51- <li class="commit-item">
52- <div class="commit-main">
49+ <>
50+ <p class="commit-verify-hint">
51+ Note: To verify signed commits locally, download the{" "}
52+ <a href="/allowed_signers">allowed signers file</a>{" "}
53+ and run:{" "}
54+ <code class="mono">
55+ git -c gpg.format=ssh -c
56+ gpg.ssh.allowedSignersFile=allowed_signers
57+ verify-commit &lt;hash&gt;
58+ </code>
59+ </p>
60+ <ul class="commit-list commit-log">
61+ {commits.map((c) => (
62+ <li class="commit-item">
5363 <a
5464 href={`/${repo.name}/commit/${c.hash}`}
5565 class="commit-subject"
5666 >
5767 {c.subject}
5868 </a>
59- </div>
60- <div class="commit-meta">
61- <span class="commit-author">
62- {c.author}
63- </span>
64- <a
65- href={`/${repo.name}/commit/${c.hash}`}
66- class="commit-hash mono"
67- >
68- {c.hash.slice(0, 7)}
69- </a>
70- <time class="commit-date" datetime={c.date}>
71- {formatDateTime(c.date)}
72- </time>
73- </div>
74- </li>
75- ))}
76- </ul>
69+ <div class="commit-meta">
70+ <span class="commit-author">
71+ {c.author}
72+ </span>
73+ <span class="commit-meta-right">
74+ {c.sigStatus === "good" && (
75+ <span class="sig-badge verified">
76+ verified
77+ </span>
78+ )}
79+ {c.sigStatus === "bad" && (
80+ <span class="sig-badge unverified">
81+ unverified
82+ </span>
83+ )}
84+ <a
85+ href={`/${repo.name}/commit/${c.hash}`}
86+ class="commit-hash mono"
87+ >
88+ {c.hash.slice(0, 7)}
89+ </a>
90+ <time
91+ class="commit-date"
92+ datetime={c.date}
93+ >
94+ {formatDateTime(c.date)}
95+ </time>
96+ </span>
97+ </div>
98+ </li>
99+ ))}
100+ </ul>
101+ </>
77102 )}
78103 {(newerUrl || olderUrl) && (
79104 <nav
Msrc/views/repos/RepoSettings.tsx
@@ -100,7 +100,8 @@ export function RepoSettings({
100100 <label for="patch_template">
101101 Patch template{" "}
102102 <span class="text-muted">
103- (Markdown, prefilled when submitting a new patch)
103+ (Markdown, prefilled when submitting a new
104+ patch)
104105 </span>
105106 </label>
106107 <textarea
Mtests/e2e.test.ts
@@ -2,8 +2,10 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
22 import { chromium } from 'playwright';
33 import type { Browser, BrowserContext } from 'playwright';
44 import { $ } from 'bun';
5+import { existsSync, readFileSync } from 'node:fs';
56 import {
67 BASE,
8+ DATA_DIR,
79 ADMIN_PASS,
810 setupTestEnv,
911 spawnServer,
@@ -2363,3 +2365,82 @@ describe('issue and patch templates', () => {
23632365 } finally { await page.close(); }
23642366 });
23652367 });
2368+
2369+// ─── Commit signing ───────────────────────────────────────────────────────────
2370+// Depends on the 'patches' block having already merged CLEAN_PATCH into my-repo.
2371+
2372+describe('commit signing', () => {
2373+ let adminCtx: BrowserContext;
2374+
2375+ beforeAll(async () => { adminCtx = await loggedInContext(); });
2376+ afterAll(async () => { await adminCtx.close(); });
2377+
2378+ test('allowed_signers file is generated at startup', () => {
2379+ const allowedSignersPath = `${process.cwd()}/${DATA_DIR}/allowed_signers`;
2380+ expect(existsSync(allowedSignersPath)).toBe(true);
2381+ const content = readFileSync(allowedSignersPath, 'utf8');
2382+ expect(content).toContain('namespaces="git"');
2383+ expect(content).toContain('ssh-ed25519');
2384+ });
2385+
2386+ test('merged commit has a gpgsig header', async () => {
2387+ const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
2388+ // Find the patch commit specifically by subject
2389+ const hash = (await $`git -C ${repoDir} log --format=%H --grep="Add patch-test.txt" -1`.quiet()).text().trim();
2390+ expect(hash).toBeTruthy();
2391+ const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text();
2392+ expect(obj).toContain('gpgsig');
2393+ });
2394+
2395+ test('unsigned commits have no gpgsig header', async () => {
2396+ const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
2397+ // Initial commit was created by seedRepo (plain git commit, not hearthforge)
2398+ const hash = (await $`git -C ${repoDir} log --format=%H --grep="Initial commit" -1`.quiet()).text().trim();
2399+ expect(hash).toBeTruthy();
2400+ const obj = (await $`git -C ${repoDir} cat-file -p ${hash}`.quiet()).text();
2401+ expect(obj).not.toContain('gpgsig');
2402+ });
2403+
2404+ test('commit log shows verified badge on signed commit', async () => {
2405+ const page = await adminCtx.newPage();
2406+ try {
2407+ await page.goto(`${BASE}/my-repo/commits/main`);
2408+ // Find the commit-item for the merged patch by subject text
2409+ const patchItem = page.locator('.commit-item').filter({ hasText: 'Add patch-test.txt' });
2410+ expect(await patchItem.locator('.sig-badge.verified').isVisible()).toBe(true);
2411+ } finally { await page.close(); }
2412+ });
2413+
2414+ test('commit log shows no sig badge on unsigned commit', async () => {
2415+ const page = await adminCtx.newPage();
2416+ try {
2417+ await page.goto(`${BASE}/my-repo/commits/main`);
2418+ // Initial commit was not signed via hearthforge
2419+ const initialItem = page.locator('.commit-item').filter({ hasText: 'Initial commit' });
2420+ expect(await initialItem.locator('.sig-badge').count()).toBe(0);
2421+ } finally { await page.close(); }
2422+ });
2423+
2424+ test('commit detail shows verified signature row for signed commit', async () => {
2425+ const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
2426+ const hash = (await $`git -C ${repoDir} log --format=%H --grep="Add patch-test.txt" -1`.quiet()).text().trim();
2427+ const page = await adminCtx.newPage();
2428+ try {
2429+ await page.goto(`${BASE}/my-repo/commit/${hash}`);
2430+ const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' });
2431+ expect(await sigRow.isVisible()).toBe(true);
2432+ expect(await sigRow.locator('.sig-badge.verified').isVisible()).toBe(true);
2433+ } finally { await page.close(); }
2434+ });
2435+
2436+ test('commit detail shows no signature row for unsigned commit', async () => {
2437+ const repoDir = `${process.cwd()}/${DATA_DIR}/repos/my-repo.git`;
2438+ const hash = (await $`git -C ${repoDir} log --format=%H --grep="Initial commit" -1`.quiet()).text().trim();
2439+ const page = await adminCtx.newPage();
2440+ try {
2441+ await page.goto(`${BASE}/my-repo/commit/${hash}`);
2442+ const sigRow = page.locator('.commit-card-meta-row').filter({ hasText: 'Signature' });
2443+ expect(await sigRow.count()).toBe(0);
2444+ } finally { await page.close(); }
2445+ });
2446+});