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
Msrc/app.ts
| @@ -117,6 +117,7 @@ export async function createApp(port: number) { | |||
|---|---|---|---|
| 117 | 117 | if (response instanceof Response) { | |
| 118 | 118 | response.headers.set("Content-Security-Policy", CSP); | |
| 119 | 119 | response.headers.set("X-Frame-Options", "DENY"); | |
| 120 | + | response.headers.set("X-Content-Type-Options", "nosniff"); | |
| 120 | 121 | if (config.PUBLIC_HTTPS) { | |
| 121 | 122 | response.headers.set( | |
| 122 | 123 | "Strict-Transport-Security", | |
Msrc/constants.ts
| @@ -85,6 +85,9 @@ export const ISSUES_PER_PAGE = 20; | |||
|---|---|---|---|
| 85 | 85 | export const PATCHES_PER_PAGE = 20; | |
| 86 | 86 | export const RELEASES_PER_PAGE = 20; | |
| 87 | 87 | 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; | |
| 88 | 91 | export const BRANCHES_PER_PAGE = 30; | |
| 89 | 92 | export const TAGS_PER_PAGE = 30; | |
| 90 | 93 | ||
Msrc/db/index.ts
| @@ -284,7 +284,11 @@ function runMigrations(s: InstanceType<typeof BunDatabase>) { | |||
|---|---|---|---|
| 284 | 284 | } | |
| 285 | 285 | } | |
| 286 | 286 | ||
| 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. | |
| 288 | 292 | ||
| 289 | 293 | /** Close the current DB and reopen from disk (used by tests after data wipe). */ | |
| 290 | 294 | export function resetDb() { | |
| @@ -342,6 +346,10 @@ sqlite.run(`CREATE TABLE IF NOT EXISTS ci_secrets ( | |||
|---|---|---|---|
| 342 | 346 | UNIQUE(repo_id, name) | |
| 343 | 347 | )`); | |
| 344 | 348 | ||
| 349 | + | // Run column-level migrations now that the CI tables are guaranteed to exist | |
| 350 | + | // (see the note above runMigrations). | |
| 351 | + | runMigrations(sqlite); | |
| 352 | + | ||
| 345 | 353 | // Migration: add allow_user_labels column to repositories if missing | |
| 346 | 354 | const repoCols = sqlite | |
| 347 | 355 | .query<{ name: string }, []>("PRAGMA table_info(repositories)") | |
Msrc/routes/auth.tsx
| @@ -273,7 +273,9 @@ export const authRoutes = new Elysia() | |||
|---|---|---|---|
| 273 | 273 | password2: t.Optional( | |
| 274 | 274 | t.String({ maxLength: config.MAX_PASSWORD_BYTES }), | |
| 275 | 275 | ), | |
| 276 | - | application: t.Optional(t.String()), | |
| 276 | + | application: t.Optional( | |
| 277 | + | t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }), | |
| 278 | + | ), | |
| 277 | 279 | }), | |
| 278 | 280 | }, | |
| 279 | 281 | ) | |
| @@ -292,13 +294,35 @@ export const authRoutes = new Elysia() | |||
|---|---|---|---|
| 292 | 294 | // Create account (passkey-only path, called before passkey registration) | |
| 293 | 295 | .post( | |
| 294 | 296 | "/auth/passkey/create-user", | |
| 295 | - | async ({ body }) => { | |
| 297 | + | async ({ body, request, server }) => { | |
| 296 | 298 | if (config.REGISTRATION_TYPE === "disabled") { | |
| 297 | 299 | return new Response( | |
| 298 | 300 | JSON.stringify({ error: "Registration is disabled" }), | |
| 299 | 301 | { status: 400 }, | |
| 300 | 302 | ); | |
| 301 | 303 | } | |
| 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 | + | } | |
| 302 | 326 | const { username, application } = body; | |
| 303 | 327 | if (!username || !VALID_USERNAME_RE.test(username)) { | |
| 304 | 328 | return new Response( | |
| @@ -361,8 +385,10 @@ export const authRoutes = new Elysia() | |||
|---|---|---|---|
| 361 | 385 | }, | |
| 362 | 386 | { | |
| 363 | 387 | 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 | + | ), | |
| 366 | 392 | }), | |
| 367 | 393 | }, | |
| 368 | 394 | ) | |
Msrc/routes/git.ts
| @@ -184,17 +184,33 @@ async function getRepo( | |||
|---|---|---|---|
| 184 | 184 | return { name: repo.name, repoPath, isPrivate: repo.is_private === 1 }; | |
| 185 | 185 | } | |
| 186 | 186 | ||
| 187 | + | interface GitResult { | |
| 188 | + | ok: boolean; | |
| 189 | + | stdout: Uint8Array; | |
| 190 | + | stderr: string; | |
| 191 | + | } | |
| 192 | + | ||
| 187 | 193 | async function spawnGit( | |
| 188 | 194 | args: string[], | |
| 189 | 195 | stdinBytes?: Uint8Array, | |
| 190 | - | ): Promise<Uint8Array> { | |
| 196 | + | ): Promise<GitResult> { | |
| 191 | 197 | const proc = Bun.spawn(args, { | |
| 192 | 198 | stdin: stdinBytes ?? "ignore", | |
| 193 | 199 | stdout: "pipe", | |
| 194 | 200 | stderr: "pipe", | |
| 195 | 201 | }); | |
| 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 | + | }; | |
| 198 | 214 | } | |
| 199 | 215 | ||
| 200 | 216 | export const gitRoutes = new Elysia() | |
| @@ -237,10 +253,16 @@ export const gitRoutes = new Elysia() | |||
|---|---|---|---|
| 237 | 253 | "--advertise-refs", | |
| 238 | 254 | repo.repoPath, | |
| 239 | 255 | ]); | |
| 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 | + | } | |
| 240 | 262 | const body = Buffer.concat([ | |
| 241 | 263 | pktLine(`# service=git-${gitCmd}\n`), | |
| 242 | 264 | PKT_FLUSH, | |
| 243 | - | refs, | |
| 265 | + | refs.stdout, | |
| 244 | 266 | ]); | |
| 245 | 267 | ||
| 246 | 268 | return new Response(body, { | |
| @@ -270,12 +292,21 @@ export const gitRoutes = new Elysia() | |||
|---|---|---|---|
| 270 | 292 | ) | |
| 271 | 293 | return unauthorized(); | |
| 272 | 294 | } | |
| 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. | |
| 273 | 298 | const body = new Uint8Array(await request.arrayBuffer()); | |
| 274 | 299 | const result = await spawnGit( | |
| 275 | 300 | ["git", "upload-pack", "--stateless-rpc", repo.repoPath], | |
| 276 | 301 | body, | |
| 277 | 302 | ); | |
| 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, { | |
| 279 | 310 | headers: { | |
| 280 | 311 | "Content-Type": "application/x-git-upload-pack-result", | |
| 281 | 312 | "Cache-Control": "no-cache", | |
| @@ -292,16 +323,25 @@ export const gitRoutes = new Elysia() | |||
|---|---|---|---|
| 292 | 323 | return unauthorized(); | |
| 293 | 324 | const repo = await getRepo(params.repo); | |
| 294 | 325 | 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. | |
| 295 | 329 | const body = new Uint8Array(await request.arrayBuffer()); | |
| 296 | 330 | const refUpdates = parseRefUpdates(body); | |
| 297 | 331 | const result = await spawnGit( | |
| 298 | 332 | ["git", "receive-pack", "--stateless-rpc", repo.repoPath], | |
| 299 | 333 | body, | |
| 300 | 334 | ); | |
| 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 | + | } | |
| 301 | 341 | invalidateRefCache(repo.name); | |
| 302 | 342 | // Trigger CI in background — don't block the git push response | |
| 303 | 343 | triggerCiForPush(repo.name, refUpdates).catch(() => {}); | |
| 304 | - | return new Response(result, { | |
| 344 | + | return new Response(result.stdout, { | |
| 305 | 345 | headers: { | |
| 306 | 346 | "Content-Type": "application/x-git-receive-pack-result", | |
| 307 | 347 | "Cache-Control": "no-cache", | |
Msrc/routes/repos.tsx
| @@ -164,6 +164,21 @@ async function mimeForContent( | |||
|---|---|---|---|
| 164 | 164 | : "text/plain; charset=utf-8"; | |
| 165 | 165 | } | |
| 166 | 166 | ||
| 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 | + | ||
| 167 | 182 | const README_NAMES = ["README.md", "readme.md", "README", "readme"]; | |
| 168 | 183 | ||
| 169 | 184 | async function readReadme( | |
| @@ -696,7 +711,9 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 696 | 711 | const head = firstChunk | |
| 697 | 712 | ? Buffer.from(firstChunk.subarray(0, BINARY_DETECT_BYTES)) | |
| 698 | 713 | : Buffer.alloc(0); | |
| 699 | - | const contentType = await mimeForContent(filename, head); | |
| 714 | + | const contentType = rawServeContentType( | |
| 715 | + | await mimeForContent(filename, head), | |
| 716 | + | ); | |
| 700 | 717 | ||
| 701 | 718 | const rangeHeader = request.headers.get("Range"); | |
| 702 | 719 | const fullProc = Bun.spawn(blobArgs, { | |
| @@ -1106,10 +1123,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 1106 | 1123 | try { | |
| 1107 | 1124 | renameSync(fromPath, toPath); | |
| 1108 | 1125 | } catch (err) { | |
| 1109 | - | console.error( | |
| 1110 | - | `rename ${fromPath} -> ${toPath} failed`, | |
| 1111 | - | err, | |
| 1112 | - | ); | |
| 1126 | + | console.error(`rename ${fromPath} -> ${toPath} failed`, err); | |
| 1113 | 1127 | return back("Failed to rename repository on disk."); | |
| 1114 | 1128 | } | |
| 1115 | 1129 | ||
Msrc/services/ci.ts
| @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; | |||
|---|---|---|---|
| 2 | 2 | import path from "node:path"; | |
| 3 | 3 | import { parse as parseToml } from "smol-toml"; | |
| 4 | 4 | import config from "../config.ts"; | |
| 5 | - | import { paths } from "../constants.ts"; | |
| 5 | + | import { CI_MAX_LOG_BYTES, paths } from "../constants.ts"; | |
| 6 | 6 | import { db } from "../db/index.ts"; | |
| 7 | 7 | import { repoPath } from "./git.ts"; | |
| 8 | 8 | ||
| @@ -264,8 +264,31 @@ async function pullImage(image: string): Promise<void> { | |||
|---|---|---|---|
| 264 | 264 | `/images/create?fromImage=${encodeURIComponent(name)}&tag=${encodeURIComponent(tag)}`, | |
| 265 | 265 | { method: "POST" }, | |
| 266 | 266 | ); | |
| 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 | + | } | |
| 269 | 292 | } | |
| 270 | 293 | ||
| 271 | 294 | function parseMemoryBytes(s: string): number { | |
| @@ -417,6 +440,22 @@ async function execInContainer( | |||
|---|---|---|---|
| 417 | 440 | }); | |
| 418 | 441 | ||
| 419 | 442 | 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 | + | ||
| 420 | 459 | if (onPartialLog && startResp.body) { | |
| 421 | 460 | const reader = startResp.body.getReader(); | |
| 422 | 461 | let buf = new Uint8Array(0); | |
| @@ -430,18 +469,19 @@ async function execInContainer( | |||
|---|---|---|---|
| 430 | 469 | buf = merged; | |
| 431 | 470 | const { text, remaining } = parseMuxFrames(buf); | |
| 432 | 471 | 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) { | |
| 435 | 475 | await onPartialLog(log); | |
| 436 | 476 | lastSave = Date.now(); | |
| 437 | 477 | } | |
| 438 | 478 | } | |
| 439 | 479 | const { text } = parseMuxFrames(buf); | |
| 440 | - | log += text; | |
| 480 | + | appendLog(text); | |
| 441 | 481 | } else { | |
| 442 | 482 | const bodyBytes = new Uint8Array(await startResp.arrayBuffer()); | |
| 443 | 483 | const { text } = parseMuxFrames(bodyBytes); | |
| 444 | - | log = text; | |
| 484 | + | appendLog(text); | |
| 445 | 485 | } | |
| 446 | 486 | ||
| 447 | 487 | // Get exit code | |
| @@ -844,6 +884,11 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> { | |||
|---|---|---|---|
| 844 | 884 | const stepTimeout = | |
| 845 | 885 | step.timeout ?? cfg.timeout ?? config.CI_DEFAULT_TIMEOUT; | |
| 846 | 886 | 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]); | |
| 847 | 892 | ||
| 848 | 893 | try { | |
| 849 | 894 | const { log, exitCode } = await execInContainer( | |
| @@ -851,7 +896,7 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> { | |||
|---|---|---|---|
| 851 | 896 | [...shell, command], | |
| 852 | 897 | cfg.work_dir, | |
| 853 | 898 | envArray, | |
| 854 | - | timeoutSignal, | |
| 899 | + | stepSignal, | |
| 855 | 900 | async (partial) => { | |
| 856 | 901 | await db | |
| 857 | 902 | .updateTable("ci_steps") | |
| @@ -868,9 +913,20 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> { | |||
|---|---|---|---|
| 868 | 913 | runFailed = true; | |
| 869 | 914 | } | |
| 870 | 915 | } 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; | |
| 872 | 919 | stepStatus = "failure"; | |
| 873 | 920 | 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 | + | } | |
| 874 | 930 | } | |
| 875 | 931 | } | |
| 876 | 932 | ||
Msrc/services/git.ts
| @@ -65,7 +65,8 @@ export async function validateCommit( | |||
|---|---|---|---|
| 65 | 65 | ): Promise<boolean> { | |
| 66 | 66 | const p = repoPath(repoName); | |
| 67 | 67 | 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(); | |
| 69 | 70 | return out.trim() === "commit"; | |
| 70 | 71 | } catch { | |
| 71 | 72 | return false; | |
| @@ -90,6 +91,7 @@ export async function archiveRepo( | |||
|---|---|---|---|
| 90 | 91 | "archive", | |
| 91 | 92 | "--format=zip", | |
| 92 | 93 | `--output=${path.join(outDir, `${base}.zip`)}`, | |
| 94 | + | "--end-of-options", | |
| 93 | 95 | ref, | |
| 94 | 96 | ], | |
| 95 | 97 | { signal, env: gitEnv }, | |
| @@ -104,6 +106,7 @@ export async function archiveRepo( | |||
|---|---|---|---|
| 104 | 106 | "archive", | |
| 105 | 107 | "--format=tar.gz", | |
| 106 | 108 | `--output=${path.join(outDir, `${base}.tar.gz`)}`, | |
| 109 | + | "--end-of-options", | |
| 107 | 110 | ref, | |
| 108 | 111 | ], | |
| 109 | 112 | { signal, env: gitEnv }, | |
| @@ -113,7 +116,15 @@ export async function archiveRepo( | |||
|---|---|---|---|
| 113 | 116 | ||
| 114 | 117 | try { | |
| 115 | 118 | 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 | + | ], | |
| 117 | 128 | { signal, env: gitEnv, stdout: "pipe" }, | |
| 118 | 129 | ); | |
| 119 | 130 | const zst = Bun.spawn( | |
| @@ -299,8 +310,12 @@ export const git = { | |||
|---|---|---|---|
| 299 | 310 | `gpg.ssh.allowedSignersFile=${paths.ALLOWED_SIGNERS_PATH}`, | |
| 300 | 311 | ]; | |
| 301 | 312 | 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. | |
| 302 | 317 | 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(); | |
| 304 | 319 | return parseLog(out); | |
| 305 | 320 | } catch { | |
| 306 | 321 | return []; | |
| @@ -321,11 +336,20 @@ export const git = { | |||
|---|---|---|---|
| 321 | 336 | p, | |
| 322 | 337 | "ls-tree", | |
| 323 | 338 | "--long", | |
| 339 | + | "--end-of-options", | |
| 324 | 340 | ref, | |
| 325 | 341 | "--", | |
| 326 | 342 | `${subpath}/`, | |
| 327 | 343 | ] | |
| 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 | + | ]; | |
| 329 | 353 | const out = await $`${args}`.text(); | |
| 330 | 354 | const entries = parseLsTree(out); | |
| 331 | 355 | if (subpath) { | |
| @@ -352,7 +376,7 @@ export const git = { | |||
|---|---|---|---|
| 352 | 376 | const p = repoPath(name); | |
| 353 | 377 | try { | |
| 354 | 378 | const buf = | |
| 355 | - | await $`git -C ${p} show ${`${ref}:${filePath}`}`.arrayBuffer(); | |
| 379 | + | await $`git -C ${p} show --end-of-options ${`${ref}:${filePath}`}`.arrayBuffer(); | |
| 356 | 380 | return Buffer.from(buf); | |
| 357 | 381 | } catch { | |
| 358 | 382 | return null; | |
| @@ -362,7 +386,7 @@ export const git = { | |||
|---|---|---|---|
| 362 | 386 | async diff(name: string, sha: string): Promise<string> { | |
| 363 | 387 | const p = repoPath(name); | |
| 364 | 388 | 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(); | |
| 366 | 390 | } catch { | |
| 367 | 391 | return ""; | |
| 368 | 392 | } | |
| @@ -372,7 +396,8 @@ export const git = { | |||
|---|---|---|---|
| 372 | 396 | if (/^0+$/.test(hash)) return 0; | |
| 373 | 397 | const p = repoPath(name); | |
| 374 | 398 | 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(); | |
| 376 | 401 | return parseInt(out.trim(), 10) || 0; | |
| 377 | 402 | } catch { | |
| 378 | 403 | return 0; | |
| @@ -522,7 +547,7 @@ export const git = { | |||
|---|---|---|---|
| 522 | 547 | const p = repoPath(name); | |
| 523 | 548 | try { | |
| 524 | 549 | 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(); | |
| 526 | 551 | return parseInt(out.trim(), 10); | |
| 527 | 552 | } catch { | |
| 528 | 553 | return null; | |
| @@ -535,13 +560,20 @@ export const git = { | |||
|---|---|---|---|
| 535 | 560 | ): Promise<{ clean: boolean; output: string }> { | |
| 536 | 561 | const p = repoPath(name); | |
| 537 | 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 }; | |
| 538 | 569 | try { | |
| 539 | 570 | await Bun.write(tmpFile, patchContent); | |
| 540 | 571 | // Bare repos have no working tree; populate the index from HEAD so we can | |
| 541 | 572 | // 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(); | |
| 543 | 574 | const result = | |
| 544 | 575 | await $`git -C ${p} apply --check --cached ${tmpFile}` | |
| 576 | + | .env(idxEnv) | |
| 545 | 577 | .quiet() | |
| 546 | 578 | .nothrow(); | |
| 547 | 579 | return { | |
| @@ -551,7 +583,7 @@ export const git = { | |||
|---|---|---|---|
| 551 | 583 | } catch (e) { | |
| 552 | 584 | return { clean: false, output: String(e) }; | |
| 553 | 585 | } finally { | |
| 554 | - | await $`rm -f ${tmpFile}`.quiet().nothrow(); | |
| 586 | + | await $`rm -f ${tmpFile} ${tmpIndex}`.quiet().nothrow(); | |
| 555 | 587 | } | |
| 556 | 588 | }, | |
| 557 | 589 | ||
| @@ -839,14 +871,14 @@ export const git = { | |||
|---|---|---|---|
| 839 | 871 | ]; | |
| 840 | 872 | const result = | |
| 841 | 873 | 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}` | |
| 843 | 875 | .env({ | |
| 844 | 876 | ...gitEnv, | |
| 845 | 877 | GIT_COMMITTER_NAME: taggerName!, | |
| 846 | 878 | GIT_COMMITTER_EMAIL: taggerEmail!, | |
| 847 | 879 | }) | |
| 848 | 880 | .nothrow() | |
| 849 | - | : await $`git -C ${p} tag ${tagName} ${ref}`.nothrow(); | |
| 881 | + | : await $`git -C ${p} tag --end-of-options ${tagName} ${ref}`.nothrow(); | |
| 850 | 882 | if (result.exitCode === 0) { | |
| 851 | 883 | invalidateRefCache(repoName); | |
| 852 | 884 | return "ok"; | |
| @@ -962,7 +994,8 @@ export const git = { | |||
|---|---|---|---|
| 962 | 994 | async resolveRef(name: string, ref: string): Promise<string | null> { | |
| 963 | 995 | const p = repoPath(name); | |
| 964 | 996 | 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(); | |
| 966 | 999 | return out.trim() || null; | |
| 967 | 1000 | } catch { | |
| 968 | 1001 | return null; | |
| @@ -989,8 +1022,8 @@ export const git = { | |||
|---|---|---|---|
| 989 | 1022 | ]; | |
| 990 | 1023 | try { | |
| 991 | 1024 | 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(), | |
| 994 | 1027 | ]); | |
| 995 | 1028 | const parts = metaOut.trim().split("\x1f"); | |
| 996 | 1029 | const fullMsg = msgOut.trimEnd(); | |
Msrc/services/repoSync.ts
| @@ -93,9 +93,13 @@ export async function ensureRepoRecord(name: string): Promise<RepositoryRow> { | |||
|---|---|---|---|
| 93 | 93 | .selectAll() | |
| 94 | 94 | .where("name", "=", name) | |
| 95 | 95 | .executeTakeFirst(); | |
| 96 | - | await $`git config --file ${path.join(repoPath(name), "config")} core.bare true`; | |
| 97 | 96 | if (existing) return existing; | |
| 98 | 97 | ||
| 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`; | |
| 99 | 103 | const branch = await git.defaultBranch(name); | |
| 100 | 104 | const now = new Date().toISOString(); | |
| 101 | 105 | return await db | |
Msrc/views/DiffView.tsx
| @@ -151,7 +151,9 @@ export function DiffView({ files, repo, sha }: DiffViewProps) { | |||
|---|---|---|---|
| 151 | 151 | <> | |
| 152 | 152 | {files.length > 0 && ( | |
| 153 | 153 | <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> | |
| 155 | 157 | </div> | |
| 156 | 158 | )} | |
| 157 | 159 | ||
| @@ -192,12 +194,18 @@ export function DiffView({ files, repo, sha }: DiffViewProps) { | |||
|---|---|---|---|
| 192 | 194 | > | |
| 193 | 195 | {sl} | |
| 194 | 196 | </span> | |
| 195 | - | <span class="diff-file-path mono" safe> | |
| 197 | + | <span | |
| 198 | + | class="diff-file-path mono" | |
| 199 | + | safe | |
| 200 | + | > | |
| 196 | 201 | {displayPath} | |
| 197 | 202 | </span> | |
| 198 | 203 | {f.status === "renamed" && | |
| 199 | 204 | f.oldPath !== f.newPath && ( | |
| 200 | - | <span class="diff-rename-arrow" safe> | |
| 205 | + | <span | |
| 206 | + | class="diff-rename-arrow" | |
| 207 | + | safe | |
| 208 | + | > | |
| 201 | 209 | ← {f.oldPath} | |
| 202 | 210 | </span> | |
| 203 | 211 | )} | |
Msrc/views/ReactionBar.tsx
| @@ -43,7 +43,7 @@ export function ReactionBar({ | |||
|---|---|---|---|
| 43 | 43 | disabled={!user} | |
| 44 | 44 | safe | |
| 45 | 45 | > | |
| 46 | - | {unsafeEmoji + " " + r.count} | |
| 46 | + | {`${unsafeEmoji} ${r.count}`} | |
| 47 | 47 | </button> | |
| 48 | 48 | </form> | |
| 49 | 49 | ); | |
Msrc/views/Settings.tsx
| @@ -311,7 +311,10 @@ export function Settings({ | |||
|---|---|---|---|
| 311 | 311 | <span class="ssh-key-name" safe> | |
| 312 | 312 | {key.name} | |
| 313 | 313 | </span> | |
| 314 | - | <span class="passkey-date ssh-key-fingerprint" safe> | |
| 314 | + | <span | |
| 315 | + | class="passkey-date ssh-key-fingerprint" | |
| 316 | + | safe | |
| 317 | + | > | |
| 315 | 318 | {key.fingerprint} | |
| 316 | 319 | </span> | |
| 317 | 320 | <span class="passkey-date" safe> | |
| @@ -420,14 +423,20 @@ export function Settings({ | |||
|---|---|---|---|
| 420 | 423 | <strong safe> | |
| 421 | 424 | {u.username} | |
| 422 | 425 | </strong> | |
| 423 | - | <span class="queue-item-date" safe> | |
| 426 | + | <span | |
| 427 | + | class="queue-item-date" | |
| 428 | + | safe | |
| 429 | + | > | |
| 424 | 430 | {formatDateTime( | |
| 425 | 431 | u.created_at, | |
| 426 | 432 | )} | |
| 427 | 433 | </span> | |
| 428 | 434 | </div> | |
| 429 | 435 | {!!u.register_application && ( | |
| 430 | - | <p class="queue-item-answer" safe> | |
| 436 | + | <p | |
| 437 | + | class="queue-item-answer" | |
| 438 | + | safe | |
| 439 | + | > | |
| 431 | 440 | {u.register_application} | |
| 432 | 441 | </p> | |
| 433 | 442 | )} | |
Msrc/views/ci/CiHistory.tsx
| @@ -100,7 +100,10 @@ function CiHelp({ repo }: { repo: RepositoryRow }) { | |||
|---|---|---|---|
| 100 | 100 | <div class="ci-help-section"> | |
| 101 | 101 | <h4 class="ci-help-section-title">Status badge</h4> | |
| 102 | 102 | <p class="ci-help-badge-desc">Embed in your README:</p> | |
| 103 | - | <code class="ci-help-badge-code" safe>{``}</code> | |
| 103 | + | <code | |
| 104 | + | class="ci-help-badge-code" | |
| 105 | + | safe | |
| 106 | + | >{``}</code> | |
| 104 | 107 | <h4 | |
| 105 | 108 | class="ci-help-section-title" | |
| 106 | 109 | style="margin-top: var(--space-4)" | |
| @@ -145,9 +148,7 @@ export function CiHistory({ | |||
|---|---|---|---|
| 145 | 148 | ); | |
| 146 | 149 | return ( | |
| 147 | 150 | <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} | |
| 151 | 152 | <div class="container"> | |
| 152 | 153 | <RepoHeader repo={repo} /> | |
| 153 | 154 | <RepoNav repo={repo} active="ci" user={user} /> | |
| @@ -244,7 +245,8 @@ export function CiHistory({ | |||
|---|---|---|---|
| 244 | 245 | </span> | |
| 245 | 246 | {!!run.triggered_by_username && ( | |
| 246 | 247 | <span class="text-muted" safe> | |
| 247 | - | by {run.triggered_by_username} | |
| 248 | + | by{" "} | |
| 249 | + | {run.triggered_by_username} | |
| 248 | 250 | </span> | |
| 249 | 251 | )} | |
| 250 | 252 | {run.artifact_count > 0 && ( | |
| @@ -258,7 +260,10 @@ export function CiHistory({ | |||
|---|---|---|---|
| 258 | 260 | )} | |
| 259 | 261 | {!!run.started_at && | |
| 260 | 262 | !!run.finished_at && ( | |
| 261 | - | <span class="text-muted" safe> | |
| 263 | + | <span | |
| 264 | + | class="text-muted" | |
| 265 | + | safe | |
| 266 | + | > | |
| 262 | 267 | {duration( | |
| 263 | 268 | run.started_at, | |
| 264 | 269 | run.finished_at, | |
Msrc/views/ci/CiRunDetail.tsx
| @@ -137,7 +137,11 @@ export function CiRunDetail({ | |||
|---|---|---|---|
| 137 | 137 | {duration(run.started_at, run.finished_at)} | |
| 138 | 138 | </span> | |
| 139 | 139 | )} | |
| 140 | - | <time datetime={run.created_at} class="text-muted" safe> | |
| 140 | + | <time | |
| 141 | + | datetime={run.created_at} | |
| 142 | + | class="text-muted" | |
| 143 | + | safe | |
| 144 | + | > | |
| 141 | 145 | {formatDateTime(run.created_at)} | |
| 142 | 146 | </time> | |
| 143 | 147 | </div> | |
| @@ -222,14 +226,18 @@ export function CiRunDetail({ | |||
|---|---|---|---|
| 222 | 226 | <span class="ci-step-name" safe> | |
| 223 | 227 | {step.name} | |
| 224 | 228 | </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 | + | )} | |
| 233 | 241 | </summary> | |
| 234 | 242 | {step.log ? ( | |
| 235 | 243 | <pre class="ci-step-log" safe> | |
| @@ -258,7 +266,10 @@ export function CiRunDetail({ | |||
|---|---|---|---|
| 258 | 266 | > | |
| 259 | 267 | {artifact.filename} | |
| 260 | 268 | </a> | |
| 261 | - | <span class="ci-artifact-size text-muted" safe> | |
| 269 | + | <span | |
| 270 | + | class="ci-artifact-size text-muted" | |
| 271 | + | safe | |
| 272 | + | > | |
| 262 | 273 | {formatBytes(artifact.size)} | |
| 263 | 274 | </span> | |
| 264 | 275 | </li> | |
Msrc/views/releases/ReleaseList.tsx
| @@ -27,7 +27,9 @@ function NotesPreview({ notes }: { notes: string }) { | |||
|---|---|---|---|
| 27 | 27 | </div> | |
| 28 | 28 | <span class="notes-toggle-label" /> | |
| 29 | 29 | </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> | |
| 31 | 33 | </details> | |
| 32 | 34 | ); | |
| 33 | 35 | } | |
| @@ -97,7 +99,10 @@ export function ReleaseList({ | |||
|---|---|---|---|
| 97 | 99 | {release.tag_name} | |
| 98 | 100 | </a> | |
| 99 | 101 | )} | |
| 100 | - | <time datetime={release.created_at} safe> | |
| 102 | + | <time | |
| 103 | + | datetime={release.created_at} | |
| 104 | + | safe | |
| 105 | + | > | |
| 101 | 106 | {formatDate(release.created_at)} | |
| 102 | 107 | </time> | |
| 103 | 108 | </div> | |
Msrc/views/repos/CommitDetail.tsx
| @@ -109,7 +109,10 @@ export function CommitDetail({ | |||
|---|---|---|---|
| 109 | 109 | )} | |
| 110 | 110 | <div class="commit-card-meta-row"> | |
| 111 | 111 | <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 | + | > | |
| 113 | 116 | {meta.hash} | |
| 114 | 117 | </code> | |
| 115 | 118 | </div> | |
Msrc/views/repos/FileBlob.tsx
| @@ -124,7 +124,9 @@ export function FileBlob({ | |||
|---|---|---|---|
| 124 | 124 | </div> | |
| 125 | 125 | <div class="file-blob-body"> | |
| 126 | 126 | {markdownHtml ? ( | |
| 127 | - | <div class="markdown-body">{markdownHtml as "safe"}</div> | |
| 127 | + | <div class="markdown-body"> | |
| 128 | + | {markdownHtml as "safe"} | |
| 129 | + | </div> | |
| 128 | 130 | ) : view.type === "inline" ? ( | |
| 129 | 131 | <div class="shiki-wrapper">{view.html as "safe"}</div> | |
| 130 | 132 | ) : view.type === "media" ? ( | |
Msrc/views/repos/RepoHome.tsx
| @@ -124,7 +124,9 @@ git push origin main`}</code> | |||
|---|---|---|---|
| 124 | 124 | </a> | |
| 125 | 125 | )} | |
| 126 | 126 | </div> | |
| 127 | - | <div class="markdown-body">{readmeHtml as "safe"}</div> | |
| 127 | + | <div class="markdown-body"> | |
| 128 | + | {readmeHtml as "safe"} | |
| 129 | + | </div> | |
| 128 | 130 | </div> | |
| 129 | 131 | )} | |
| 130 | 132 | </> | |
Msrc/views/repos/RepoSettings.tsx
| @@ -308,9 +308,9 @@ export function RepoSettings({ | |||
|---|---|---|---|
| 308 | 308 | <div class="danger-item-info"> | |
| 309 | 309 | <strong>Rename this repository</strong> | |
| 310 | 310 | <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. | |
| 314 | 314 | </p> | |
| 315 | 315 | </div> | |
| 316 | 316 | <form | |