remove git identity settings again, add back admin name&email env vars, drop .diff support, use metadata from .patch files

AuthorKonata <konata@posteo.jp>
Date
Commit6f66d1c03bc86dfd9c1da10ac53bae45b55eb01b
Parentf2122aa
13 files changed, 310 insertions(+), 210 deletions(-)
MREADME.md
@@ -48,22 +48,24 @@ podman compose up
4848
4949 All settings are environment variables:
5050
51-| Variable | Default | Description |
52-|-------------------------|-------------------------|-------------------------------------------------|
53-| `PORT` | `3000` | HTTP port |
54-| `SSH_PORT` | `2222` | SSH port |
55-| `DATA_DIR` | `./data` | Repos, database, uploads |
56-| `ADMIN_PASSWORD` | `changeme` | Initial admin password |
57-| `OWNER_DISPLAY_NAME` | `Admin` | Display name for the owner |
58-| `BASE_URL` | `http://localhost:3000` | Used in clone URLs and links |
59-| `REGISTRATION_DISABLED` | `0` | Set to `1` to disable signups |
60-| `MAX_UPLOAD_BYTES` | `10485760` | Max request body size (any uploads/requests) |
61-| `MAX_USER_UPLOAD_BYTES` | `2097152` | Max request body size (user uploads) |
62-| `INLINE_MAX_BYTES` | `524288` | Max file size to render inline in the file view |
63-| `SSH_DISABLED` | `0` | Disable the embedded SSH-server |
64-| `TRUSTED_PROXY` | `0` | Trust `X-Forwarded-For` |
65-| `RATE_LIMIT_DISABLED` | `0` | Set to `1` to disable rate limiting |
66-| `HIGHLIGHT_WORKERS` | `4` | Number of syntax highlighting workers* |
51+| Variable | Default | Description |
52+|-------------------------|----------------------------------|-------------------------------------------------|
53+| `PORT` | `3000` | HTTP port |
54+| `SSH_PORT` | `2222` | SSH port |
55+| `DATA_DIR` | `./data` | Repos, database, uploads |
56+| `ADMIN_PASSWORD` | `changeme` | Initial admin password |
57+| `OWNER_DISPLAY_NAME` | `Admin` | Display name for the owner |
58+| `BASE_URL` | `http://localhost:3000` | Used in clone URLs and links |
59+| `REGISTRATION_DISABLED` | `0` | Set to `1` to disable signups |
60+| `MAX_UPLOAD_BYTES` | `10485760` | Max request body size (any uploads/requests) |
61+| `MAX_USER_UPLOAD_BYTES` | `2097152` | Max request body size (user uploads) |
62+| `INLINE_MAX_BYTES` | `524288` | Max file size to render inline in the file view |
63+| `SSH_DISABLED` | `0` | Disable the embedded SSH-server |
64+| `TRUSTED_PROXY` | `0` | Trust `X-Forwarded-For` |
65+| `RATE_LIMIT_DISABLED` | `0` | Set to `1` to disable rate limiting |
66+| `HIGHLIGHT_WORKERS` | `4` | Number of syntax highlighting workers* |
67+| `COMMITTER_NAME` | `$OWNER_DISPLAY_NAME` | Git committer name used when merging patches |
68+| `COMMITTER_EMAIL` | `$OWNER_DISPLAY_NAME@<hostname>` | Git committer email used when merging patches |
6769
6870 \* More workers mean more CPU cores can be used to parallelize highlighting of files.
6971 Because of the language grammars, which can't be shared across workers, the memory usage per worker is quite high, at about 200MB.
@@ -92,3 +94,5 @@ bun run test # Playwright E2E tests (don't use bun test, it doesn't res
9294 - Issue labels
9395 - Repository list reordering (e.g. last committed) and starring
9496 - redirect image urls in readme
97+- issue/patch templates
98+- commit signing
Msrc/config.ts
@@ -18,3 +18,7 @@ export const BASE_URL = process.env.BASE_URL ?? `http://localhost:${PORT}`;
1818 export const DATA_DIR = path.resolve(process.env.DATA_DIR ?? "./data");
1919 export const HIGHLIGHT_WORKERS =
2020 parseInt(process.env.HIGHLIGHT_WORKERS ?? "", 10) || 4;
21+export const COMMITTER_NAME = process.env.COMMITTER_NAME ?? OWNER_DISPLAY_NAME;
22+export const COMMITTER_EMAIL =
23+ process.env.COMMITTER_EMAIL ??
24+ `${OWNER_DISPLAY_NAME}@${new URL(BASE_URL).hostname}`;
Msrc/db/index.ts
@@ -10,8 +10,6 @@ interface UserTable {
1010 password_hash: string | null;
1111 created_at: string;
1212 avatar_version: Generated<number>;
13- git_name: string | null;
14- git_email: string | null;
1513 }
1614
1715 interface PasskeyTable {
Msrc/middleware/session.ts
@@ -6,8 +6,6 @@ export interface SessionUser {
66 username: string;
77 isAdmin: boolean;
88 avatar_version: number;
9- git_name: string | null;
10- git_email: string | null;
119 }
1210
1311 export async function resolveSession(
@@ -22,8 +20,6 @@ export async function resolveSession(
2220 "users.id",
2321 "users.username",
2422 "users.avatar_version",
25- "users.git_name",
26- "users.git_email",
2723 "sessions.expires_at",
2824 ])
2925 .where("sessions.id", "=", cookie)
@@ -35,8 +31,6 @@ export async function resolveSession(
3531 username: session.username,
3632 isAdmin: session.username === ADMIN_USERNAME,
3733 avatar_version: session.avatar_version,
38- git_name: session.git_name,
39- git_email: session.git_email,
4034 };
4135 }
4236
Msrc/routes/patches.tsx
@@ -1,6 +1,10 @@
11 import { Elysia, t } from "elysia";
22 import { sql } from "kysely";
3-import { MAX_USER_UPLOAD_BYTES } from "../config.ts";
3+import {
4+ COMMITTER_EMAIL,
5+ COMMITTER_NAME,
6+ MAX_USER_UPLOAD_BYTES,
7+} from "../config.ts";
48 import { ALLOWED_REACTIONS, PATCHES_PER_PAGE } from "../constants.ts";
59 import { db, getRepo } from "../db/index.ts";
610 import {
@@ -8,7 +12,7 @@ import {
812 requireAuth,
913 resolveSession,
1014 } from "../middleware/session.ts";
11-import { git } from "../services/git.ts";
15+import { extractPatchMeta, git } from "../services/git.ts";
1216 import { prepareDiff } from "../services/highlightWorker.ts";
1317 import { renderMarkdown } from "../services/markdown.ts";
1418 import { patchCache } from "../services/patchCache.ts";
@@ -152,16 +156,6 @@ export const patchRoutes = new Elysia()
152156 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
153157 if (!repo) return new Response("Not found", { status: 404 });
154158
155- if (!user!.git_name?.trim() || !user!.git_email?.trim()) {
156- return html(
157- <NewPatch
158- user={user!}
159- repo={repo}
160- error="You must set your git name and email in Settings before creating a patch"
161- />,
162- );
163- }
164-
165159 if (!body.title?.trim()) {
166160 return html(
167161 <NewPatch
@@ -203,17 +197,45 @@ export const patchRoutes = new Elysia()
203197 );
204198 }
205199
206- // Validate it looks like a patch/diff file
200+ // Validate it looks like a patch file
207201 if (!isValidPatch(patchContent)) {
208202 return html(
209203 <NewPatch
210204 user={user!}
211205 repo={repo}
212- error="File does not appear to be a valid patch or diff file"
206+ error="File does not appear to be a valid patch file"
213207 />,
214208 );
215209 }
216210
211+ const uploadMeta = extractPatchMeta(patchContent);
212+ if (!uploadMeta.subject) {
213+ return html(
214+ <NewPatch
215+ user={user!}
216+ repo={repo}
217+ error="Patch is missing a Subject header. Make sure to upload a patch created with git format-patch."
218+ />,
219+ );
220+ }
221+ if (!uploadMeta.author || !uploadMeta.email) {
222+ return html(
223+ <NewPatch
224+ user={user!}
225+ repo={repo}
226+ error="Patch is missing a From header with name and email."
227+ />,
228+ );
229+ }
230+ if (!uploadMeta.date) {
231+ return html(
232+ <NewPatch
233+ user={user!}
234+ repo={repo}
235+ error="Patch is missing a Date header."
236+ />,
237+ );
238+ }
217239 const now = new Date().toISOString();
218240 const { number, result } = await db
219241 .transaction()
@@ -234,8 +256,8 @@ export const patchRoutes = new Elysia()
234256 description: body.description?.trim() ?? "",
235257 patch_content: patchContent,
236258 status: "open",
237- author_name: user!.git_name!,
238- author_email: user!.git_email!,
259+ author_name: uploadMeta.author,
260+ author_email: uploadMeta.email,
239261 created_at: now,
240262 updated_at: now,
241263 })
@@ -353,6 +375,8 @@ export const patchRoutes = new Elysia()
353375 ? ("changes" as const)
354376 : ("conversation" as const);
355377
378+ const patchMeta = extractPatchMeta(patch.patch_content);
379+
356380 return html(
357381 <PatchDetail
358382 user={user}
@@ -369,6 +393,7 @@ export const patchRoutes = new Elysia()
369393 applyResult={applyResult}
370394 files={files}
371395 tab={tab}
396+ patchMeta={patchMeta}
372397 comments={
373398 commentsWithHtml as ((typeof commentsWithHtml)[0] & {
374399 author_username: string;
@@ -390,28 +415,13 @@ export const patchRoutes = new Elysia()
390415 const deny = requireAdmin(user);
391416 if (deny) return deny;
392417
393- if (!user!.git_name?.trim() || !user!.git_email?.trim()) {
394- return new Response(
395- "Set your git name and email in Settings before merging",
396- { status: 400, headers: { "Content-Type": "text/plain" } },
397- );
398- }
399-
400418 const repo = await getRepo(params.repo, true);
401419 if (!repo) return new Response("Not found", { status: 404 });
402420
403421 const patchNum = parseInt(params.number, 10);
404422 const patch = await db
405423 .selectFrom("patches")
406- .select([
407- "id",
408- "title",
409- "description",
410- "patch_content",
411- "status",
412- "author_name",
413- "author_email",
414- ])
424+ .select(["id", "patch_content", "status"])
415425 .where("repo_id", "=", repo.id)
416426 .where("number", "=", patchNum)
417427 .executeTakeFirst();
@@ -428,16 +438,15 @@ export const patchRoutes = new Elysia()
428438 if (!claimed || claimed.numUpdatedRows === 0n)
429439 return new Response("Patch is not open", { status: 400 });
430440
441+ const mergeMeta = extractPatchMeta(patch.patch_content);
431442 try {
432443 await git.applyPatch(
433444 repo.name,
434445 patch.patch_content,
435- patch.title,
436- patch.description,
437- patch.author_name,
438- patch.author_email,
439- user!.git_name!,
440- user!.git_email!,
446+ mergeMeta.author,
447+ mergeMeta.email,
448+ COMMITTER_NAME,
449+ COMMITTER_EMAIL,
441450 );
442451 } catch (err) {
443452 // Roll back the status if the git operation fails
Msrc/routes/releases.tsx
@@ -1,6 +1,7 @@
11 import { mkdirSync, rmSync } from "node:fs";
22 import path from "node:path";
33 import { Elysia, t } from "elysia";
4+import { COMMITTER_EMAIL, COMMITTER_NAME } from "../config.ts";
45 import { RELEASES_DIR, RELEASES_PER_PAGE } from "../constants.ts";
56 import { db, getRepo } from "../db/index.ts";
67 import { requireAdmin, resolveSession } from "../middleware/session.ts";
@@ -130,17 +131,6 @@ export const releasesRoutes = new Elysia()
130131 include_source_code: body.include_source_code === "on",
131132 };
132133
133- if (createTag && (!user!.git_name?.trim() || !user!.git_email?.trim())) {
134- return html(
135- <NewRelease
136- user={user!}
137- repo={repo}
138- error="You must set your git name and email in Settings before creating a tagged release"
139- values={formValues}
140- />,
141- );
142- }
143-
144134 if (!name) {
145135 return html(
146136 <NewRelease
@@ -194,8 +184,8 @@ export const releasesRoutes = new Elysia()
194184 tagName,
195185 revision,
196186 tagMessage,
197- user!.git_name!,
198- user!.git_email!,
187+ COMMITTER_NAME,
188+ COMMITTER_EMAIL,
199189 );
200190 if (tagResult === "already_exists") {
201191 return html(
Msrc/routes/settings.tsx
@@ -34,7 +34,7 @@ export const settingsRoutes = new Elysia()
3434
3535 const userRow = await db
3636 .selectFrom("users")
37- .select(["id", "password_hash", "git_name", "git_email"])
37+ .select(["id", "password_hash"])
3838 .where("id", "=", user.id)
3939 .executeTakeFirst();
4040
@@ -61,8 +61,6 @@ export const settingsRoutes = new Elysia()
6161 <Settings
6262 user={user}
6363 hasPassword={hasPassword}
64- gitName={userRow?.git_name ?? null}
65- gitEmail={userRow?.git_email ?? null}
6664 passkeys={passkeys}
6765 sshKeys={sshKeys}
6866 theme={theme}
@@ -346,40 +344,6 @@ export const settingsRoutes = new Elysia()
346344 },
347345 )
348346
349- .post(
350- "/settings/git-identity",
351- async ({ cookie, body }) => {
352- const user = await resolveSession(
353- cookie.session?.value as string | undefined,
354- );
355- if (!user) return redirect("/login");
356-
357- const name = body.git_name.trim();
358- const email = body.git_email.trim();
359-
360- if (!name) {
361- return redirect("/settings?error=Git+name+is+required");
362- }
363- if (!email) {
364- return redirect("/settings?error=Git+email+is+required");
365- }
366-
367- await db
368- .updateTable("users")
369- .set({ git_name: name, git_email: email })
370- .where("id", "=", user.id)
371- .execute();
372-
373- return redirect("/settings?success=git_identity");
374- },
375- {
376- body: t.Object({
377- git_name: t.String(),
378- git_email: t.String(),
379- }),
380- },
381- )
382-
383347 .post(
384348 "/settings/ssh-keys",
385349 async ({ cookie, body }) => {
Msrc/services/git.ts
@@ -173,7 +173,7 @@ function parseLsTree(out: string): TreeEntry[] {
173173 });
174174 }
175175
176-function extractPatchSubject(patch: string): string {
176+export function extractPatchSubject(patch: string): string {
177177 for (const line of patch.split("\n").slice(0, 30)) {
178178 if (line.startsWith("Subject: ")) {
179179 // Strip "[PATCH ...] " prefix added by git format-patch
@@ -183,6 +183,58 @@ function extractPatchSubject(patch: string): string {
183183 return "";
184184 }
185185
186+export interface PatchMeta {
187+ subject: string;
188+ body: string;
189+ author: string;
190+ email: string;
191+ date: string;
192+}
193+
194+export function extractPatchMeta(patch: string): PatchMeta {
195+ const lines = patch.split("\n");
196+ let subject = "";
197+ let author = "";
198+ let email = "";
199+ let date = "";
200+ const bodyLines: string[] = [];
201+ let inHeaders = true;
202+ let pastSubject = false;
203+
204+ for (const line of lines) {
205+ if (inHeaders) {
206+ if (line.startsWith("From: ")) {
207+ const match = line.slice(6).match(/^(.*?)\s*<([^>]+)>/);
208+ if (match) {
209+ author = match[1]!.trim();
210+ email = match[2]!;
211+ } else {
212+ author = line.slice(6).trim();
213+ }
214+ } else if (line.startsWith("Date: ")) {
215+ date = line.slice(6).trim();
216+ } else if (line.startsWith("Subject: ")) {
217+ subject = line.slice(9).replace(/^\[PATCH[^\]]*\]\s*/, "");
218+ pastSubject = true;
219+ } else if (pastSubject && line === "") {
220+ inHeaders = false;
221+ }
222+ } else {
223+ if (line === "---") break;
224+ bodyLines.push(line);
225+ }
226+ }
227+
228+ while (
229+ bodyLines.length > 0 &&
230+ bodyLines[bodyLines.length - 1]!.trim() === ""
231+ ) {
232+ bodyLines.pop();
233+ }
234+
235+ return { subject, body: bodyLines.join("\n"), author, email, date };
236+}
237+
186238 export const git = {
187239 async init(name: string, branch = "main") {
188240 return withRepoLock(name, async () => {
@@ -371,8 +423,6 @@ export const git = {
371423 async applyPatch(
372424 name: string,
373425 patchContent: string,
374- title: string,
375- description: string,
376426 authorName: string,
377427 authorEmail: string,
378428 committerName: string,
@@ -390,10 +440,7 @@ export const git = {
390440 const parent = (
391441 await $`git -C ${p} rev-parse HEAD`.text()
392442 ).trim();
393- const fallback = description.trim()
394- ? `${title}\n\n${description.trim()}`
395- : title;
396- const msg = extractPatchSubject(patchContent) || fallback;
443+ const msg = extractPatchSubject(patchContent);
397444 const commit = (
398445 await $`git -C ${p} commit-tree ${tree} -p ${parent} -m ${msg}`
399446 .env({
Msrc/views/Settings.tsx
@@ -6,8 +6,6 @@ import { Layout } from "./layout.tsx";
66 interface SettingsProps {
77 user: SessionUser;
88 hasPassword: boolean;
9- gitName: string | null;
10- gitEmail: string | null;
119 passkeys: { id: number; created_at: string }[];
1210 sshKeys: {
1311 id: number;
@@ -29,14 +27,11 @@ const successMessages: Record<string, string> = {
2927 user_deleted: "Account deleted.",
3028 ssh_key_added: "SSH key added.",
3129 ssh_key_deleted: "SSH key removed.",
32- git_identity: "Git identity saved.",
3330 };
3431
3532 export function Settings({
3633 user,
3734 hasPassword,
38- gitName,
39- gitEmail,
4035 passkeys,
4136 sshKeys,
4237 theme,
@@ -102,55 +97,6 @@ export function Settings({
10297 </div>
10398 </div>
10499
105- {/* Git Identity */}
106- <div class="form-card">
107- <h2 class="section-title">Git Identity</h2>
108- <p class="text-muted">
109- Used as the author when creating patches. Required to
110- submit patches.
111- </p>
112- <form
113- method="POST"
114- action="/settings/git-identity"
115- class="settings-form"
116- style="margin-top: var(--space-4)"
117- >
118- <div class="form-group">
119- <label class="form-label" for="git_name">
120- Name
121- </label>
122- <input
123- class="form-input"
124- type="text"
125- id="git_name"
126- name="git_name"
127- value={gitName ?? ""}
128- autocomplete="name"
129- required
130- />
131- </div>
132- <div class="form-group">
133- <label class="form-label" for="git_email">
134- Email
135- </label>
136- <input
137- class="form-input"
138- type="email"
139- id="git_email"
140- name="git_email"
141- value={gitEmail ?? ""}
142- autocomplete="email"
143- required
144- />
145- </div>
146- <div class="form-actions">
147- <button class="btn btn-primary" type="submit">
148- Save
149- </button>
150- </div>
151- </form>
152- </div>
153-
154100 {/* Appearance */}
155101 <div class="form-card">
156102 <h2 class="section-title">Appearance</h2>
Msrc/views/issues/NewIssue.tsx
@@ -36,7 +36,9 @@ export function NewIssue({ user, repo, error }: NewIssueProps) {
3636 <div class="form-group">
3737 <label for="body">
3838 Description{" "}
39- <span class="text-muted">(Markdown supported)</span>
39+ <span class="text-muted">
40+ (Markdown supported, optional)
41+ </span>
4042 </label>
4143 <textarea
4244 id="body"
Msrc/views/patches/NewPatch.tsx
@@ -49,14 +49,13 @@ export function NewPatch({ user, repo, error }: NewPatchProps) {
4949 </div>
5050 <div class="form-group">
5151 <label for="patch_file">
52- Patch file{" "}
53- <span class="text-muted">(.patch or .diff)</span>
52+ Patch file <span class="text-muted">(.patch)</span>
5453 </label>
5554 <input
5655 id="patch_file"
5756 name="patch_file"
5857 type="file"
59- accept=".patch,.diff,text/plain"
58+ accept=".patch"
6059 required
6160 />
6261 </div>
Msrc/views/patches/PatchDetail.tsx
@@ -4,9 +4,11 @@ import type {
44 PatchRow,
55 RepositoryRow,
66 } from "../../db/index.ts";
7+import { formatDateTime } from "../../lib/formatDate.ts";
78 import { displayName } from "../../lib/users.ts";
89 import type { SessionUser } from "../../middleware/session.ts";
910 import type { RenderedDiffFile } from "../../services/diffHighlight.ts";
11+import type { PatchMeta } from "../../services/git.ts";
1012 import type { ApplyResult } from "../../services/patchCache.ts";
1113 import { Avatar } from "../Avatar.tsx";
1214 import { DateWithEdited } from "../DateWithEdited.tsx";
@@ -29,6 +31,7 @@ interface PatchDetailProps {
2931 applyResult: ApplyResult | null;
3032 files: RenderedDiffFile[];
3133 tab: "conversation" | "changes";
34+ patchMeta: PatchMeta;
3235 comments: (PatchCommentRow & {
3336 author_username: string;
3437 author_avatar_version: number | null;
@@ -46,6 +49,7 @@ export function PatchDetail({
4649 applyResult,
4750 files,
4851 tab,
52+ patchMeta,
4953 comments,
5054 reactions,
5155 commentReactions,
@@ -419,7 +423,40 @@ export function PatchDetail({
419423 )}
420424 </div>
421425 ) : (
422- <DiffView files={files} repo={repo} />
426+ <div>
427+ <div class="commit-card">
428+ <h2 class="commit-card-subject">
429+ {escapeHtml(patchMeta.subject)}
430+ </h2>
431+ {patchMeta.body && (
432+ <pre class="commit-card-body">
433+ {escapeHtml(patchMeta.body)}
434+ </pre>
435+ )}
436+ <div class="commit-card-meta">
437+ <div class="commit-card-meta-row">
438+ <span class="commit-meta-label">
439+ Author
440+ </span>
441+ <span class="commit-meta-value">
442+ {escapeHtml(
443+ `${patchMeta.author} <${patchMeta.email}>`,
444+ )}
445+ </span>
446+ </div>
447+ <div class="commit-card-meta-row">
448+ <span class="commit-meta-label">Date</span>
449+ <time
450+ class="commit-meta-value"
451+ datetime={patchMeta.date}
452+ >
453+ {formatDateTime(patchMeta.date)}
454+ </time>
455+ </div>
456+ </div>
457+ </div>
458+ <DiffView files={files} repo={repo} />
459+ </div>
423460 )}
424461 </div>
425462 </Layout>
Mtests/e2e.test.ts
@@ -59,6 +59,12 @@ async function bulkCreateIssues(ctx: BrowserContext, repo: string, count: number
5959 // Helper: create N patches in a repo using the server API
6060 async function bulkCreatePatches(ctx: BrowserContext, repo: string, count: number) {
6161 const VALID_PATCH = [
62+ 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
63+ 'From: Test User <test@example.com>',
64+ 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
65+ 'Subject: [PATCH] Add f.txt',
66+ '',
67+ '---',
6268 'diff --git a/f.txt b/f.txt',
6369 'new file mode 100644',
6470 'index 0000000..9daeafb',
@@ -641,6 +647,12 @@ describe('patches', () => {
641647
642648 // Adds a new file — applies cleanly to my-repo
643649 const CLEAN_PATCH = [
650+ 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
651+ 'From: Test User <test@example.com>',
652+ 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
653+ 'Subject: [PATCH] Add patch-test.txt',
654+ '',
655+ '---',
644656 'diff --git a/patch-test.txt b/patch-test.txt',
645657 'new file mode 100644',
646658 'index 0000000..9daeafb',
@@ -653,6 +665,12 @@ describe('patches', () => {
653665
654666 // References non-existent lines in README.md — always conflicts
655667 const CONFLICT_PATCH = [
668+ 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b3 Mon Sep 17 00:00:00 2001',
669+ 'From: Test User <test@example.com>',
670+ 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
671+ 'Subject: [PATCH] Modify README',
672+ '',
673+ '---',
656674 'diff --git a/README.md b/README.md',
657675 'index abc1234..def5678 100644',
658676 '--- a/README.md',
@@ -666,6 +684,12 @@ describe('patches', () => {
666684
667685 // Adds another new file — for testing close flow
668686 const CLOSE_PATCH = [
687+ 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b4 Mon Sep 17 00:00:00 2001',
688+ 'From: Test User <test@example.com>',
689+ 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
690+ 'Subject: [PATCH] Add patch-close.txt',
691+ '',
692+ '---',
669693 'diff --git a/patch-close.txt b/patch-close.txt',
670694 'new file mode 100644',
671695 'index 0000000..9daeafb',
@@ -678,12 +702,6 @@ describe('patches', () => {
678702
679703 beforeAll(async () => {
680704 adminCtx = await loggedInContext();
681- // Git identity is required to submit patches
682- await adminCtx.request.fetch(`${BASE}/settings/git-identity`, {
683- method: 'POST',
684- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
685- data: 'git_name=Admin+User&git_email=admin%40test.com',
686- });
687705 });
688706
689707 afterAll(async () => { await adminCtx.close(); });
@@ -700,6 +718,78 @@ describe('patches', () => {
700718 } finally { await page.close(); }
701719 });
702720
721+ test('reject patch missing Subject header', async () => {
722+ writeTempFile('/tmp/no-subject.patch', [
723+ 'From: Test User <test@example.com>',
724+ 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
725+ '',
726+ '---',
727+ 'diff --git a/f.txt b/f.txt',
728+ 'new file mode 100644',
729+ '--- /dev/null',
730+ '+++ b/f.txt',
731+ '@@ -0,0 +1 @@',
732+ '+x',
733+ '',
734+ ].join('\n'));
735+ const page = await adminCtx.newPage();
736+ try {
737+ await page.goto(`${BASE}/my-repo/patches/new`);
738+ await page.fill('[name=title]', 'No subject');
739+ await page.locator('[name=patch_file]').setInputFiles('/tmp/no-subject.patch');
740+ await page.click('form[action$="/patches"] button[type=submit]');
741+ expect(await page.locator('.form-error').textContent()).toContain('Subject');
742+ } finally { await page.close(); }
743+ });
744+
745+ test('reject patch missing From header', async () => {
746+ writeTempFile('/tmp/no-from.patch', [
747+ 'Date: Mon, 01 Jan 2024 12:00:00 +0000',
748+ 'Subject: [PATCH] Add f.txt',
749+ '',
750+ '---',
751+ 'diff --git a/f.txt b/f.txt',
752+ 'new file mode 100644',
753+ '--- /dev/null',
754+ '+++ b/f.txt',
755+ '@@ -0,0 +1 @@',
756+ '+x',
757+ '',
758+ ].join('\n'));
759+ const page = await adminCtx.newPage();
760+ try {
761+ await page.goto(`${BASE}/my-repo/patches/new`);
762+ await page.fill('[name=title]', 'No from');
763+ await page.locator('[name=patch_file]').setInputFiles('/tmp/no-from.patch');
764+ await page.click('form[action$="/patches"] button[type=submit]');
765+ expect(await page.locator('.form-error').textContent()).toContain('From');
766+ } finally { await page.close(); }
767+ });
768+
769+ test('reject patch missing Date header', async () => {
770+ writeTempFile('/tmp/no-date.patch', [
771+ 'From: Test User <test@example.com>',
772+ 'Subject: [PATCH] Add f.txt',
773+ '',
774+ '---',
775+ 'diff --git a/f.txt b/f.txt',
776+ 'new file mode 100644',
777+ '--- /dev/null',
778+ '+++ b/f.txt',
779+ '@@ -0,0 +1 @@',
780+ '+x',
781+ '',
782+ ].join('\n'));
783+ const page = await adminCtx.newPage();
784+ try {
785+ await page.goto(`${BASE}/my-repo/patches/new`);
786+ await page.fill('[name=title]', 'No date');
787+ await page.locator('[name=patch_file]').setInputFiles('/tmp/no-date.patch');
788+ await page.click('form[action$="/patches"] button[type=submit]');
789+ expect(await page.locator('.form-error').textContent()).toContain('Date');
790+ } finally { await page.close(); }
791+ });
792+
703793 test('upload clean patch', async () => {
704794 writeTempFile('/tmp/clean.patch', CLEAN_PATCH);
705795 const page = await adminCtx.newPage();
@@ -715,6 +805,27 @@ describe('patches', () => {
715805 } finally { await page.close(); }
716806 });
717807
808+ test('author on patch comes from patch From header', async () => {
809+ const page = await adminCtx.newPage();
810+ try {
811+ await page.goto(cleanPatchUrl);
812+ expect(await page.locator('.patch-author-identity').textContent()).toContain('Test User');
813+ expect(await page.locator('.patch-author-identity').textContent()).toContain('test@example.com');
814+ } finally { await page.close(); }
815+ });
816+
817+ test('changes tab shows commit metadata card', async () => {
818+ const page = await adminCtx.newPage();
819+ try {
820+ await page.goto(cleanPatchUrl + '?tab=changes');
821+ expect(await page.locator('.commit-card').isVisible()).toBe(true);
822+ expect(await page.locator('.commit-card-subject').textContent()).toContain('Add patch-test.txt');
823+ expect(await page.locator('.commit-card-meta').textContent()).toContain('Test User');
824+ expect(await page.locator('.commit-card-meta').textContent()).toContain('test@example.com');
825+ expect(await page.locator('.commit-card-meta time').isVisible()).toBe(true);
826+ } finally { await page.close(); }
827+ });
828+
718829 test('patch description renders markdown', async () => {
719830 const page = await adminCtx.newPage();
720831 try {
@@ -806,6 +917,16 @@ describe('patches', () => {
806917 } finally { await page.close(); }
807918 });
808919
920+ test('merge uses patch From header as git author', async () => {
921+ const repoPath = `${process.cwd()}/data-test/repos/my-repo.git`;
922+ const authorName = (await $`git -C ${repoPath} log -1 --format=%aN`.quiet()).text().trim();
923+ const authorEmail = (await $`git -C ${repoPath} log -1 --format=%aE`.quiet()).text().trim();
924+ const subject = (await $`git -C ${repoPath} log -1 --format=%s`.quiet()).text().trim();
925+ expect(authorName).toBe('Test User');
926+ expect(authorEmail).toBe('test@example.com');
927+ expect(subject).toBe('Add patch-test.txt');
928+ });
929+
809930 test('merged patch appears in merged list', async () => {
810931 const page = await adminCtx.newPage();
811932 try {
@@ -923,12 +1044,6 @@ describe('pagination', () => {
9231044
9241045 beforeAll(async () => {
9251046 adminCtx = await loggedInContext();
926- // Git identity is required to submit patches
927- await adminCtx.request.fetch(`${BASE}/settings/git-identity`, {
928- method: 'POST',
929- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
930- data: 'git_name=Admin+User&git_email=admin%40test.com',
931- });
9321047
9331048 // Create a repo dedicated to pagination testing
9341049 const page = await adminCtx.newPage();
@@ -1893,31 +2008,22 @@ describe('settings', () => {
18932008 expect(resp.headers()['location']).toContain('error');
18942009 });
18952010
1896- test('git identity: empty name shows error', async () => {
1897- const resp = await adminCtx.request.post(`${BASE}/settings/git-identity`, {
1898- form: { git_name: ' ', git_email: 'test@example.com' },
1899- maxRedirects: 0,
1900- });
1901- expect(resp.status()).toBe(302);
1902- expect(resp.headers()['location']).toContain('error=Git+name+is+required');
2011+ test('settings page has no git identity section', async () => {
2012+ const page = await adminCtx.newPage();
2013+ try {
2014+ await page.goto(`${BASE}/settings`);
2015+ expect(await page.locator('text=Git Identity').count()).toBe(0);
2016+ expect(await page.locator('[name=git_name]').count()).toBe(0);
2017+ expect(await page.locator('[name=git_email]').count()).toBe(0);
2018+ } finally { await page.close(); }
19032019 });
19042020
1905- test('git identity: empty email shows error', async () => {
2021+ test('git identity route no longer exists', async () => {
19062022 const resp = await adminCtx.request.post(`${BASE}/settings/git-identity`, {
1907- form: { git_name: 'Test User', git_email: ' ' },
2023+ form: { git_name: 'Test', git_email: 'test@example.com' },
19082024 maxRedirects: 0,
19092025 });
1910- expect(resp.status()).toBe(302);
1911- expect(resp.headers()['location']).toContain('error=Git+email+is+required');
1912- });
1913-
1914- test('git identity: valid values redirect with success', async () => {
1915- const resp = await aliceCtx.request.post(`${BASE}/settings/git-identity`, {
1916- form: { git_name: 'Alice Smith', git_email: 'alice@example.com' },
1917- maxRedirects: 0,
1918- });
1919- expect(resp.status()).toBe(302);
1920- expect(resp.headers()['location']).toContain('success=git_identity');
2026+ expect(resp.status()).toBe(404);
19212027 });
19222028 });
19232029