add per-user git identity for patch authorship

Users can now set their own git name and email in Settings, which are stored on the user record and used as the author when creating/merging patches. Removes the global GIT_AUTHOR_NAME/EMAIL config in favour of per-user identity.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commit3fd610676ac8591e884f62bd01dc41cd693db61b
Parent9e3cb42
11 files changed, 191 insertions(+), 17 deletions(-)
MREADME.md
@@ -64,8 +64,6 @@ All settings are environment variables:
6464 | `TRUSTED_PROXY` | `0` | Trust `X-Forwarded-For` |
6565 | `RATE_LIMIT_DISABLED` | `0` | Set to `1` to disable rate limiting |
6666 | `HIGHLIGHT_WORKERS` | `4` | Number of syntax highlighting workers* |
67-| `GIT_AUTHOR_NAME` | `OWNER_DISPLAY_NAME` | Author/committer name used when merging patches |
68-| `GIT_AUTHOR_EMAIL` | `{name}@{BASE_URL host}`| Author/committer email used when merging patches|
6967
7068 \* More workers mean more CPU cores can be used to parallelize highlighting of files.
7169 Because of the language grammars, which can't be shared across workers, the memory usage per worker is quite high, at about 200MB.
Msrc/config.ts
@@ -15,11 +15,6 @@ export const PORT = parseInt(process.env.PORT ?? "", 10) || 3000;
1515 export const SSH_PORT = parseInt(process.env.SSH_PORT ?? "", 10) || 2222;
1616 export const REGISTRATION_DISABLED = !!process.env.REGISTRATION_DISABLED;
1717 export const BASE_URL = process.env.BASE_URL ?? `http://localhost:${PORT}`;
18-export const GIT_AUTHOR_NAME =
19- process.env.GIT_AUTHOR_NAME ?? OWNER_DISPLAY_NAME;
20-export const GIT_AUTHOR_EMAIL =
21- process.env.GIT_AUTHOR_EMAIL ??
22- `${OWNER_DISPLAY_NAME}@${new URL(BASE_URL).hostname}`;
2318 export const DATA_DIR = path.resolve(process.env.DATA_DIR ?? "./data");
2419 export const HIGHLIGHT_WORKERS =
2520 parseInt(process.env.HIGHLIGHT_WORKERS ?? "", 10) || 4;
Msrc/db/index.ts
@@ -10,6 +10,8 @@ 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;
1315 }
1416
1517 interface PasskeyTable {
@@ -78,6 +80,8 @@ interface PatchTable {
7880 description: string;
7981 patch_content: string;
8082 status: string;
83+ author_name: string;
84+ author_email: string;
8185 created_at: string;
8286 updated_at: string;
8387 edited_at: string | null;
@@ -164,6 +168,21 @@ const sqlite = new BunDatabase(DB_PATH);
164168 sqlite.run("PRAGMA journal_mode=WAL");
165169 sqlite.run("PRAGMA foreign_keys=ON");
166170
171+// Migrations: add new columns to existing tables if not present
172+// SQLite does not support IF NOT EXISTS on ALTER TABLE, so we catch the error.
173+for (const sql of [
174+ "ALTER TABLE users ADD COLUMN git_name TEXT",
175+ "ALTER TABLE users ADD COLUMN git_email TEXT",
176+ "ALTER TABLE patches ADD COLUMN author_name TEXT NOT NULL DEFAULT ''",
177+ "ALTER TABLE patches ADD COLUMN author_email TEXT NOT NULL DEFAULT ''",
178+]) {
179+ try {
180+ sqlite.run(sql);
181+ } catch {
182+ // Column already exists — ignore
183+ }
184+}
185+
167186 export const db = new Kysely<Database>({
168187 dialect: new BunSqliteDialect({ database: sqlite }),
169188 });
Msrc/db/schema.sql
@@ -3,7 +3,9 @@ CREATE TABLE IF NOT EXISTS users (
33 username TEXT UNIQUE NOT NULL,
44 password_hash TEXT,
55 created_at TEXT NOT NULL,
6- avatar_version INTEGER NOT NULL DEFAULT 1
6+ avatar_version INTEGER NOT NULL DEFAULT 1,
7+ git_name TEXT,
8+ git_email TEXT
79 );
810
911 CREATE TABLE IF NOT EXISTS passkeys (
@@ -74,6 +76,8 @@ CREATE TABLE IF NOT EXISTS patches (
7476 description TEXT NOT NULL DEFAULT '',
7577 patch_content TEXT NOT NULL,
7678 status TEXT NOT NULL DEFAULT 'open',
79+ author_name TEXT NOT NULL DEFAULT '',
80+ author_email TEXT NOT NULL DEFAULT '',
7781 created_at TEXT NOT NULL,
7882 updated_at TEXT NOT NULL,
7983 edited_at TEXT,
Msrc/middleware/session.ts
@@ -6,6 +6,8 @@ export interface SessionUser {
66 username: string;
77 isAdmin: boolean;
88 avatar_version: number;
9+ git_name: string | null;
10+ git_email: string | null;
911 }
1012
1113 export async function resolveSession(
@@ -20,6 +22,8 @@ export async function resolveSession(
2022 "users.id",
2123 "users.username",
2224 "users.avatar_version",
25+ "users.git_name",
26+ "users.git_email",
2327 "sessions.expires_at",
2428 ])
2529 .where("sessions.id", "=", cookie)
@@ -31,6 +35,8 @@ export async function resolveSession(
3135 username: session.username,
3236 isAdmin: session.username === ADMIN_USERNAME,
3337 avatar_version: session.avatar_version,
38+ git_name: session.git_name,
39+ git_email: session.git_email,
3440 };
3541 }
3642
Msrc/routes/patches.tsx
@@ -90,6 +90,8 @@ export const patchRoutes = new Elysia()
9090 "patches.description",
9191 "patches.patch_content",
9292 "patches.status",
93+ "patches.author_name",
94+ "patches.author_email",
9395 "patches.created_at",
9496 "patches.updated_at",
9597 "patches.edited_at",
@@ -150,6 +152,16 @@ export const patchRoutes = new Elysia()
150152 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
151153 if (!repo) return new Response("Not found", { status: 404 });
152154
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+
153165 if (!body.title?.trim()) {
154166 return html(
155167 <NewPatch
@@ -222,6 +234,8 @@ export const patchRoutes = new Elysia()
222234 description: body.description?.trim() ?? "",
223235 patch_content: patchContent,
224236 status: "open",
237+ author_name: user!.git_name!,
238+ author_email: user!.git_email!,
225239 created_at: now,
226240 updated_at: now,
227241 })
@@ -266,6 +280,8 @@ export const patchRoutes = new Elysia()
266280 "patches.description",
267281 "patches.patch_content",
268282 "patches.status",
283+ "patches.author_name",
284+ "patches.author_email",
269285 "patches.created_at",
270286 "patches.updated_at",
271287 "patches.edited_at",
@@ -345,6 +361,8 @@ export const patchRoutes = new Elysia()
345361 patch as typeof patch & {
346362 author_username: string;
347363 author_avatar_version: number | null;
364+ author_name: string;
365+ author_email: string;
348366 }
349367 }
350368 descriptionHtml={descriptionHtml}
@@ -371,13 +389,29 @@ export const patchRoutes = new Elysia()
371389 const user = await resolveSession(cookie.session.value);
372390 const deny = requireAdmin(user);
373391 if (deny) return deny;
392+
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+
374400 const repo = await getRepo(params.repo, true);
375401 if (!repo) return new Response("Not found", { status: 404 });
376402
377403 const patchNum = parseInt(params.number, 10);
378404 const patch = await db
379405 .selectFrom("patches")
380- .select(["id", "title", "description", "patch_content", "status"])
406+ .select([
407+ "id",
408+ "title",
409+ "description",
410+ "patch_content",
411+ "status",
412+ "author_name",
413+ "author_email",
414+ ])
381415 .where("repo_id", "=", repo.id)
382416 .where("number", "=", patchNum)
383417 .executeTakeFirst();
@@ -400,6 +434,10 @@ export const patchRoutes = new Elysia()
400434 patch.patch_content,
401435 patch.title,
402436 patch.description,
437+ patch.author_name,
438+ patch.author_email,
439+ user!.git_name!,
440+ user!.git_email!,
403441 );
404442 } catch (err) {
405443 // Roll back the status if the git operation fails
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"])
37+ .select(["id", "password_hash", "git_name", "git_email"])
3838 .where("id", "=", user.id)
3939 .executeTakeFirst();
4040
@@ -61,6 +61,8 @@ 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}
6466 passkeys={passkeys}
6567 sshKeys={sshKeys}
6668 theme={theme}
@@ -344,6 +346,40 @@ export const settingsRoutes = new Elysia()
344346 },
345347 )
346348
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+
347383 .post(
348384 "/settings/ssh-keys",
349385 async ({ cookie, body }) => {
Msrc/services/git.ts
@@ -1,7 +1,6 @@
11 import path from "node:path";
22 import { $ as _$ } from "bun";
33
4-import { GIT_AUTHOR_EMAIL, GIT_AUTHOR_NAME } from "../config.ts";
54 import { REPOS_DIR } from "../constants.ts";
65
76 const $ = _$.env({ ...process.env, LC_ALL: "C", LANG: "C" });
@@ -360,7 +359,11 @@ export const git = {
360359 name: string,
361360 patchContent: string,
362361 title: string,
363- description?: string,
362+ description: string,
363+ authorName: string,
364+ authorEmail: string,
365+ committerName: string,
366+ committerEmail: string,
364367 ): Promise<void> {
365368 return withRepoLock(name, async () => {
366369 const p = repoPath(name);
@@ -374,7 +377,7 @@ export const git = {
374377 const parent = (
375378 await $`git -C ${p} rev-parse HEAD`.text()
376379 ).trim();
377- const fallback = description?.trim()
380+ const fallback = description.trim()
378381 ? `${title}\n\n${description.trim()}`
379382 : title;
380383 const msg = extractPatchSubject(patchContent) || fallback;
@@ -384,10 +387,10 @@ export const git = {
384387 ...process.env,
385388 LC_ALL: "C",
386389 LANG: "C",
387- GIT_AUTHOR_NAME,
388- GIT_AUTHOR_EMAIL,
389- GIT_COMMITTER_NAME: GIT_AUTHOR_NAME,
390- GIT_COMMITTER_EMAIL: GIT_AUTHOR_EMAIL,
390+ GIT_AUTHOR_NAME: authorName,
391+ GIT_AUTHOR_EMAIL: authorEmail,
392+ GIT_COMMITTER_NAME: committerName,
393+ GIT_COMMITTER_EMAIL: committerEmail,
391394 })
392395 .text()
393396 ).trim();
Msrc/styles/main.css
@@ -1566,6 +1566,20 @@
15661566 display: flex;
15671567 gap: var(--space-2);
15681568 }
1569+ .patch-author-meta {
1570+ display: flex;
1571+ align-items: center;
1572+ gap: var(--space-2);
1573+ font-size: var(--text-xs);
1574+ margin-top: calc(-1 * var(--space-4));
1575+ margin-bottom: var(--space-6);
1576+ }
1577+ .patch-author-label {
1578+ color: var(--color-text-muted);
1579+ }
1580+ .patch-author-identity {
1581+ font-family: monospace;
1582+ }
15691583 .timeline-item {
15701584 border: 1px solid var(--color-border);
15711585 border-radius: var(--radius-lg);
Msrc/views/Settings.tsx
@@ -5,6 +5,8 @@ import { Layout } from "./layout.tsx";
55 interface SettingsProps {
66 user: SessionUser;
77 hasPassword: boolean;
8+ gitName: string | null;
9+ gitEmail: string | null;
810 passkeys: { id: number; created_at: string }[];
911 sshKeys: {
1012 id: number;
@@ -26,11 +28,14 @@ const successMessages: Record<string, string> = {
2628 user_deleted: "Account deleted.",
2729 ssh_key_added: "SSH key added.",
2830 ssh_key_deleted: "SSH key removed.",
31+ git_identity: "Git identity saved.",
2932 };
3033
3134 export function Settings({
3235 user,
3336 hasPassword,
37+ gitName,
38+ gitEmail,
3439 passkeys,
3540 sshKeys,
3641 theme,
@@ -96,6 +101,55 @@ export function Settings({
96101 </div>
97102 </div>
98103
104+ {/* Git Identity */}
105+ <div class="form-card">
106+ <h2 class="section-title">Git Identity</h2>
107+ <p class="text-muted">
108+ Used as the author when creating patches. Required to
109+ submit patches.
110+ </p>
111+ <form
112+ method="POST"
113+ action="/settings/git-identity"
114+ class="settings-form"
115+ style="margin-top: var(--space-4)"
116+ >
117+ <div class="form-group">
118+ <label class="form-label" for="git_name">
119+ Name
120+ </label>
121+ <input
122+ class="form-input"
123+ type="text"
124+ id="git_name"
125+ name="git_name"
126+ value={gitName ?? ""}
127+ autocomplete="name"
128+ required
129+ />
130+ </div>
131+ <div class="form-group">
132+ <label class="form-label" for="git_email">
133+ Email
134+ </label>
135+ <input
136+ class="form-input"
137+ type="email"
138+ id="git_email"
139+ name="git_email"
140+ value={gitEmail ?? ""}
141+ autocomplete="email"
142+ required
143+ />
144+ </div>
145+ <div class="form-actions">
146+ <button class="btn btn-primary" type="submit">
147+ Save
148+ </button>
149+ </div>
150+ </form>
151+ </div>
152+
99153 {/* Appearance */}
100154 <div class="form-card">
101155 <h2 class="section-title">Appearance</h2>
Msrc/views/patches/PatchDetail.tsx
@@ -1,3 +1,4 @@
1+import { escapeHtml } from "@kitajs/html";
12 import type {
23 PatchCommentRow,
34 PatchRow,
@@ -21,6 +22,8 @@ interface PatchDetailProps {
2122 patch: PatchRow & {
2223 author_username: string;
2324 author_avatar_version: number | null;
25+ author_name: string;
26+ author_email: string;
2427 };
2528 descriptionHtml: string;
2629 applyResult: ApplyResult | null;
@@ -173,6 +176,10 @@ export function PatchDetail({
173176 </div>
174177 )}
175178 </div>
179+ <div class="patch-author-meta">
180+ <span class="patch-author-label">git author:</span>
181+ <span class="patch-author-identity">{escapeHtml(`${patch.author_name} <${patch.author_email}>`)}</span>
182+ </div>
176183 </div>
177184
178185 {/* Subview tabs */}