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
Mscripts/test.ts
| @@ -10,16 +10,21 @@ const files = readdirSync(testsDir) | |||
|---|---|---|---|
| 10 | 10 | const STALL_TIMEOUT = 20_000; // kill if no output for 20s | |
| 11 | 11 | const MAX_RETRIES = 3; | |
| 12 | 12 | // 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 }> { | |
| 15 | 18 | return new Promise((resolve) => { | |
| 16 | 19 | const child = spawn('bun', ['test', '--bail=1', '--timeout', '30000', filePath], { | |
| 17 | 20 | stdio: ['ignore', 'pipe', 'pipe'], | |
| 18 | 21 | }); | |
| 19 | 22 | ||
| 23 | + | let stalled = false; | |
| 20 | 24 | let timer = setTimeout(onStall, STALL_TIMEOUT); | |
| 21 | 25 | ||
| 22 | 26 | function onStall() { | |
| 27 | + | stalled = true; | |
| 23 | 28 | console.error(`\n[test-runner] stall detected, killing ${path.basename(filePath)} (no output for ${STALL_TIMEOUT / 1000}s)`); | |
| 24 | 29 | child.kill('SIGKILL'); | |
| 25 | 30 | } | |
| @@ -40,7 +45,7 @@ function runTest(filePath: string): Promise<boolean> { | |||
|---|---|---|---|
| 40 | 45 | ||
| 41 | 46 | child.on('close', (code) => { | |
| 42 | 47 | clearTimeout(timer); | |
| 43 | - | resolve(code === 0); | |
| 48 | + | resolve({ ok: code === 0, stalled }); | |
| 44 | 49 | }); | |
| 45 | 50 | }); | |
| 46 | 51 | } | |
| @@ -56,8 +61,10 @@ for (const file of files) { | |||
|---|---|---|---|
| 56 | 61 | if (attempt > 1) { | |
| 57 | 62 | console.log(`[test-runner] retrying ${file} (attempt ${attempt}/${MAX_RETRIES})`); | |
| 58 | 63 | } | |
| 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; | |
| 61 | 68 | } | |
| 62 | 69 | ||
| 63 | 70 | if (ok) passed++; | |
Msrc/db/index.ts
| @@ -198,6 +198,14 @@ interface CiSecretTable { | |||
|---|---|---|---|
| 198 | 198 | created_at: Generated<string>; | |
| 199 | 199 | } | |
| 200 | 200 | ||
| 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 | + | ||
| 201 | 209 | export interface Database { | |
| 202 | 210 | users: UserTable; | |
| 203 | 211 | passkeys: PasskeyTable; | |
| @@ -219,6 +227,7 @@ export interface Database { | |||
|---|---|---|---|
| 219 | 227 | ci_steps: CiStepTable; | |
| 220 | 228 | ci_artifacts: CiArtifactTable; | |
| 221 | 229 | ci_secrets: CiSecretTable; | |
| 230 | + | ci_run_counters: CiRunCounterTable; | |
| 222 | 231 | } | |
| 223 | 232 | ||
| 224 | 233 | // Selectable row types (id is plain number, as returned by queries) | |
| @@ -345,6 +354,10 @@ sqlite.run(`CREATE TABLE IF NOT EXISTS ci_secrets ( | |||
|---|---|---|---|
| 345 | 354 | created_at TEXT NOT NULL DEFAULT (datetime('now')), | |
| 346 | 355 | UNIQUE(repo_id, name) | |
| 347 | 356 | )`); | |
| 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 | + | )`); | |
| 348 | 361 | ||
| 349 | 362 | // Run column-level migrations now that the CI tables are guaranteed to exist | |
| 350 | 363 | // (see the note above runMigrations). | |
Msrc/db/schema.sql
| @@ -4,8 +4,6 @@ CREATE TABLE IF NOT EXISTS users ( | |||
|---|---|---|---|
| 4 | 4 | password_hash TEXT, | |
| 5 | 5 | created_at TEXT NOT NULL, | |
| 6 | 6 | avatar_version INTEGER NOT NULL DEFAULT 1, | |
| 7 | - | git_name TEXT, | |
| 8 | - | git_email TEXT, | |
| 9 | 7 | is_pending INTEGER NOT NULL DEFAULT 0, | |
| 10 | 8 | register_application TEXT | |
| 11 | 9 | ); | |
| @@ -170,7 +168,8 @@ CREATE TABLE IF NOT EXISTS ci_runs ( | |||
|---|---|---|---|
| 170 | 168 | variable_overrides TEXT, | |
| 171 | 169 | started_at TEXT, | |
| 172 | 170 | 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 | |
| 174 | 173 | ); | |
| 175 | 174 | ||
| 176 | 175 | CREATE TABLE IF NOT EXISTS ci_steps ( | |
| @@ -201,3 +200,8 @@ CREATE TABLE IF NOT EXISTS ci_secrets ( | |||
|---|---|---|---|
| 201 | 200 | created_at TEXT NOT NULL DEFAULT (datetime('now')), | |
| 202 | 201 | UNIQUE(repo_id, name) | |
| 203 | 202 | ); | |
| 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"; | |||
|---|---|---|---|
| 29 | 29 | import { Register } from "../views/auth/Register.tsx"; | |
| 30 | 30 | import { html } from "../views/render.tsx"; | |
| 31 | 31 | ||
| 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 }; | |
| 36 | 40 | } | |
| 37 | 41 | ||
| 38 | 42 | function randomHex(bytes: number): string { | |
Msrc/routes/repos.tsx
| @@ -20,6 +20,7 @@ import { db } from "../db/index.ts"; | |||
|---|---|---|---|
| 20 | 20 | import { contentDisposition } from "../lib/contentDisposition.ts"; | |
| 21 | 21 | import { redirect } from "../lib/redirect.ts"; | |
| 22 | 22 | import { requireAdmin, resolveSession } from "../middleware/session.ts"; | |
| 23 | + | import { purgeRepoCaches } from "../services/ci.ts"; | |
| 23 | 24 | import { | |
| 24 | 25 | git, | |
| 25 | 26 | invalidateRefCache, | |
| @@ -165,18 +166,24 @@ async function mimeForContent( | |||
|---|---|---|---|
| 165 | 166 | } | |
| 166 | 167 | ||
| 167 | 168 | // 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 | + | ]); | |
| 174 | 182 | function rawServeContentType(contentType: string): string { | |
| 175 | 183 | 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; | |
| 180 | 187 | } | |
| 181 | 188 | ||
| 182 | 189 | const README_NAMES = ["README.md", "readme.md", "README", "readme"]; | |
| @@ -1075,6 +1082,9 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 1075 | 1082 | // we abort before touching the DB so the repo remains accessible. | |
| 1076 | 1083 | rmSync(repoPath(repo.name), { recursive: true, force: true }); | |
| 1077 | 1084 | 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(() => {}); | |
| 1078 | 1088 | ||
| 1079 | 1089 | return new Response(null, { status: 302, headers: { Location: "/" } }); | |
| 1080 | 1090 | }) | |
| @@ -1144,6 +1154,10 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 1144 | 1154 | } | |
| 1145 | 1155 | ||
| 1146 | 1156 | 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(() => {}); | |
| 1147 | 1161 | return redirect( | |
| 1148 | 1162 | `/${newName}/settings?success=${encodeURIComponent("Repository renamed.")}`, | |
| 1149 | 1163 | ); | |
Msrc/services/ci.ts
| @@ -1069,14 +1069,23 @@ export async function triggerRun( | |||
|---|---|---|---|
| 1069 | 1069 | .returning("id") | |
| 1070 | 1070 | .executeTakeFirstOrThrow(); | |
| 1071 | 1071 | ||
| 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") | |
| 1076 | 1085 | .executeTakeFirstOrThrow(); | |
| 1077 | 1086 | await db | |
| 1078 | 1087 | .updateTable("ci_runs") | |
| 1079 | - | .set({ repo_run_id: Number(countRow.c) }) | |
| 1088 | + | .set({ repo_run_id: counter.last_run_id }) | |
| 1080 | 1089 | .where("id", "=", runId.id) | |
| 1081 | 1090 | .execute(); | |
| 1082 | 1091 | ||
Msrc/services/git.ts
| @@ -1,3 +1,5 @@ | |||
|---|---|---|---|
| 1 | + | import { mkdtempSync, rmSync } from "node:fs"; | |
| 2 | + | import os from "node:os"; | |
| 1 | 3 | import path from "node:path"; | |
| 2 | 4 | import { $ as _$ } from "bun"; | |
| 3 | 5 | ||
| @@ -59,6 +61,49 @@ export function repoPath(name: string): string { | |||
|---|---|---|---|
| 59 | 61 | return path.join(paths.REPOS_DIR, `${name}.git`); | |
| 60 | 62 | } | |
| 61 | 63 | ||
| 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 | + | ||
| 62 | 107 | export async function validateCommit( | |
| 63 | 108 | repoName: string, | |
| 64 | 109 | hash: string, | |
| @@ -114,6 +159,11 @@ export async function archiveRepo( | |||
|---|---|---|---|
| 114 | 159 | if ((await tgz.exited) !== 0) | |
| 115 | 160 | throw new Error("git archive (tar.gz) failed"); | |
| 116 | 161 | ||
| 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`); | |
| 117 | 167 | try { | |
| 118 | 168 | const tar = Bun.spawn( | |
| 119 | 169 | [ | |
| @@ -127,13 +177,21 @@ export async function archiveRepo( | |||
|---|---|---|---|
| 127 | 177 | ], | |
| 128 | 178 | { signal, env: gitEnv, stdout: "pipe" }, | |
| 129 | 179 | ); | |
| 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 | + | } | |
| 135 | 192 | } catch { | |
| 136 | 193 | // zstd not available — skip silently | |
| 194 | + | await $`rm -f ${zstPath}`.quiet().nothrow(); | |
| 137 | 195 | } | |
| 138 | 196 | } | |
| 139 | 197 | ||
| @@ -317,7 +375,8 @@ export const git = { | |||
|---|---|---|---|
| 317 | 375 | const out = | |
| 318 | 376 | 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(); | |
| 319 | 377 | return parseLog(out); | |
| 320 | - | } catch { | |
| 378 | + | } catch (e) { | |
| 379 | + | logGitError(`log(${ref})`, name, e); | |
| 321 | 380 | return []; | |
| 322 | 381 | } | |
| 323 | 382 | }, | |
| @@ -363,7 +422,8 @@ export const git = { | |||
|---|---|---|---|
| 363 | 422 | })); | |
| 364 | 423 | } | |
| 365 | 424 | return entries; | |
| 366 | - | } catch { | |
| 425 | + | } catch (e) { | |
| 426 | + | logGitError(`lsTree(${ref})`, name, e); | |
| 367 | 427 | return []; | |
| 368 | 428 | } | |
| 369 | 429 | }, | |
| @@ -378,7 +438,8 @@ export const git = { | |||
|---|---|---|---|
| 378 | 438 | const buf = | |
| 379 | 439 | await $`git -C ${p} show --end-of-options ${`${ref}:${filePath}`}`.arrayBuffer(); | |
| 380 | 440 | return Buffer.from(buf); | |
| 381 | - | } catch { | |
| 441 | + | } catch (e) { | |
| 442 | + | logGitError(`show(${ref}:${filePath})`, name, e); | |
| 382 | 443 | return null; | |
| 383 | 444 | } | |
| 384 | 445 | }, | |
| @@ -387,7 +448,8 @@ export const git = { | |||
|---|---|---|---|
| 387 | 448 | const p = repoPath(name); | |
| 388 | 449 | try { | |
| 389 | 450 | 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); | |
| 391 | 453 | return ""; | |
| 392 | 454 | } | |
| 393 | 455 | }, | |
| @@ -419,7 +481,8 @@ export const git = { | |||
|---|---|---|---|
| 419 | 481 | branchCache.delete(branchCache.keys().next().value!); | |
| 420 | 482 | } | |
| 421 | 483 | return value; | |
| 422 | - | } catch { | |
| 484 | + | } catch (e) { | |
| 485 | + | logGitError("branches", name, e); | |
| 423 | 486 | return []; | |
| 424 | 487 | } | |
| 425 | 488 | }, | |
| @@ -439,7 +502,8 @@ export const git = { | |||
|---|---|---|---|
| 439 | 502 | tagCache.delete(tagCache.keys().next().value!); | |
| 440 | 503 | } | |
| 441 | 504 | return value; | |
| 442 | - | } catch { | |
| 505 | + | } catch (e) { | |
| 506 | + | logGitError("tags", name, e); | |
| 443 | 507 | return []; | |
| 444 | 508 | } | |
| 445 | 509 | }, | |
| @@ -469,7 +533,8 @@ export const git = { | |||
|---|---|---|---|
| 469 | 533 | date: parts[4] ?? "", | |
| 470 | 534 | }; | |
| 471 | 535 | }); | |
| 472 | - | } catch { | |
| 536 | + | } catch (e) { | |
| 537 | + | logGitError("branchesWithInfo", name, e); | |
| 473 | 538 | return []; | |
| 474 | 539 | } | |
| 475 | 540 | }, | |
| @@ -501,7 +566,8 @@ export const git = { | |||
|---|---|---|---|
| 501 | 566 | isAnnotated, | |
| 502 | 567 | }; | |
| 503 | 568 | }); | |
| 504 | - | } catch { | |
| 569 | + | } catch (e) { | |
| 570 | + | logGitError("tagsWithInfo", name, e); | |
| 505 | 571 | return []; | |
| 506 | 572 | } | |
| 507 | 573 | }, | |
| @@ -559,31 +625,35 @@ export const git = { | |||
|---|---|---|---|
| 559 | 625 | patchContent: string, | |
| 560 | 626 | ): Promise<{ clean: boolean; output: string }> { | |
| 561 | 627 | 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 }; | |
| 569 | 628 | 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 | + | }); | |
| 583 | 655 | } catch (e) { | |
| 584 | 656 | return { clean: false, output: String(e) }; | |
| 585 | - | } finally { | |
| 586 | - | await $`rm -f ${tmpFile} ${tmpIndex}`.quiet().nothrow(); | |
| 587 | 657 | } | |
| 588 | 658 | }, | |
| 589 | 659 | ||
| @@ -597,8 +667,8 @@ export const git = { | |||
|---|---|---|---|
| 597 | 667 | ): Promise<void> { | |
| 598 | 668 | return withRepoLock(name, async () => { | |
| 599 | 669 | 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"); | |
| 602 | 672 | await Bun.write(tmpFile, patchContent); | |
| 603 | 673 | // Populate index, apply to index, then create a real commit in the bare repo. | |
| 604 | 674 | await $`git -C ${p} read-tree HEAD`; | |
| @@ -629,9 +699,7 @@ export const git = { | |||
|---|---|---|---|
| 629 | 699 | await $`git -C ${p} symbolic-ref HEAD`.text() | |
| 630 | 700 | ).trim(); | |
| 631 | 701 | await $`git -C ${p} update-ref ${ref} ${commit}`; | |
| 632 | - | } finally { | |
| 633 | - | await $`rm -f ${tmpFile}`.quiet().nothrow(); | |
| 634 | - | } | |
| 702 | + | }); | |
| 635 | 703 | }); | |
| 636 | 704 | }, | |
| 637 | 705 | ||
| @@ -650,8 +718,13 @@ export const git = { | |||
|---|---|---|---|
| 650 | 718 | newPath && newPath !== filePath ? newPath : filePath; | |
| 651 | 719 | const isMove = targetPath !== filePath; | |
| 652 | 720 | 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"); | |
| 655 | 728 | await Bun.write(tmpFile, content); | |
| 656 | 729 | if (isMove) { | |
| 657 | 730 | await $`git --work-tree=/tmp -C ${p} read-tree refs/heads/${branch}`; | |
| @@ -664,7 +737,8 @@ export const git = { | |||
|---|---|---|---|
| 664 | 737 | if (isMove) { | |
| 665 | 738 | await $`git --work-tree=/tmp -C ${p} update-index --remove ${filePath}`; | |
| 666 | 739 | } | |
| 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}`; | |
| 668 | 742 | const tree = isMove | |
| 669 | 743 | ? ( | |
| 670 | 744 | await $`git --work-tree=/tmp -C ${p} write-tree`.text() | |
| @@ -692,9 +766,7 @@ export const git = { | |||
|---|---|---|---|
| 692 | 766 | ).trim(); | |
| 693 | 767 | await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`; | |
| 694 | 768 | return commit; | |
| 695 | - | } finally { | |
| 696 | - | await $`rm -f ${tmpFile}`.quiet().nothrow(); | |
| 697 | - | } | |
| 769 | + | }); | |
| 698 | 770 | }); | |
| 699 | 771 | }, | |
| 700 | 772 | ||
| @@ -709,8 +781,8 @@ export const git = { | |||
|---|---|---|---|
| 709 | 781 | ): Promise<string> { | |
| 710 | 782 | return withRepoLock(name, async () => { | |
| 711 | 783 | 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"); | |
| 714 | 786 | await Bun.write(tmpFile, content); | |
| 715 | 787 | const parentSha = await git.resolveRef( | |
| 716 | 788 | name, | |
| @@ -750,9 +822,7 @@ export const git = { | |||
|---|---|---|---|
| 750 | 822 | ).trim(); | |
| 751 | 823 | await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`; | |
| 752 | 824 | return commit; | |
| 753 | - | } finally { | |
| 754 | - | await $`rm -f ${tmpFile}`.quiet().nothrow(); | |
| 755 | - | } | |
| 825 | + | }); | |
| 756 | 826 | }); | |
| 757 | 827 | }, | |
| 758 | 828 | ||
| @@ -809,10 +879,14 @@ export const git = { | |||
|---|---|---|---|
| 809 | 879 | ): Promise<string> { | |
| 810 | 880 | return withRepoLock(name, async () => { | |
| 811 | 881 | 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"); | |
| 814 | 888 | const contentBuf = | |
| 815 | - | await $`git -C ${p} show ${`${branch}:${oldPath}`}`.arrayBuffer(); | |
| 889 | + | await $`git -C ${p} show --end-of-options ${`${branch}:${oldPath}`}`.arrayBuffer(); | |
| 816 | 890 | await Bun.write(tmpFile, contentBuf); | |
| 817 | 891 | // --work-tree=/tmp is needed because bare repos have no work tree and | |
| 818 | 892 | // `update-index --remove` requires one (even though it only touches the index). | |
| @@ -821,7 +895,8 @@ export const git = { | |||
|---|---|---|---|
| 821 | 895 | await $`git -C ${p} hash-object -w ${tmpFile}`.text() | |
| 822 | 896 | ).trim(); | |
| 823 | 897 | 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}`; | |
| 825 | 900 | const tree = ( | |
| 826 | 901 | await $`git --work-tree=/tmp -C ${p} write-tree`.text() | |
| 827 | 902 | ).trim(); | |
| @@ -847,9 +922,7 @@ export const git = { | |||
|---|---|---|---|
| 847 | 922 | ).trim(); | |
| 848 | 923 | await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`; | |
| 849 | 924 | return commit; | |
| 850 | - | } finally { | |
| 851 | - | await $`rm -f ${tmpFile}`.quiet().nothrow(); | |
| 852 | - | } | |
| 925 | + | }); | |
| 853 | 926 | }); | |
| 854 | 927 | }, | |
| 855 | 928 | ||
| @@ -1049,7 +1122,8 @@ export const git = { | |||
|---|---|---|---|
| 1049 | 1122 | parents: (parts[7] ?? "").trim().split(/\s+/).filter(Boolean), | |
| 1050 | 1123 | sigStatus: parseSigStatus(parts[8] ?? ""), | |
| 1051 | 1124 | }; | |
| 1052 | - | } catch { | |
| 1125 | + | } catch (e) { | |
| 1126 | + | logGitError(`commitMeta(${sha})`, name, e); | |
| 1053 | 1127 | return null; | |
| 1054 | 1128 | } | |
| 1055 | 1129 | }, | |