harden low-severity review findings across git, CI, and auth

- raw: also serve SVG as text/plain (same XSS class as HTML)
- git: log unexpected failures in content/list ops (not existence checks)
- git: use mkdtemp for patch/edit temp files instead of predictable /tmp paths
- git: preserve existing file mode (exec bit, symlink) on edit/move
- ci: allocate repo_run_id from an atomic per-repo counter table so run
  numbers never collide or repeat after history pruning
- ci: purge cache volumes on repo delete/rename
- git: check tar/zstd exit codes in archiveRepo; drop partial .tar.zst
- auth: pin WebAuthn origin/RP-ID to BASE_URL, not the client Origin header
- test runner: retry only on the futex stall, never on real failures
- schema.sql: add repo_run_id + ci_run_counters, drop dead git_name/git_email
AuthorKonata <konata@posteo.jp>
Date
Commit56123391517390e7bb70ecdac394c54ffc488a66
Parent4524187
7 files changed, 211 insertions(+), 86 deletions(-)
Mscripts/test.ts
@@ -10,16 +10,21 @@ const files = readdirSync(testsDir)
1010 const STALL_TIMEOUT = 20_000; // kill if no output for 20s
1111 const MAX_RETRIES = 3;
1212 // retry logic needed because tests get randomly get stuck on startup with bun
13-// strace shows bun completely spinning in futex and not doing anything else
14-function runTest(filePath: string): Promise<boolean> {
13+// strace shows bun completely spinning in futex and not doing anything else.
14+// Only a stall-kill is retried — a genuine assertion failure returns
15+// {ok: false, stalled: false} and must NOT be retried, or a real product bug
16+// that fails intermittently would be laundered into a pass.
17+function runTest(filePath: string): Promise<{ ok: boolean; stalled: boolean }> {
1518 return new Promise((resolve) => {
1619 const child = spawn('bun', ['test', '--bail=1', '--timeout', '30000', filePath], {
1720 stdio: ['ignore', 'pipe', 'pipe'],
1821 });
1922
23+ let stalled = false;
2024 let timer = setTimeout(onStall, STALL_TIMEOUT);
2125
2226 function onStall() {
27+ stalled = true;
2328 console.error(`\n[test-runner] stall detected, killing ${path.basename(filePath)} (no output for ${STALL_TIMEOUT / 1000}s)`);
2429 child.kill('SIGKILL');
2530 }
@@ -40,7 +45,7 @@ function runTest(filePath: string): Promise<boolean> {
4045
4146 child.on('close', (code) => {
4247 clearTimeout(timer);
43- resolve(code === 0);
48+ resolve({ ok: code === 0, stalled });
4449 });
4550 });
4651 }
@@ -56,8 +61,10 @@ for (const file of files) {
5661 if (attempt > 1) {
5762 console.log(`[test-runner] retrying ${file} (attempt ${attempt}/${MAX_RETRIES})`);
5863 }
59- ok = await runTest(filePath);
60- if (ok) break;
64+ const result = await runTest(filePath);
65+ ok = result.ok;
66+ // Retry only the futex stall — a genuine failure is final.
67+ if (ok || !result.stalled) break;
6168 }
6269
6370 if (ok) passed++;
Msrc/db/index.ts
@@ -198,6 +198,14 @@ interface CiSecretTable {
198198 created_at: Generated<string>;
199199 }
200200
201+// Per-repo monotonic counter for the human-facing run number (#1, #2, …).
202+// Incremented atomically on each trigger so numbers never collide or repeat
203+// after history pruning — unlike deriving the number from a live row count.
204+interface CiRunCounterTable {
205+ repo_id: number;
206+ last_run_id: number;
207+}
208+
201209 export interface Database {
202210 users: UserTable;
203211 passkeys: PasskeyTable;
@@ -219,6 +227,7 @@ export interface Database {
219227 ci_steps: CiStepTable;
220228 ci_artifacts: CiArtifactTable;
221229 ci_secrets: CiSecretTable;
230+ ci_run_counters: CiRunCounterTable;
222231 }
223232
224233 // Selectable row types (id is plain number, as returned by queries)
@@ -345,6 +354,10 @@ sqlite.run(`CREATE TABLE IF NOT EXISTS ci_secrets (
345354 created_at TEXT NOT NULL DEFAULT (datetime('now')),
346355 UNIQUE(repo_id, name)
347356 )`);
357+sqlite.run(`CREATE TABLE IF NOT EXISTS ci_run_counters (
358+ repo_id INTEGER PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE,
359+ last_run_id INTEGER NOT NULL DEFAULT 0
360+)`);
348361
349362 // Run column-level migrations now that the CI tables are guaranteed to exist
350363 // (see the note above runMigrations).
Msrc/db/schema.sql
@@ -4,8 +4,6 @@ CREATE TABLE IF NOT EXISTS users (
44 password_hash TEXT,
55 created_at TEXT NOT NULL,
66 avatar_version INTEGER NOT NULL DEFAULT 1,
7- git_name TEXT,
8- git_email TEXT,
97 is_pending INTEGER NOT NULL DEFAULT 0,
108 register_application TEXT
119 );
@@ -170,7 +168,8 @@ CREATE TABLE IF NOT EXISTS ci_runs (
170168 variable_overrides TEXT,
171169 started_at TEXT,
172170 finished_at TEXT,
173- created_at TEXT NOT NULL DEFAULT (datetime('now'))
171+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
172+ repo_run_id INTEGER
174173 );
175174
176175 CREATE TABLE IF NOT EXISTS ci_steps (
@@ -201,3 +200,8 @@ CREATE TABLE IF NOT EXISTS ci_secrets (
201200 created_at TEXT NOT NULL DEFAULT (datetime('now')),
202201 UNIQUE(repo_id, name)
203202 );
203+
204+CREATE TABLE IF NOT EXISTS ci_run_counters (
205+ repo_id INTEGER PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE,
206+ last_run_id INTEGER NOT NULL DEFAULT 0
207+);
Msrc/routes/auth.tsx
@@ -29,10 +29,14 @@ import { Login } from "../views/auth/Login.tsx";
2929 import { Register } from "../views/auth/Register.tsx";
3030 import { html } from "../views/render.tsx";
3131
32-function rpFromRequest(request: Request): { origin: string; rpId: string } {
33- const origin = request.headers.get("origin");
34- if (!origin) throw new Error("Missing Origin header");
35- return { origin, rpId: new URL(origin).hostname };
32+// WebAuthn's expected origin and RP ID are pinned to the configured public
33+// origin (BASE_URL), never derived from the client's Origin header — otherwise
34+// the server-side origin check in verification validates the value against
35+// itself and becomes a no-op. The browser must be on this origin for passkeys
36+// to work, which is the intended production posture (set BASE_URL).
37+const PUBLIC_RP_ID = new URL(config.PUBLIC_ORIGIN).hostname;
38+function rpFromRequest(_request: Request): { origin: string; rpId: string } {
39+ return { origin: config.PUBLIC_ORIGIN, rpId: PUBLIC_RP_ID };
3640 }
3741
3842 function randomHex(bytes: number): string {
Msrc/routes/repos.tsx
@@ -20,6 +20,7 @@ import { db } from "../db/index.ts";
2020 import { contentDisposition } from "../lib/contentDisposition.ts";
2121 import { redirect } from "../lib/redirect.ts";
2222 import { requireAdmin, resolveSession } from "../middleware/session.ts";
23+import { purgeRepoCaches } from "../services/ci.ts";
2324 import {
2425 git,
2526 invalidateRefCache,
@@ -165,18 +166,24 @@ async function mimeForContent(
165166 }
166167
167168 // 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.
169+// HTML and SVG would render as active documents there and, despite our CSP,
170+// could load a same-origin `<script src>` pointing at another raw file — a
171+// stored-XSS path through the normal patch-merge flow. Serve those as plain
172+// text so they can't execute; every other type keeps its real MIME so media
173+// previews and downloads still work. (SVG is never shown via <img> in the
174+// blob view — it renders as highlighted source — so this costs no preview.)
175+// Paired with `X-Content-Type-Options: nosniff` (set globally) so a
176+// text/plain body can't be sniffed back into HTML.
177+const RAW_INERT_TYPES = new Set([
178+ "text/html",
179+ "application/xhtml+xml",
180+ "image/svg+xml",
181+]);
174182 function rawServeContentType(contentType: string): string {
175183 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;
184+ return RAW_INERT_TYPES.has(base)
185+ ? "text/plain; charset=utf-8"
186+ : contentType;
180187 }
181188
182189 const README_NAMES = ["README.md", "readme.md", "README", "readme"];
@@ -1075,6 +1082,9 @@ export const repoRoutes = new Elysia()
10751082 // we abort before touching the DB so the repo remains accessible.
10761083 rmSync(repoPath(repo.name), { recursive: true, force: true });
10771084 await db.deleteFrom("repositories").where("id", "=", repo.id).execute();
1085+ // Reclaim the repo's CI cache volumes (labeled by repo name), which the
1086+ // DB cascade doesn't touch. Best-effort — don't block the redirect.
1087+ purgeRepoCaches(repo.name).catch(() => {});
10781088
10791089 return new Response(null, { status: 302, headers: { Location: "/" } });
10801090 })
@@ -1144,6 +1154,10 @@ export const repoRoutes = new Elysia()
11441154 }
11451155
11461156 invalidateRefCache(oldName);
1157+ // CI cache volumes are labeled with the old repo name and would
1158+ // otherwise detach (a run under the new name can't find them).
1159+ // Purge them so caches rebuild cleanly under the new name.
1160+ purgeRepoCaches(oldName).catch(() => {});
11471161 return redirect(
11481162 `/${newName}/settings?success=${encodeURIComponent("Repository renamed.")}`,
11491163 );
Msrc/services/ci.ts
@@ -1069,14 +1069,23 @@ export async function triggerRun(
10691069 .returning("id")
10701070 .executeTakeFirstOrThrow();
10711071
1072- const countRow = await db
1073- .selectFrom("ci_runs")
1074- .select(db.fn.countAll<number>().as("c"))
1075- .where("repo_id", "=", repo.id)
1072+ // Allocate the human-facing run number atomically from a per-repo counter.
1073+ // A single upsert-and-increment can't collide under concurrent triggers and
1074+ // never reuses a number after pruneHistory shrinks the run table — both of
1075+ // which a COUNT(*)-based scheme suffered from.
1076+ const counter = await db
1077+ .insertInto("ci_run_counters")
1078+ .values({ repo_id: repo.id, last_run_id: 1 })
1079+ .onConflict((oc) =>
1080+ oc.column("repo_id").doUpdateSet((eb) => ({
1081+ last_run_id: eb("ci_run_counters.last_run_id", "+", 1),
1082+ })),
1083+ )
1084+ .returning("last_run_id")
10761085 .executeTakeFirstOrThrow();
10771086 await db
10781087 .updateTable("ci_runs")
1079- .set({ repo_run_id: Number(countRow.c) })
1088+ .set({ repo_run_id: counter.last_run_id })
10801089 .where("id", "=", runId.id)
10811090 .execute();
10821091
Msrc/services/git.ts
@@ -1,3 +1,5 @@
1+import { mkdtempSync, rmSync } from "node:fs";
2+import os from "node:os";
13 import path from "node:path";
24 import { $ as _$ } from "bun";
35
@@ -59,6 +61,49 @@ export function repoPath(name: string): string {
5961 return path.join(paths.REPOS_DIR, `${name}.git`);
6062 }
6163
64+// Log an unexpected git failure. Many git calls legitimately fail for benign
65+// reasons (a ref that doesn't exist yet, an empty repo), so callers still
66+// swallow the error and return an empty result — but we surface it here so a
67+// corrupted repo, permission problem, or missing binary isn't completely
68+// invisible.
69+function logGitError(op: string, name: string, err: unknown): void {
70+ console.error(`[git] ${op} failed for ${name}:`, err);
71+}
72+
73+// Create a private, uniquely-named temp directory (mode 0700, created
74+// atomically by the OS) and remove it afterward. Replaces predictable
75+// /tmp/hf-*-<time>-<rand> paths, which on a shared host were open to a
76+// pre-planted symlink redirecting our writes.
77+async function withTempDir<T>(
78+ prefix: string,
79+ fn: (dir: string) => Promise<T>,
80+): Promise<T> {
81+ const dir = mkdtempSync(path.join(os.tmpdir(), `hf-${prefix}-`));
82+ try {
83+ return await fn(dir);
84+ } finally {
85+ rmSync(dir, { recursive: true, force: true });
86+ }
87+}
88+
89+// The 6-digit octal mode of a path at a given ref (e.g. "100644", "100755",
90+// "120000"), or null if it doesn't exist there. Used to preserve the
91+// executable bit / symlink type across UI edits instead of forcing 100644.
92+async function treeFileMode(
93+ p: string,
94+ ref: string,
95+ filePath: string,
96+): Promise<string | null> {
97+ try {
98+ const out =
99+ await $`git -C ${p} ls-tree --end-of-options ${ref} -- ${filePath}`.text();
100+ const mode = out.split(/\s+/)[0];
101+ return mode && /^\d{6}$/.test(mode) ? mode : null;
102+ } catch {
103+ return null;
104+ }
105+}
106+
62107 export async function validateCommit(
63108 repoName: string,
64109 hash: string,
@@ -114,6 +159,11 @@ export async function archiveRepo(
114159 if ((await tgz.exited) !== 0)
115160 throw new Error("git archive (tar.gz) failed");
116161
162+ // The .tar.zst is a best-effort bonus format: if zstd is missing the spawn
163+ // throws and we skip it. But if zstd IS present and fails (disk full,
164+ // refusing to overwrite, …) we must not leave a truncated artifact behind
165+ // silently — log it and remove the partial file.
166+ const zstPath = path.join(outDir, `${base}.tar.zst`);
117167 try {
118168 const tar = Bun.spawn(
119169 [
@@ -127,13 +177,21 @@ export async function archiveRepo(
127177 ],
128178 { signal, env: gitEnv, stdout: "pipe" },
129179 );
130- const zst = Bun.spawn(
131- ["zstd", "-o", path.join(outDir, `${base}.tar.zst`)],
132- { signal, env: gitEnv, stdin: tar.stdout },
133- );
134- await Promise.all([tar.exited, zst.exited]);
180+ const zst = Bun.spawn(["zstd", "-f", "-o", zstPath], {
181+ signal,
182+ env: gitEnv,
183+ stdin: tar.stdout,
184+ });
185+ const [tarCode, zstCode] = await Promise.all([tar.exited, zst.exited]);
186+ if (tarCode !== 0 || zstCode !== 0) {
187+ console.error(
188+ `[git] archive (tar.zst) failed for ${repoName}@${ref}: tar=${tarCode} zstd=${zstCode}`,
189+ );
190+ await $`rm -f ${zstPath}`.quiet().nothrow();
191+ }
135192 } catch {
136193 // zstd not available — skip silently
194+ await $`rm -f ${zstPath}`.quiet().nothrow();
137195 }
138196 }
139197
@@ -317,7 +375,8 @@ export const git = {
317375 const out =
318376 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();
319377 return parseLog(out);
320- } catch {
378+ } catch (e) {
379+ logGitError(`log(${ref})`, name, e);
321380 return [];
322381 }
323382 },
@@ -363,7 +422,8 @@ export const git = {
363422 }));
364423 }
365424 return entries;
366- } catch {
425+ } catch (e) {
426+ logGitError(`lsTree(${ref})`, name, e);
367427 return [];
368428 }
369429 },
@@ -378,7 +438,8 @@ export const git = {
378438 const buf =
379439 await $`git -C ${p} show --end-of-options ${`${ref}:${filePath}`}`.arrayBuffer();
380440 return Buffer.from(buf);
381- } catch {
441+ } catch (e) {
442+ logGitError(`show(${ref}:${filePath})`, name, e);
382443 return null;
383444 }
384445 },
@@ -387,7 +448,8 @@ export const git = {
387448 const p = repoPath(name);
388449 try {
389450 return await $`git -C ${p} diff-tree --no-commit-id -r -p -M --root --end-of-options ${sha}`.text();
390- } catch {
451+ } catch (e) {
452+ logGitError(`diff(${sha})`, name, e);
391453 return "";
392454 }
393455 },
@@ -419,7 +481,8 @@ export const git = {
419481 branchCache.delete(branchCache.keys().next().value!);
420482 }
421483 return value;
422- } catch {
484+ } catch (e) {
485+ logGitError("branches", name, e);
423486 return [];
424487 }
425488 },
@@ -439,7 +502,8 @@ export const git = {
439502 tagCache.delete(tagCache.keys().next().value!);
440503 }
441504 return value;
442- } catch {
505+ } catch (e) {
506+ logGitError("tags", name, e);
443507 return [];
444508 }
445509 },
@@ -469,7 +533,8 @@ export const git = {
469533 date: parts[4] ?? "",
470534 };
471535 });
472- } catch {
536+ } catch (e) {
537+ logGitError("branchesWithInfo", name, e);
473538 return [];
474539 }
475540 },
@@ -501,7 +566,8 @@ export const git = {
501566 isAnnotated,
502567 };
503568 });
504- } catch {
569+ } catch (e) {
570+ logGitError("tagsWithInfo", name, e);
505571 return [];
506572 }
507573 },
@@ -559,31 +625,35 @@ export const git = {
559625 patchContent: string,
560626 ): Promise<{ clean: boolean; output: string }> {
561627 const p = repoPath(name);
562- 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 };
569628 try {
570- await Bun.write(tmpFile, patchContent);
571- // Bare repos have no working tree; populate the index from HEAD so we can
572- // check against git objects (--cached) rather than the filesystem.
573- await $`git -C ${p} read-tree HEAD`.env(idxEnv).quiet();
574- const result =
575- await $`git -C ${p} apply --check --cached ${tmpFile}`
576- .env(idxEnv)
577- .quiet()
578- .nothrow();
579- return {
580- clean: result.exitCode === 0,
581- output: result.stderr.toString(),
582- };
629+ return await withTempDir("patch", async (dir) => {
630+ const tmpFile = path.join(dir, "change.patch");
631+ // Use a throwaway index (GIT_INDEX_FILE) so this read-only
632+ // preview never mutates — nor races a concurrent
633+ // applyPatch/editFile on — the repo's shared index. Without it,
634+ // this GET-triggered check could reset the index mid-merge and
635+ // silently drop the patch being written.
636+ const idxEnv = {
637+ ...gitEnv,
638+ GIT_INDEX_FILE: path.join(dir, "index"),
639+ };
640+ await Bun.write(tmpFile, patchContent);
641+ // Bare repos have no working tree; populate the index from HEAD
642+ // so we can check against git objects (--cached) rather than the
643+ // filesystem.
644+ await $`git -C ${p} read-tree HEAD`.env(idxEnv).quiet();
645+ const result =
646+ await $`git -C ${p} apply --check --cached ${tmpFile}`
647+ .env(idxEnv)
648+ .quiet()
649+ .nothrow();
650+ return {
651+ clean: result.exitCode === 0,
652+ output: result.stderr.toString(),
653+ };
654+ });
583655 } catch (e) {
584656 return { clean: false, output: String(e) };
585- } finally {
586- await $`rm -f ${tmpFile} ${tmpIndex}`.quiet().nothrow();
587657 }
588658 },
589659
@@ -597,8 +667,8 @@ export const git = {
597667 ): Promise<void> {
598668 return withRepoLock(name, async () => {
599669 const p = repoPath(name);
600- const tmpFile = `/tmp/hf-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
601- try {
670+ await withTempDir("patch", async (dir) => {
671+ const tmpFile = path.join(dir, "change.patch");
602672 await Bun.write(tmpFile, patchContent);
603673 // Populate index, apply to index, then create a real commit in the bare repo.
604674 await $`git -C ${p} read-tree HEAD`;
@@ -629,9 +699,7 @@ export const git = {
629699 await $`git -C ${p} symbolic-ref HEAD`.text()
630700 ).trim();
631701 await $`git -C ${p} update-ref ${ref} ${commit}`;
632- } finally {
633- await $`rm -f ${tmpFile}`.quiet().nothrow();
634- }
702+ });
635703 });
636704 },
637705
@@ -650,8 +718,13 @@ export const git = {
650718 newPath && newPath !== filePath ? newPath : filePath;
651719 const isMove = targetPath !== filePath;
652720 const p = repoPath(name);
653- const tmpFile = `/tmp/hf-edit-${Date.now()}-${Math.random().toString(36).slice(2)}`;
654- try {
721+ // Preserve the file's existing mode (executable bit / symlink)
722+ // rather than forcing every edit back to a plain 100644 file.
723+ const mode =
724+ (await treeFileMode(p, `refs/heads/${branch}`, filePath)) ??
725+ "100644";
726+ return await withTempDir("edit", async (dir) => {
727+ const tmpFile = path.join(dir, "blob");
655728 await Bun.write(tmpFile, content);
656729 if (isMove) {
657730 await $`git --work-tree=/tmp -C ${p} read-tree refs/heads/${branch}`;
@@ -664,7 +737,8 @@ export const git = {
664737 if (isMove) {
665738 await $`git --work-tree=/tmp -C ${p} update-index --remove ${filePath}`;
666739 }
667- await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${targetPath}`;
740+ const cacheInfo = `${mode},${blobHash},${targetPath}`;
741+ await $`git -C ${p} update-index --add --cacheinfo ${cacheInfo}`;
668742 const tree = isMove
669743 ? (
670744 await $`git --work-tree=/tmp -C ${p} write-tree`.text()
@@ -692,9 +766,7 @@ export const git = {
692766 ).trim();
693767 await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
694768 return commit;
695- } finally {
696- await $`rm -f ${tmpFile}`.quiet().nothrow();
697- }
769+ });
698770 });
699771 },
700772
@@ -709,8 +781,8 @@ export const git = {
709781 ): Promise<string> {
710782 return withRepoLock(name, async () => {
711783 const p = repoPath(name);
712- const tmpFile = `/tmp/hf-new-${Date.now()}-${Math.random().toString(36).slice(2)}`;
713- try {
784+ return await withTempDir("new", async (dir) => {
785+ const tmpFile = path.join(dir, "blob");
714786 await Bun.write(tmpFile, content);
715787 const parentSha = await git.resolveRef(
716788 name,
@@ -750,9 +822,7 @@ export const git = {
750822 ).trim();
751823 await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
752824 return commit;
753- } finally {
754- await $`rm -f ${tmpFile}`.quiet().nothrow();
755- }
825+ });
756826 });
757827 },
758828
@@ -809,10 +879,14 @@ export const git = {
809879 ): Promise<string> {
810880 return withRepoLock(name, async () => {
811881 const p = repoPath(name);
812- const tmpFile = `/tmp/hf-move-${Date.now()}-${Math.random().toString(36).slice(2)}`;
813- try {
882+ // Preserve the moved file's mode (executable bit / symlink).
883+ const mode =
884+ (await treeFileMode(p, `refs/heads/${branch}`, oldPath)) ??
885+ "100644";
886+ return await withTempDir("move", async (dir) => {
887+ const tmpFile = path.join(dir, "blob");
814888 const contentBuf =
815- await $`git -C ${p} show ${`${branch}:${oldPath}`}`.arrayBuffer();
889+ await $`git -C ${p} show --end-of-options ${`${branch}:${oldPath}`}`.arrayBuffer();
816890 await Bun.write(tmpFile, contentBuf);
817891 // --work-tree=/tmp is needed because bare repos have no work tree and
818892 // `update-index --remove` requires one (even though it only touches the index).
@@ -821,7 +895,8 @@ export const git = {
821895 await $`git -C ${p} hash-object -w ${tmpFile}`.text()
822896 ).trim();
823897 await $`git --work-tree=/tmp -C ${p} update-index --remove ${oldPath}`;
824- await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${newPath}`;
898+ const cacheInfo = `${mode},${blobHash},${newPath}`;
899+ await $`git -C ${p} update-index --add --cacheinfo ${cacheInfo}`;
825900 const tree = (
826901 await $`git --work-tree=/tmp -C ${p} write-tree`.text()
827902 ).trim();
@@ -847,9 +922,7 @@ export const git = {
847922 ).trim();
848923 await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
849924 return commit;
850- } finally {
851- await $`rm -f ${tmpFile}`.quiet().nothrow();
852- }
925+ });
853926 });
854927 },
855928
@@ -1049,7 +1122,8 @@ export const git = {
10491122 parents: (parts[7] ?? "").trim().split(/\s+/).filter(Boolean),
10501123 sigStatus: parseSigStatus(parts[8] ?? ""),
10511124 };
1052- } catch {
1125+ } catch (e) {
1126+ logGitError(`commitMeta(${sha})`, name, e);
10531127 return null;
10541128 }
10551129 },