security: fix git arg injection and harden CI, transport, and uploads

Fixes from code review:

- git: guard all user-supplied refs with --end-of-options to close an
  unauthenticated arbitrary file write (git log --output= via ?after=)
- git smart-HTTP: check exit status (was empty 200 on failure) and drain
  stdout/stderr concurrently to avoid deadlock on large output
- checkPatch: use a throwaway GIT_INDEX_FILE so the patch-apply preview
  can't race/corrupt the shared index during a merge
- ci: drain the image-pull stream to completion (was aborted) and surface
  pull errors; link step exec to the run cancel signal + kill on timeout;
  cap step logs at 2 MB
- auth: rate-limit + bound the passkey create-user endpoint; bound the
  registration application field
- raw files: serve HTML as text/plain and add global nosniff to close a
  same-origin stored-XSS path
- db: run the ci_runs column migration after the table is created so
  pre-CI databases can upgrade
- repos: only set core.bare on first discovery, not on every page view
AuthorKonata <konata@posteo.jp>
Date
Commit4524187d7f4e91d65fd8a55cc95b43ca1f7fc399
Parent1b49a3d
19 files changed, 302 insertions(+), 72 deletions(-)
Msrc/app.ts
@@ -117,6 +117,7 @@ export async function createApp(port: number) {
117117 if (response instanceof Response) {
118118 response.headers.set("Content-Security-Policy", CSP);
119119 response.headers.set("X-Frame-Options", "DENY");
120+ response.headers.set("X-Content-Type-Options", "nosniff");
120121 if (config.PUBLIC_HTTPS) {
121122 response.headers.set(
122123 "Strict-Transport-Security",
Msrc/constants.ts
@@ -85,6 +85,9 @@ export const ISSUES_PER_PAGE = 20;
8585 export const PATCHES_PER_PAGE = 20;
8686 export const RELEASES_PER_PAGE = 20;
8787 export const CI_RUNS_PER_PAGE = 20;
88+// Cap a single CI step's captured log so a chatty step can't exhaust server
89+// RAM (it is buffered in memory) or bloat the ci_steps row.
90+export const CI_MAX_LOG_BYTES = 2 * 1024 * 1024;
8891 export const BRANCHES_PER_PAGE = 30;
8992 export const TAGS_PER_PAGE = 30;
9093
Msrc/db/index.ts
@@ -284,7 +284,11 @@ function runMigrations(s: InstanceType<typeof BunDatabase>) {
284284 }
285285 }
286286
287-runMigrations(sqlite);
287+// NOTE: runMigrations() alters ci_runs, so it must run *after* the
288+// `CREATE TABLE IF NOT EXISTS ci_runs` block below — otherwise upgrading a
289+// database created before the CI tables existed would ALTER a missing table
290+// and throw at import. The call is intentionally placed at the end of the
291+// migration section, not here.
288292
289293 /** Close the current DB and reopen from disk (used by tests after data wipe). */
290294 export function resetDb() {
@@ -342,6 +346,10 @@ sqlite.run(`CREATE TABLE IF NOT EXISTS ci_secrets (
342346 UNIQUE(repo_id, name)
343347 )`);
344348
349+// Run column-level migrations now that the CI tables are guaranteed to exist
350+// (see the note above runMigrations).
351+runMigrations(sqlite);
352+
345353 // Migration: add allow_user_labels column to repositories if missing
346354 const repoCols = sqlite
347355 .query<{ name: string }, []>("PRAGMA table_info(repositories)")
Msrc/routes/auth.tsx
@@ -273,7 +273,9 @@ export const authRoutes = new Elysia()
273273 password2: t.Optional(
274274 t.String({ maxLength: config.MAX_PASSWORD_BYTES }),
275275 ),
276- application: t.Optional(t.String()),
276+ application: t.Optional(
277+ t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
278+ ),
277279 }),
278280 },
279281 )
@@ -292,13 +294,35 @@ export const authRoutes = new Elysia()
292294 // Create account (passkey-only path, called before passkey registration)
293295 .post(
294296 "/auth/passkey/create-user",
295- async ({ body }) => {
297+ async ({ body, request, server }) => {
296298 if (config.REGISTRATION_TYPE === "disabled") {
297299 return new Response(
298300 JSON.stringify({ error: "Registration is disabled" }),
299301 { status: 400 },
300302 );
301303 }
304+ // Shares the "register" bucket with the password path so this
305+ // endpoint can't be used to sidestep that limiter (both create a
306+ // real users row).
307+ const ip = getClientIp(request, server);
308+ if (
309+ !checkRateLimit(
310+ ip,
311+ "register",
312+ REGISTRATION_MAX_ATTEMPTS,
313+ REGISTRATION_RATE_WINDOW_MS,
314+ )
315+ ) {
316+ return new Response(
317+ JSON.stringify({
318+ error: "Too many registration attempts. Please try again later.",
319+ }),
320+ {
321+ status: 429,
322+ headers: { "Content-Type": "application/json" },
323+ },
324+ );
325+ }
302326 const { username, application } = body;
303327 if (!username || !VALID_USERNAME_RE.test(username)) {
304328 return new Response(
@@ -361,8 +385,10 @@ export const authRoutes = new Elysia()
361385 },
362386 {
363387 body: t.Object({
364- username: t.String(),
365- application: t.Optional(t.String()),
388+ username: t.String({ maxLength: config.MAX_USERNAME_BYTES }),
389+ application: t.Optional(
390+ t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
391+ ),
366392 }),
367393 },
368394 )
Msrc/routes/git.ts
@@ -184,17 +184,33 @@ async function getRepo(
184184 return { name: repo.name, repoPath, isPrivate: repo.is_private === 1 };
185185 }
186186
187+interface GitResult {
188+ ok: boolean;
189+ stdout: Uint8Array;
190+ stderr: string;
191+}
192+
187193 async function spawnGit(
188194 args: string[],
189195 stdinBytes?: Uint8Array,
190-): Promise<Uint8Array> {
196+): Promise<GitResult> {
191197 const proc = Bun.spawn(args, {
192198 stdin: stdinBytes ?? "ignore",
193199 stdout: "pipe",
194200 stderr: "pipe",
195201 });
196- await proc.exited;
197- return new Uint8Array(await Bun.readableStreamToArrayBuffer(proc.stdout));
202+ // Drain stdout/stderr concurrently with waiting on exit — reading only
203+ // after `exited` can deadlock once git's output exceeds the OS pipe buffer.
204+ const [stdout, stderr, exitCode] = await Promise.all([
205+ Bun.readableStreamToArrayBuffer(proc.stdout),
206+ new Response(proc.stderr).text(),
207+ proc.exited,
208+ ]);
209+ return {
210+ ok: exitCode === 0,
211+ stdout: new Uint8Array(stdout),
212+ stderr,
213+ };
198214 }
199215
200216 export const gitRoutes = new Elysia()
@@ -237,10 +253,16 @@ export const gitRoutes = new Elysia()
237253 "--advertise-refs",
238254 repo.repoPath,
239255 ]);
256+ if (!refs.ok) {
257+ console.error(
258+ `git ${gitCmd} --advertise-refs failed for ${repo.name}: ${refs.stderr}`,
259+ );
260+ return new Response("Git backend error", { status: 500 });
261+ }
240262 const body = Buffer.concat([
241263 pktLine(`# service=git-${gitCmd}\n`),
242264 PKT_FLUSH,
243- refs,
265+ refs.stdout,
244266 ]);
245267
246268 return new Response(body, {
@@ -270,12 +292,21 @@ export const gitRoutes = new Elysia()
270292 )
271293 return unauthorized();
272294 }
295+ // Oversized bodies are rejected with 413 by the server's
296+ // maxRequestBodySize (config.MAX_UPLOAD_BYTES) before this handler runs,
297+ // so the cap surfaces as an explicit error rather than a truncated read.
273298 const body = new Uint8Array(await request.arrayBuffer());
274299 const result = await spawnGit(
275300 ["git", "upload-pack", "--stateless-rpc", repo.repoPath],
276301 body,
277302 );
278- return new Response(result, {
303+ if (!result.ok) {
304+ console.error(
305+ `git upload-pack failed for ${repo.name}: ${result.stderr}`,
306+ );
307+ return new Response("Git backend error", { status: 500 });
308+ }
309+ return new Response(result.stdout, {
279310 headers: {
280311 "Content-Type": "application/x-git-upload-pack-result",
281312 "Cache-Control": "no-cache",
@@ -292,16 +323,25 @@ export const gitRoutes = new Elysia()
292323 return unauthorized();
293324 const repo = await getRepo(params.repo);
294325 if (!repo) return new Response("Not Found", { status: 404 });
326+ // Oversized pushes are rejected with 413 by the server's
327+ // maxRequestBodySize (config.MAX_UPLOAD_BYTES) before this handler runs,
328+ // so the cap surfaces as an explicit error rather than a truncated read.
295329 const body = new Uint8Array(await request.arrayBuffer());
296330 const refUpdates = parseRefUpdates(body);
297331 const result = await spawnGit(
298332 ["git", "receive-pack", "--stateless-rpc", repo.repoPath],
299333 body,
300334 );
335+ if (!result.ok) {
336+ console.error(
337+ `git receive-pack failed for ${repo.name}: ${result.stderr}`,
338+ );
339+ return new Response("Git backend error", { status: 500 });
340+ }
301341 invalidateRefCache(repo.name);
302342 // Trigger CI in background — don't block the git push response
303343 triggerCiForPush(repo.name, refUpdates).catch(() => {});
304- return new Response(result, {
344+ return new Response(result.stdout, {
305345 headers: {
306346 "Content-Type": "application/x-git-receive-pack-result",
307347 "Cache-Control": "no-cache",
Msrc/routes/repos.tsx
@@ -164,6 +164,21 @@ async function mimeForContent(
164164 : "text/plain; charset=utf-8";
165165 }
166166
167+// A repo file opened directly via /raw is served from the forge's own origin.
168+// HTML would render as a document there and, despite our CSP, could load a
169+// same-origin `<script src>` pointing at another raw file — a stored-XSS path
170+// through the normal patch-merge flow. Serve HTML as plain text so it can't
171+// execute; every other type keeps its real MIME so media previews and
172+// downloads still work. Paired with `X-Content-Type-Options: nosniff` (set
173+// globally) so a text/plain body can't be sniffed back into HTML.
174+function rawServeContentType(contentType: string): string {
175+ const base = contentType.split(";")[0]!.trim().toLowerCase();
176+ if (base === "text/html" || base === "application/xhtml+xml") {
177+ return "text/plain; charset=utf-8";
178+ }
179+ return contentType;
180+}
181+
167182 const README_NAMES = ["README.md", "readme.md", "README", "readme"];
168183
169184 async function readReadme(
@@ -696,7 +711,9 @@ export const repoRoutes = new Elysia()
696711 const head = firstChunk
697712 ? Buffer.from(firstChunk.subarray(0, BINARY_DETECT_BYTES))
698713 : Buffer.alloc(0);
699- const contentType = await mimeForContent(filename, head);
714+ const contentType = rawServeContentType(
715+ await mimeForContent(filename, head),
716+ );
700717
701718 const rangeHeader = request.headers.get("Range");
702719 const fullProc = Bun.spawn(blobArgs, {
@@ -1106,10 +1123,7 @@ export const repoRoutes = new Elysia()
11061123 try {
11071124 renameSync(fromPath, toPath);
11081125 } catch (err) {
1109- console.error(
1110- `rename ${fromPath} -> ${toPath} failed`,
1111- err,
1112- );
1126+ console.error(`rename ${fromPath} -> ${toPath} failed`, err);
11131127 return back("Failed to rename repository on disk.");
11141128 }
11151129
Msrc/services/ci.ts
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
22 import path from "node:path";
33 import { parse as parseToml } from "smol-toml";
44 import config from "../config.ts";
5-import { paths } from "../constants.ts";
5+import { CI_MAX_LOG_BYTES, paths } from "../constants.ts";
66 import { db } from "../db/index.ts";
77 import { repoPath } from "./git.ts";
88
@@ -264,8 +264,31 @@ async function pullImage(image: string): Promise<void> {
264264 `/images/create?fromImage=${encodeURIComponent(name)}&tag=${encodeURIComponent(tag)}`,
265265 { method: "POST" },
266266 );
267- // Consume body to completion
268- await resp.body?.cancel();
267+ if (!resp.ok) {
268+ const detail = (await resp.text().catch(() => "")).trim();
269+ throw new Error(
270+ `Failed to pull image ${image}: HTTP ${resp.status}${detail ? ` ${detail}` : ""}`,
271+ );
272+ }
273+ // The /images/create stream must be read to the end — the pull only
274+ // completes when the stream does. Cancelling it (the previous behavior)
275+ // aborted the pull, so createContainer could race a not-yet-present image.
276+ // Each line is a JSON progress object; a trailing {"error": …} means the
277+ // pull failed despite the HTTP 200.
278+ const body = await resp.text();
279+ for (const line of body.split("\n")) {
280+ const trimmed = line.trim();
281+ if (!trimmed) continue;
282+ let obj: { error?: string } | null = null;
283+ try {
284+ obj = JSON.parse(trimmed);
285+ } catch {
286+ continue; // non-JSON progress line — ignore
287+ }
288+ if (obj?.error) {
289+ throw new Error(`Failed to pull image ${image}: ${obj.error}`);
290+ }
291+ }
269292 }
270293
271294 function parseMemoryBytes(s: string): number {
@@ -417,6 +440,22 @@ async function execInContainer(
417440 });
418441
419442 let log = "";
443+ let truncated = false;
444+ // Bound the buffered log: stop appending once we hit the cap (and note it
445+ // once) so a runaway step can't exhaust RAM or make each partial-flush
446+ // rewrite an ever-growing row.
447+ const appendLog = (text: string) => {
448+ if (truncated || !text) return;
449+ const room = CI_MAX_LOG_BYTES - log.length;
450+ if (text.length <= room) {
451+ log += text;
452+ } else {
453+ log += text.slice(0, Math.max(0, room));
454+ log += `\n[log truncated at ${CI_MAX_LOG_BYTES} bytes]\n`;
455+ truncated = true;
456+ }
457+ };
458+
420459 if (onPartialLog && startResp.body) {
421460 const reader = startResp.body.getReader();
422461 let buf = new Uint8Array(0);
@@ -430,18 +469,19 @@ async function execInContainer(
430469 buf = merged;
431470 const { text, remaining } = parseMuxFrames(buf);
432471 buf = remaining;
433- log += text;
434- if (Date.now() - lastSave >= 2000) {
472+ appendLog(text);
473+ // Once truncated the log no longer changes, so stop re-flushing it.
474+ if (!truncated && Date.now() - lastSave >= 2000) {
435475 await onPartialLog(log);
436476 lastSave = Date.now();
437477 }
438478 }
439479 const { text } = parseMuxFrames(buf);
440- log += text;
480+ appendLog(text);
441481 } else {
442482 const bodyBytes = new Uint8Array(await startResp.arrayBuffer());
443483 const { text } = parseMuxFrames(bodyBytes);
444- log = text;
484+ appendLog(text);
445485 }
446486
447487 // Get exit code
@@ -844,6 +884,11 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> {
844884 const stepTimeout =
845885 step.timeout ?? cfg.timeout ?? config.CI_DEFAULT_TIMEOUT;
846886 const timeoutSignal = AbortSignal.timeout(stepTimeout * 1000);
887+ // Abort the exec stream on either a run cancellation or the
888+ // per-step timeout. Docker has no per-exec kill, so on abort we
889+ // force-remove the container (below), which kills the command
890+ // still running inside it.
891+ const stepSignal = AbortSignal.any([signal, timeoutSignal]);
847892
848893 try {
849894 const { log, exitCode } = await execInContainer(
@@ -851,7 +896,7 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> {
851896 [...shell, command],
852897 cfg.work_dir,
853898 envArray,
854- timeoutSignal,
899+ stepSignal,
855900 async (partial) => {
856901 await db
857902 .updateTable("ci_steps")
@@ -868,9 +913,20 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> {
868913 runFailed = true;
869914 }
870915 } catch (err) {
871- stepLog = `Step failed: ${err instanceof Error ? err.message : String(err)}\n`;
916+ // A run cancellation is reported as "cancelled" by the outer
917+ // catch — don't relabel it as a step failure here.
918+ if (signal.aborted) throw err;
872919 stepStatus = "failure";
873920 runFailed = true;
921+ if (timeoutSignal.aborted) {
922+ stepLog = `Step timed out after ${stepTimeout}s\n`;
923+ // Kill the container now so the timed-out command stops
924+ // immediately rather than lingering until cleanup.
925+ await removeContainer(containerId);
926+ containerId = "";
927+ } else {
928+ stepLog = `Step failed: ${err instanceof Error ? err.message : String(err)}\n`;
929+ }
874930 }
875931 }
876932
Msrc/services/git.ts
@@ -65,7 +65,8 @@ export async function validateCommit(
6565 ): Promise<boolean> {
6666 const p = repoPath(repoName);
6767 try {
68- const out = await $`git -C ${p} cat-file -t ${hash}`.text();
68+ const out =
69+ await $`git -C ${p} cat-file -t --end-of-options ${hash}`.text();
6970 return out.trim() === "commit";
7071 } catch {
7172 return false;
@@ -90,6 +91,7 @@ export async function archiveRepo(
9091 "archive",
9192 "--format=zip",
9293 `--output=${path.join(outDir, `${base}.zip`)}`,
94+ "--end-of-options",
9395 ref,
9496 ],
9597 { signal, env: gitEnv },
@@ -104,6 +106,7 @@ export async function archiveRepo(
104106 "archive",
105107 "--format=tar.gz",
106108 `--output=${path.join(outDir, `${base}.tar.gz`)}`,
109+ "--end-of-options",
107110 ref,
108111 ],
109112 { signal, env: gitEnv },
@@ -113,7 +116,15 @@ export async function archiveRepo(
113116
114117 try {
115118 const tar = Bun.spawn(
116- ["git", "-C", p, "archive", "--format=tar", ref],
119+ [
120+ "git",
121+ "-C",
122+ p,
123+ "archive",
124+ "--format=tar",
125+ "--end-of-options",
126+ ref,
127+ ],
117128 { signal, env: gitEnv, stdout: "pipe" },
118129 );
119130 const zst = Bun.spawn(
@@ -299,8 +310,12 @@ export const git = {
299310 `gpg.ssh.allowedSignersFile=${paths.ALLOWED_SIGNERS_PATH}`,
300311 ];
301312 try {
313+ // `--end-of-options` before the ref stops a user-supplied ref that
314+ // begins with `-` from being parsed as a git option (e.g. `--output=`,
315+ // which would write to an arbitrary file). All real options must
316+ // therefore precede it.
302317 const out =
303- await $`git ${sigArgs} -C ${p} log ${ref} --format=%H%x1f%s%x1f%an%x1f%ai%x1f%G? --max-count=${limit} --skip=${skip}`.text();
318+ await $`git ${sigArgs} -C ${p} log --format=%H%x1f%s%x1f%an%x1f%ai%x1f%G? --max-count=${limit} --skip=${skip} --end-of-options ${ref}`.text();
304319 return parseLog(out);
305320 } catch {
306321 return [];
@@ -321,11 +336,20 @@ export const git = {
321336 p,
322337 "ls-tree",
323338 "--long",
339+ "--end-of-options",
324340 ref,
325341 "--",
326342 `${subpath}/`,
327343 ]
328- : ["git", "-C", p, "ls-tree", "--long", ref];
344+ : [
345+ "git",
346+ "-C",
347+ p,
348+ "ls-tree",
349+ "--long",
350+ "--end-of-options",
351+ ref,
352+ ];
329353 const out = await $`${args}`.text();
330354 const entries = parseLsTree(out);
331355 if (subpath) {
@@ -352,7 +376,7 @@ export const git = {
352376 const p = repoPath(name);
353377 try {
354378 const buf =
355- await $`git -C ${p} show ${`${ref}:${filePath}`}`.arrayBuffer();
379+ await $`git -C ${p} show --end-of-options ${`${ref}:${filePath}`}`.arrayBuffer();
356380 return Buffer.from(buf);
357381 } catch {
358382 return null;
@@ -362,7 +386,7 @@ export const git = {
362386 async diff(name: string, sha: string): Promise<string> {
363387 const p = repoPath(name);
364388 try {
365- return await $`git -C ${p} diff-tree --no-commit-id -r -p -M --root ${sha}`.text();
389+ return await $`git -C ${p} diff-tree --no-commit-id -r -p -M --root --end-of-options ${sha}`.text();
366390 } catch {
367391 return "";
368392 }
@@ -372,7 +396,8 @@ export const git = {
372396 if (/^0+$/.test(hash)) return 0;
373397 const p = repoPath(name);
374398 try {
375- const out = await $`git -C ${p} cat-file -s ${hash}`.text();
399+ const out =
400+ await $`git -C ${p} cat-file -s --end-of-options ${hash}`.text();
376401 return parseInt(out.trim(), 10) || 0;
377402 } catch {
378403 return 0;
@@ -522,7 +547,7 @@ export const git = {
522547 const p = repoPath(name);
523548 try {
524549 const out =
525- await $`git -C ${p} cat-file -s ${`${ref}:${filePath}`}`.text();
550+ await $`git -C ${p} cat-file -s --end-of-options ${`${ref}:${filePath}`}`.text();
526551 return parseInt(out.trim(), 10);
527552 } catch {
528553 return null;
@@ -535,13 +560,20 @@ export const git = {
535560 ): Promise<{ clean: boolean; output: string }> {
536561 const p = repoPath(name);
537562 const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
563+ // Use a throwaway index (GIT_INDEX_FILE) so this read-only preview never
564+ // mutates — nor races a concurrent applyPatch/editFile on — the repo's
565+ // shared index. Without it, this GET-triggered check could reset the
566+ // index mid-merge and silently drop the patch being written.
567+ const tmpIndex = `/tmp/hf-index-${Date.now()}-${Math.random().toString(36).slice(2)}`;
568+ const idxEnv = { ...gitEnv, GIT_INDEX_FILE: tmpIndex };
538569 try {
539570 await Bun.write(tmpFile, patchContent);
540571 // Bare repos have no working tree; populate the index from HEAD so we can
541572 // check against git objects (--cached) rather than the filesystem.
542- await $`git -C ${p} read-tree HEAD`.quiet();
573+ await $`git -C ${p} read-tree HEAD`.env(idxEnv).quiet();
543574 const result =
544575 await $`git -C ${p} apply --check --cached ${tmpFile}`
576+ .env(idxEnv)
545577 .quiet()
546578 .nothrow();
547579 return {
@@ -551,7 +583,7 @@ export const git = {
551583 } catch (e) {
552584 return { clean: false, output: String(e) };
553585 } finally {
554- await $`rm -f ${tmpFile}`.quiet().nothrow();
586+ await $`rm -f ${tmpFile} ${tmpIndex}`.quiet().nothrow();
555587 }
556588 },
557589
@@ -839,14 +871,14 @@ export const git = {
839871 ];
840872 const result =
841873 message !== undefined
842- ? await $`git ${sigArgs} -C ${p} tag -s ${tagName} ${ref} -m ${message}`
874+ ? await $`git ${sigArgs} -C ${p} tag -s -m ${message} --end-of-options ${tagName} ${ref}`
843875 .env({
844876 ...gitEnv,
845877 GIT_COMMITTER_NAME: taggerName!,
846878 GIT_COMMITTER_EMAIL: taggerEmail!,
847879 })
848880 .nothrow()
849- : await $`git -C ${p} tag ${tagName} ${ref}`.nothrow();
881+ : await $`git -C ${p} tag --end-of-options ${tagName} ${ref}`.nothrow();
850882 if (result.exitCode === 0) {
851883 invalidateRefCache(repoName);
852884 return "ok";
@@ -962,7 +994,8 @@ export const git = {
962994 async resolveRef(name: string, ref: string): Promise<string | null> {
963995 const p = repoPath(name);
964996 try {
965- const out = await $`git -C ${p} rev-parse --verify ${ref}`.text();
997+ const out =
998+ await $`git -C ${p} rev-parse --verify --end-of-options ${ref}`.text();
966999 return out.trim() || null;
9671000 } catch {
9681001 return null;
@@ -989,8 +1022,8 @@ export const git = {
9891022 ];
9901023 try {
9911024 const [metaOut, msgOut] = await Promise.all([
992- $`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(),
993- $`git -C ${p} log --format=%B -1 ${sha}`.text(),
1025+ $`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? --end-of-options ${sha}`.text(),
1026+ $`git -C ${p} log --format=%B -1 --end-of-options ${sha}`.text(),
9941027 ]);
9951028 const parts = metaOut.trim().split("\x1f");
9961029 const fullMsg = msgOut.trimEnd();
Msrc/services/repoSync.ts
@@ -93,9 +93,13 @@ export async function ensureRepoRecord(name: string): Promise<RepositoryRow> {
9393 .selectAll()
9494 .where("name", "=", name)
9595 .executeTakeFirst();
96- await $`git config --file ${path.join(repoPath(name), "config")} core.bare true`;
9796 if (existing) return existing;
9897
98+ // First time this repo is seen (pushed externally, manually imported, or
99+ // freshly created): make sure git treats it as bare before we record and
100+ // serve it. Doing this only on discovery — not on every read — keeps repo
101+ // page views free of a per-request subprocess spawn and config write.
102+ await $`git config --file ${path.join(repoPath(name), "config")} core.bare true`;
99103 const branch = await git.defaultBranch(name);
100104 const now = new Date().toISOString();
101105 return await db
Msrc/views/DiffView.tsx
@@ -151,7 +151,9 @@ export function DiffView({ files, repo, sha }: DiffViewProps) {
151151 <>
152152 {files.length > 0 && (
153153 <div class="commit-stats-bar">
154- <span class="commit-stats-text" safe>{changedStr}</span>
154+ <span class="commit-stats-text" safe>
155+ {changedStr}
156+ </span>
155157 </div>
156158 )}
157159
@@ -192,12 +194,18 @@ export function DiffView({ files, repo, sha }: DiffViewProps) {
192194 >
193195 {sl}
194196 </span>
195- <span class="diff-file-path mono" safe>
197+ <span
198+ class="diff-file-path mono"
199+ safe
200+ >
196201 {displayPath}
197202 </span>
198203 {f.status === "renamed" &&
199204 f.oldPath !== f.newPath && (
200- <span class="diff-rename-arrow" safe>
205+ <span
206+ class="diff-rename-arrow"
207+ safe
208+ >
201209 ← {f.oldPath}
202210 </span>
203211 )}
Msrc/views/ReactionBar.tsx
@@ -43,7 +43,7 @@ export function ReactionBar({
4343 disabled={!user}
4444 safe
4545 >
46- {unsafeEmoji + " " + r.count}
46+ {`${unsafeEmoji} ${r.count}`}
4747 </button>
4848 </form>
4949 );
Msrc/views/Settings.tsx
@@ -311,7 +311,10 @@ export function Settings({
311311 <span class="ssh-key-name" safe>
312312 {key.name}
313313 </span>
314- <span class="passkey-date ssh-key-fingerprint" safe>
314+ <span
315+ class="passkey-date ssh-key-fingerprint"
316+ safe
317+ >
315318 {key.fingerprint}
316319 </span>
317320 <span class="passkey-date" safe>
@@ -420,14 +423,20 @@ export function Settings({
420423 <strong safe>
421424 {u.username}
422425 </strong>
423- <span class="queue-item-date" safe>
426+ <span
427+ class="queue-item-date"
428+ safe
429+ >
424430 {formatDateTime(
425431 u.created_at,
426432 )}
427433 </span>
428434 </div>
429435 {!!u.register_application && (
430- <p class="queue-item-answer" safe>
436+ <p
437+ class="queue-item-answer"
438+ safe
439+ >
431440 {u.register_application}
432441 </p>
433442 )}
Msrc/views/ci/CiHistory.tsx
@@ -100,7 +100,10 @@ function CiHelp({ repo }: { repo: RepositoryRow }) {
100100 <div class="ci-help-section">
101101 <h4 class="ci-help-section-title">Status badge</h4>
102102 <p class="ci-help-badge-desc">Embed in your README:</p>
103- <code class="ci-help-badge-code" safe>{`![pipeline](/${repo.name}/ci/badge.svg)`}</code>
103+ <code
104+ class="ci-help-badge-code"
105+ safe
106+ >{`![pipeline](/${repo.name}/ci/badge.svg)`}</code>
104107 <h4
105108 class="ci-help-section-title"
106109 style="margin-top: var(--space-4)"
@@ -145,9 +148,7 @@ export function CiHistory({
145148 );
146149 return (
147150 <Layout user={user} title={`Pipelines — ${repo.name}`}>
148- {isRunning ? (
149- <meta http-equiv="refresh" content="4" />
150- ) : null}
151+ {isRunning ? <meta http-equiv="refresh" content="4" /> : null}
151152 <div class="container">
152153 <RepoHeader repo={repo} />
153154 <RepoNav repo={repo} active="ci" user={user} />
@@ -244,7 +245,8 @@ export function CiHistory({
244245 </span>
245246 {!!run.triggered_by_username && (
246247 <span class="text-muted" safe>
247- by {run.triggered_by_username}
248+ by{" "}
249+ {run.triggered_by_username}
248250 </span>
249251 )}
250252 {run.artifact_count > 0 && (
@@ -258,7 +260,10 @@ export function CiHistory({
258260 )}
259261 {!!run.started_at &&
260262 !!run.finished_at && (
261- <span class="text-muted" safe>
263+ <span
264+ class="text-muted"
265+ safe
266+ >
262267 {duration(
263268 run.started_at,
264269 run.finished_at,
Msrc/views/ci/CiRunDetail.tsx
@@ -137,7 +137,11 @@ export function CiRunDetail({
137137 {duration(run.started_at, run.finished_at)}
138138 </span>
139139 )}
140- <time datetime={run.created_at} class="text-muted" safe>
140+ <time
141+ datetime={run.created_at}
142+ class="text-muted"
143+ safe
144+ >
141145 {formatDateTime(run.created_at)}
142146 </time>
143147 </div>
@@ -222,14 +226,18 @@ export function CiRunDetail({
222226 <span class="ci-step-name" safe>
223227 {step.name}
224228 </span>
225- {!!step.started_at && !!step.finished_at && (
226- <span class="ci-step-duration text-muted" safe>
227- {duration(
228- step.started_at,
229- step.finished_at,
230- )}
231- </span>
232- )}
229+ {!!step.started_at &&
230+ !!step.finished_at && (
231+ <span
232+ class="ci-step-duration text-muted"
233+ safe
234+ >
235+ {duration(
236+ step.started_at,
237+ step.finished_at,
238+ )}
239+ </span>
240+ )}
233241 </summary>
234242 {step.log ? (
235243 <pre class="ci-step-log" safe>
@@ -258,7 +266,10 @@ export function CiRunDetail({
258266 >
259267 {artifact.filename}
260268 </a>
261- <span class="ci-artifact-size text-muted" safe>
269+ <span
270+ class="ci-artifact-size text-muted"
271+ safe
272+ >
262273 {formatBytes(artifact.size)}
263274 </span>
264275 </li>
Msrc/views/releases/ReleaseList.tsx
@@ -27,7 +27,9 @@ function NotesPreview({ notes }: { notes: string }) {
2727 </div>
2828 <span class="notes-toggle-label" />
2929 </summary>
30- <div class="notes-full markdown-body">{renderMarkdown(notes) as "safe"}</div>
30+ <div class="notes-full markdown-body">
31+ {renderMarkdown(notes) as "safe"}
32+ </div>
3133 </details>
3234 );
3335 }
@@ -97,7 +99,10 @@ export function ReleaseList({
9799 {release.tag_name}
98100 </a>
99101 )}
100- <time datetime={release.created_at} safe>
102+ <time
103+ datetime={release.created_at}
104+ safe
105+ >
101106 {formatDate(release.created_at)}
102107 </time>
103108 </div>
Msrc/views/repos/CommitDetail.tsx
@@ -109,7 +109,10 @@ export function CommitDetail({
109109 )}
110110 <div class="commit-card-meta-row">
111111 <span class="commit-meta-label">Commit</span>
112- <code class="commit-meta-value commit-sha-full mono" safe>
112+ <code
113+ class="commit-meta-value commit-sha-full mono"
114+ safe
115+ >
113116 {meta.hash}
114117 </code>
115118 </div>
Msrc/views/repos/FileBlob.tsx
@@ -124,7 +124,9 @@ export function FileBlob({
124124 </div>
125125 <div class="file-blob-body">
126126 {markdownHtml ? (
127- <div class="markdown-body">{markdownHtml as "safe"}</div>
127+ <div class="markdown-body">
128+ {markdownHtml as "safe"}
129+ </div>
128130 ) : view.type === "inline" ? (
129131 <div class="shiki-wrapper">{view.html as "safe"}</div>
130132 ) : view.type === "media" ? (
Msrc/views/repos/RepoHome.tsx
@@ -124,7 +124,9 @@ git push origin main`}</code>
124124 </a>
125125 )}
126126 </div>
127- <div class="markdown-body">{readmeHtml as "safe"}</div>
127+ <div class="markdown-body">
128+ {readmeHtml as "safe"}
129+ </div>
128130 </div>
129131 )}
130132 </>
Msrc/views/repos/RepoSettings.tsx
@@ -308,9 +308,9 @@ export function RepoSettings({
308308 <div class="danger-item-info">
309309 <strong>Rename this repository</strong>
310310 <p class="text-muted">
311- Changing the name breaks existing clone
312- URLs and links to this repo. Collaborators
313- will need to update their remotes.
311+ Changing the name breaks existing clone URLs
312+ and links to this repo. Collaborators will
313+ need to update their remotes.
314314 </p>
315315 </div>
316316 <form