invalidate branch & tag cache on pushes
Msrc/constants.ts
| @@ -40,6 +40,9 @@ export const MAX_MD_CACHE = 50; | |||
|---|---|---|---|
| 40 | 40 | export const MAX_FILE_CACHE = 500; | |
| 41 | 41 | export const MAX_DIFF_CACHE = 500; | |
| 42 | 42 | export const MAX_PATCH_CACHE = 100; | |
| 43 | + | export const MAX_BRANCH_CACHE = 200; | |
| 44 | + | export const MAX_TAG_CACHE = 200; | |
| 45 | + | export const REF_CACHE_TTL_MS = 30_000; | |
| 43 | 46 | ||
| 44 | 47 | // Paths — derived from config.DATA_DIR via getters so they reflect overrides. | |
| 45 | 48 | export const paths = { | |
Msrc/routes/git.ts
| @@ -4,6 +4,7 @@ import * as argon2 from "argon2"; | |||
|---|---|---|---|
| 4 | 4 | import { Elysia, t } from "elysia"; | |
| 5 | 5 | import { ADMIN_USERNAME, paths, VALID_REPO_NAME_RE } from "../constants.ts"; | |
| 6 | 6 | import { db } from "../db"; | |
| 7 | + | import { invalidateRefCache } from "../services/git.ts"; | |
| 7 | 8 | ||
| 8 | 9 | function pktLine(str: string): Buffer { | |
| 9 | 10 | const len = Buffer.byteLength(str, "utf-8") + 4; | |
| @@ -168,6 +169,7 @@ export const gitRoutes = new Elysia() | |||
|---|---|---|---|
| 168 | 169 | ["git", "receive-pack", "--stateless-rpc", repo.repoPath], | |
| 169 | 170 | body, | |
| 170 | 171 | ); | |
| 172 | + | invalidateRefCache(repo.name); | |
| 171 | 173 | return new Response(result, { | |
| 172 | 174 | headers: { | |
| 173 | 175 | "Content-Type": "application/x-git-receive-pack-result", | |
Msrc/routes/repos.tsx
| @@ -15,7 +15,7 @@ import { | |||
|---|---|---|---|
| 15 | 15 | import { db } from "../db/index.ts"; | |
| 16 | 16 | import { redirect } from "../lib/redirect.ts"; | |
| 17 | 17 | import { requireAdmin, resolveSession } from "../middleware/session.ts"; | |
| 18 | - | import { git, repoPath } from "../services/git.ts"; | |
| 18 | + | import { git, repoPath, type TreeEntry } from "../services/git.ts"; | |
| 19 | 19 | import { | |
| 20 | 20 | hasBinaryContent, | |
| 21 | 21 | prepareDiff, | |
| @@ -61,25 +61,22 @@ async function mimeForContent( | |||
|---|---|---|---|
| 61 | 61 | : "text/plain; charset=utf-8"; | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | + | const README_NAMES = ["README.md", "readme.md", "README", "readme"]; | |
| 65 | + | ||
| 64 | 66 | async function readReadme( | |
| 65 | 67 | repo: string, | |
| 66 | 68 | ref: string, | |
| 67 | 69 | dir = "", | |
| 70 | + | knownEntries: TreeEntry[], | |
| 68 | 71 | ): Promise<{ content: Buffer; filename: string } | null> { | |
| 69 | 72 | const prefix = dir ? `${dir}/` : ""; | |
| 70 | - | const names = ["README.md", "readme.md", "README", "readme"]; | |
| 71 | - | const results = await Promise.all( | |
| 72 | - | names.map((n) => git.show(repo, ref, `${prefix}${n}`)), | |
| 73 | - | ); | |
| 74 | - | for (let i = 0; i < results.length; i++) { | |
| 75 | - | if (results[i]) { | |
| 76 | - | return { | |
| 77 | - | content: results[i] as Buffer, | |
| 78 | - | filename: `${prefix}${names[i]}`, | |
| 79 | - | }; | |
| 80 | - | } | |
| 81 | - | } | |
| 82 | - | return null; | |
| 73 | + | // Fast path: we already have the tree listing — find the README name and | |
| 74 | + | // fetch only that one file, avoiding up to 3 wasted git-show calls. | |
| 75 | + | const entryNames = new Set(knownEntries.map((e) => e.name)); | |
| 76 | + | const name = README_NAMES.find((n) => entryNames.has(n)); | |
| 77 | + | if (!name) return null; | |
| 78 | + | const content = await git.show(repo, ref, `${prefix}${name}`); | |
| 79 | + | return content ? { content, filename: `${prefix}${name}` } : null; | |
| 83 | 80 | } | |
| 84 | 81 | ||
| 85 | 82 | export const repoRoutes = new Elysia() | |
| @@ -298,7 +295,12 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 298 | 295 | entries = lsResult; | |
| 299 | 296 | branches = branchResult; | |
| 300 | 297 | tags = tagResult; | |
| 301 | - | const readme = await readReadme(repo.name, repo.default_branch); | |
| 298 | + | const readme = await readReadme( | |
| 299 | + | repo.name, | |
| 300 | + | repo.default_branch, | |
| 301 | + | "", | |
| 302 | + | lsResult, | |
| 303 | + | ); | |
| 302 | 304 | if (readme) { | |
| 303 | 305 | const key = resolved | |
| 304 | 306 | ? `readme:${repo.name}:${resolved}:` | |
| @@ -391,7 +393,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 391 | 393 | git.branches(repo.name), | |
| 392 | 394 | git.tags(repo.name), | |
| 393 | 395 | ]); | |
| 394 | - | const readme = await readReadme(repo.name, params.ref); | |
| 396 | + | const readme = await readReadme(repo.name, params.ref, "", entries); | |
| 395 | 397 | const readmeHtml = readme | |
| 396 | 398 | ? renderMarkdown( | |
| 397 | 399 | readme.content.toString("utf-8"), | |
| @@ -437,7 +439,12 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 437 | 439 | }, | |
| 438 | 440 | }); | |
| 439 | 441 | } | |
| 440 | - | const readme = await readReadme(repo.name, params.ref, subpath); | |
| 442 | + | const readme = await readReadme( | |
| 443 | + | repo.name, | |
| 444 | + | params.ref, | |
| 445 | + | subpath, | |
| 446 | + | entries, | |
| 447 | + | ); | |
| 441 | 448 | const readmeHtml = readme | |
| 442 | 449 | ? renderMarkdown( | |
| 443 | 450 | readme.content.toString("utf-8"), | |
Msrc/services/git.ts
| @@ -1,7 +1,12 @@ | |||
|---|---|---|---|
| 1 | 1 | import path from "node:path"; | |
| 2 | 2 | import { $ as _$ } from "bun"; | |
| 3 | 3 | ||
| 4 | - | import { paths } from "../constants.ts"; | |
| 4 | + | import { | |
| 5 | + | MAX_BRANCH_CACHE, | |
| 6 | + | MAX_TAG_CACHE, | |
| 7 | + | paths, | |
| 8 | + | REF_CACHE_TTL_MS, | |
| 9 | + | } from "../constants.ts"; | |
| 5 | 10 | ||
| 6 | 11 | const gitEnv = { | |
| 7 | 12 | ...process.env, | |
| @@ -20,6 +25,15 @@ const $ = _$.env(gitEnv); | |||
|---|---|---|---|
| 20 | 25 | // (e.g. two patches being merged simultaneously, which would corrupt the index). | |
| 21 | 26 | const repoWriteLocks = new Map<string, Promise<void>>(); | |
| 22 | 27 | ||
| 28 | + | // Short-lived caches for ref lists — these change only on push/branch ops. | |
| 29 | + | const branchCache = new Map<string, { value: string[]; expiresAt: number }>(); | |
| 30 | + | const tagCache = new Map<string, { value: string[]; expiresAt: number }>(); | |
| 31 | + | ||
| 32 | + | export function invalidateRefCache(name: string): void { | |
| 33 | + | branchCache.delete(name); | |
| 34 | + | tagCache.delete(name); | |
| 35 | + | } | |
| 36 | + | ||
| 23 | 37 | async function withRepoLock<T>(name: string, fn: () => Promise<T>): Promise<T> { | |
| 24 | 38 | const prev = repoWriteLocks.get(name) ?? Promise.resolve(); | |
| 25 | 39 | let unlock!: () => void; | |
| @@ -365,30 +379,49 @@ export const git = { | |||
|---|---|---|---|
| 365 | 379 | }, | |
| 366 | 380 | ||
| 367 | 381 | async branches(name: string): Promise<string[]> { | |
| 382 | + | const now = Date.now(); | |
| 383 | + | const cached = branchCache.get(name); | |
| 384 | + | if (cached && cached.expiresAt > now) return cached.value; | |
| 368 | 385 | const p = repoPath(name); | |
| 369 | 386 | try { | |
| 370 | 387 | // %(refname:short) must be a variable — Bun Shell parses bare `()` as subshell syntax | |
| 371 | 388 | const fmt = "%(refname:short)"; | |
| 372 | 389 | const out = await $`git -C ${p} branch --format=${fmt}`.text(); | |
| 373 | - | return out.split("\n").filter(Boolean); | |
| 390 | + | const value = out.split("\n").filter(Boolean); | |
| 391 | + | branchCache.set(name, { value, expiresAt: now + REF_CACHE_TTL_MS }); | |
| 392 | + | if (branchCache.size > MAX_BRANCH_CACHE) { | |
| 393 | + | branchCache.delete(branchCache.keys().next().value!); | |
| 394 | + | } | |
| 395 | + | return value; | |
| 374 | 396 | } catch { | |
| 375 | 397 | return []; | |
| 376 | 398 | } | |
| 377 | 399 | }, | |
| 378 | 400 | ||
| 379 | 401 | async tags(name: string): Promise<string[]> { | |
| 402 | + | const now = Date.now(); | |
| 403 | + | const cached = tagCache.get(name); | |
| 404 | + | if (cached && cached.expiresAt > now) return cached.value; | |
| 380 | 405 | const p = repoPath(name); | |
| 381 | 406 | try { | |
| 382 | 407 | const fmt = "%(refname:short)"; | |
| 383 | 408 | const out = | |
| 384 | 409 | await $`git -C ${p} for-each-ref --format=${fmt} refs/tags/`.text(); | |
| 385 | - | return out.split("\n").filter(Boolean); | |
| 410 | + | const value = out.split("\n").filter(Boolean); | |
| 411 | + | tagCache.set(name, { value, expiresAt: now + REF_CACHE_TTL_MS }); | |
| 412 | + | if (tagCache.size > MAX_TAG_CACHE) { | |
| 413 | + | tagCache.delete(tagCache.keys().next().value!); | |
| 414 | + | } | |
| 415 | + | return value; | |
| 386 | 416 | } catch { | |
| 387 | 417 | return []; | |
| 388 | 418 | } | |
| 389 | 419 | }, | |
| 390 | 420 | ||
| 391 | - | async branchesWithInfo(name: string): Promise<BranchInfo[]> { | |
| 421 | + | async branchesWithInfo( | |
| 422 | + | name: string, | |
| 423 | + | maxCount = 1000, | |
| 424 | + | ): Promise<BranchInfo[]> { | |
| 392 | 425 | const p = repoPath(name); | |
| 393 | 426 | try { | |
| 394 | 427 | // Use actual unit separator byte (\x1f) — git for-each-ref does not | |
| @@ -396,7 +429,7 @@ export const git = { | |||
|---|---|---|---|
| 396 | 429 | const sep = "\x1f"; | |
| 397 | 430 | const fmt = `%(refname:short)${sep}%(objectname:short)${sep}%(contents:subject)${sep}%(authorname)${sep}%(authordate:iso8601)`; | |
| 398 | 431 | const out = | |
| 399 | - | await $`git -C ${p} for-each-ref --format=${fmt} refs/heads/`.text(); | |
| 432 | + | await $`git -C ${p} for-each-ref --sort=-creatordate --count=${maxCount} --format=${fmt} refs/heads/`.text(); | |
| 400 | 433 | return out | |
| 401 | 434 | .split("\n") | |
| 402 | 435 | .filter(Boolean) | |
| @@ -415,7 +448,7 @@ export const git = { | |||
|---|---|---|---|
| 415 | 448 | } | |
| 416 | 449 | }, | |
| 417 | 450 | ||
| 418 | - | async tagsWithInfo(name: string): Promise<TagInfo[]> { | |
| 451 | + | async tagsWithInfo(name: string, maxCount = 1000): Promise<TagInfo[]> { | |
| 419 | 452 | const p = repoPath(name); | |
| 420 | 453 | try { | |
| 421 | 454 | // Use actual unit separator byte (\x1f) — git for-each-ref does not | |
| @@ -424,7 +457,7 @@ export const git = { | |||
|---|---|---|---|
| 424 | 457 | const sep = "\x1f"; | |
| 425 | 458 | const fmt = `%(refname:short)${sep}%(*objectname:short)${sep}%(objectname:short)${sep}%(contents:subject)${sep}%(taggername)${sep}%(creatordate:iso8601)`; | |
| 426 | 459 | const out = | |
| 427 | - | await $`git -C ${p} for-each-ref --format=${fmt} refs/tags/`.text(); | |
| 460 | + | await $`git -C ${p} for-each-ref --sort=-creatordate --count=${maxCount} --format=${fmt} refs/tags/`.text(); | |
| 428 | 461 | return out | |
| 429 | 462 | .split("\n") | |
| 430 | 463 | .filter(Boolean) | |
| @@ -450,10 +483,7 @@ export const git = { | |||
|---|---|---|---|
| 450 | 483 | async defaultBranch(name: string): Promise<string> { | |
| 451 | 484 | const p = repoPath(name); | |
| 452 | 485 | try { | |
| 453 | - | const fmt = "%(refname:short)"; | |
| 454 | - | const branchesOut = | |
| 455 | - | await $`git -C ${p} branch --format=${fmt}`.text(); | |
| 456 | - | const branches = branchesOut.split("\n").filter(Boolean); | |
| 486 | + | const branches = await git.branches(name); | |
| 457 | 487 | ||
| 458 | 488 | // Read what HEAD points to (may be an unborn branch). | |
| 459 | 489 | let headBranch: string | null = null; | |
| @@ -816,7 +846,10 @@ export const git = { | |||
|---|---|---|---|
| 816 | 846 | }) | |
| 817 | 847 | .nothrow() | |
| 818 | 848 | : await $`git -C ${p} tag ${tagName} ${ref}`.nothrow(); | |
| 819 | - | if (result.exitCode === 0) return "ok"; | |
| 849 | + | if (result.exitCode === 0) { | |
| 850 | + | invalidateRefCache(repoName); | |
| 851 | + | return "ok"; | |
| 852 | + | } | |
| 820 | 853 | const stderr = result.stderr.toString(); | |
| 821 | 854 | if (stderr.includes("already exists")) return "already_exists"; | |
| 822 | 855 | if ( | |
| @@ -845,6 +878,7 @@ export const git = { | |||
|---|---|---|---|
| 845 | 878 | ); | |
| 846 | 879 | if (exists) return "already_exists"; | |
| 847 | 880 | await $`git -C ${p} update-ref refs/heads/${branchName} ${sha}`; | |
| 881 | + | invalidateRefCache(name); | |
| 848 | 882 | return "ok"; | |
| 849 | 883 | } catch { | |
| 850 | 884 | return "error"; | |
| @@ -865,6 +899,7 @@ export const git = { | |||
|---|---|---|---|
| 865 | 899 | ); | |
| 866 | 900 | if (!exists) return "not_found"; | |
| 867 | 901 | await $`git -C ${p} update-ref -d refs/heads/${branchName}`; | |
| 902 | + | invalidateRefCache(name); | |
| 868 | 903 | return "ok"; | |
| 869 | 904 | } catch { | |
| 870 | 905 | return "error"; | |
| @@ -889,6 +924,7 @@ export const git = { | |||
|---|---|---|---|
| 889 | 924 | if (exists) return "already_exists"; | |
| 890 | 925 | await $`git -C ${p} update-ref refs/heads/${newName} ${sha}`; | |
| 891 | 926 | await $`git -C ${p} update-ref -d refs/heads/${oldName}`; | |
| 927 | + | invalidateRefCache(name); | |
| 892 | 928 | return "ok"; | |
| 893 | 929 | } catch { | |
| 894 | 930 | return "error"; | |
| @@ -909,6 +945,7 @@ export const git = { | |||
|---|---|---|---|
| 909 | 945 | ); | |
| 910 | 946 | if (!exists) return "not_found"; | |
| 911 | 947 | await $`git -C ${p} tag -d ${tagName}`; | |
| 948 | + | invalidateRefCache(name); | |
| 912 | 949 | return "ok"; | |
| 913 | 950 | } catch { | |
| 914 | 951 | return "error"; | |
Msrc/services/sshServer.ts
| @@ -6,6 +6,7 @@ import { Server, utils } from "ssh2"; | |||
|---|---|---|---|
| 6 | 6 | import config from "../config.ts"; | |
| 7 | 7 | import { ADMIN_USERNAME, paths } from "../constants.ts"; | |
| 8 | 8 | import { db } from "../db/index.ts"; | |
| 9 | + | import { invalidateRefCache } from "./git.ts"; | |
| 9 | 10 | ||
| 10 | 11 | /** Compute SHA256 fingerprint from raw SSH public key bytes (the wire-format bytes). */ | |
| 11 | 12 | function fingerprintFromBytes(keyBytes: Buffer): string { | |
| @@ -129,6 +130,9 @@ export async function startSshServer() { | |||
|---|---|---|---|
| 129 | 130 | }); | |
| 130 | 131 | ||
| 131 | 132 | proc.on("close", (code: number | null) => { | |
| 133 | + | if (command === "git-receive-pack") { | |
| 134 | + | invalidateRefCache(repo.name); | |
| 135 | + | } | |
| 132 | 136 | stream.exit(code ?? 0); | |
| 133 | 137 | stream.end(); | |
| 134 | 138 | }); | |