invalidate branch & tag cache on pushes

AuthorKonata <konata@posteo.jp>
Date
Commit0f72f1e365c62b29977f89da899e59b0825b0a76
Parent067e2f8
5 files changed, 82 insertions(+), 29 deletions(-)
Msrc/constants.ts
@@ -40,6 +40,9 @@ export const MAX_MD_CACHE = 50;
4040 export const MAX_FILE_CACHE = 500;
4141 export const MAX_DIFF_CACHE = 500;
4242 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;
4346
4447 // Paths — derived from config.DATA_DIR via getters so they reflect overrides.
4548 export const paths = {
Msrc/routes/git.ts
@@ -4,6 +4,7 @@ import * as argon2 from "argon2";
44 import { Elysia, t } from "elysia";
55 import { ADMIN_USERNAME, paths, VALID_REPO_NAME_RE } from "../constants.ts";
66 import { db } from "../db";
7+import { invalidateRefCache } from "../services/git.ts";
78
89 function pktLine(str: string): Buffer {
910 const len = Buffer.byteLength(str, "utf-8") + 4;
@@ -168,6 +169,7 @@ export const gitRoutes = new Elysia()
168169 ["git", "receive-pack", "--stateless-rpc", repo.repoPath],
169170 body,
170171 );
172+ invalidateRefCache(repo.name);
171173 return new Response(result, {
172174 headers: {
173175 "Content-Type": "application/x-git-receive-pack-result",
Msrc/routes/repos.tsx
@@ -15,7 +15,7 @@ import {
1515 import { db } from "../db/index.ts";
1616 import { redirect } from "../lib/redirect.ts";
1717 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";
1919 import {
2020 hasBinaryContent,
2121 prepareDiff,
@@ -61,25 +61,22 @@ async function mimeForContent(
6161 : "text/plain; charset=utf-8";
6262 }
6363
64+const README_NAMES = ["README.md", "readme.md", "README", "readme"];
65+
6466 async function readReadme(
6567 repo: string,
6668 ref: string,
6769 dir = "",
70+ knownEntries: TreeEntry[],
6871 ): Promise<{ content: Buffer; filename: string } | null> {
6972 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;
8380 }
8481
8582 export const repoRoutes = new Elysia()
@@ -298,7 +295,12 @@ export const repoRoutes = new Elysia()
298295 entries = lsResult;
299296 branches = branchResult;
300297 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+ );
302304 if (readme) {
303305 const key = resolved
304306 ? `readme:${repo.name}:${resolved}:`
@@ -391,7 +393,7 @@ export const repoRoutes = new Elysia()
391393 git.branches(repo.name),
392394 git.tags(repo.name),
393395 ]);
394- const readme = await readReadme(repo.name, params.ref);
396+ const readme = await readReadme(repo.name, params.ref, "", entries);
395397 const readmeHtml = readme
396398 ? renderMarkdown(
397399 readme.content.toString("utf-8"),
@@ -437,7 +439,12 @@ export const repoRoutes = new Elysia()
437439 },
438440 });
439441 }
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+ );
441448 const readmeHtml = readme
442449 ? renderMarkdown(
443450 readme.content.toString("utf-8"),
Msrc/services/git.ts
@@ -1,7 +1,12 @@
11 import path from "node:path";
22 import { $ as _$ } from "bun";
33
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";
510
611 const gitEnv = {
712 ...process.env,
@@ -20,6 +25,15 @@ const $ = _$.env(gitEnv);
2025 // (e.g. two patches being merged simultaneously, which would corrupt the index).
2126 const repoWriteLocks = new Map<string, Promise<void>>();
2227
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+
2337 async function withRepoLock<T>(name: string, fn: () => Promise<T>): Promise<T> {
2438 const prev = repoWriteLocks.get(name) ?? Promise.resolve();
2539 let unlock!: () => void;
@@ -365,30 +379,49 @@ export const git = {
365379 },
366380
367381 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;
368385 const p = repoPath(name);
369386 try {
370387 // %(refname:short) must be a variable — Bun Shell parses bare `()` as subshell syntax
371388 const fmt = "%(refname:short)";
372389 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;
374396 } catch {
375397 return [];
376398 }
377399 },
378400
379401 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;
380405 const p = repoPath(name);
381406 try {
382407 const fmt = "%(refname:short)";
383408 const out =
384409 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;
386416 } catch {
387417 return [];
388418 }
389419 },
390420
391- async branchesWithInfo(name: string): Promise<BranchInfo[]> {
421+ async branchesWithInfo(
422+ name: string,
423+ maxCount = 1000,
424+ ): Promise<BranchInfo[]> {
392425 const p = repoPath(name);
393426 try {
394427 // Use actual unit separator byte (\x1f) — git for-each-ref does not
@@ -396,7 +429,7 @@ export const git = {
396429 const sep = "\x1f";
397430 const fmt = `%(refname:short)${sep}%(objectname:short)${sep}%(contents:subject)${sep}%(authorname)${sep}%(authordate:iso8601)`;
398431 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();
400433 return out
401434 .split("\n")
402435 .filter(Boolean)
@@ -415,7 +448,7 @@ export const git = {
415448 }
416449 },
417450
418- async tagsWithInfo(name: string): Promise<TagInfo[]> {
451+ async tagsWithInfo(name: string, maxCount = 1000): Promise<TagInfo[]> {
419452 const p = repoPath(name);
420453 try {
421454 // Use actual unit separator byte (\x1f) — git for-each-ref does not
@@ -424,7 +457,7 @@ export const git = {
424457 const sep = "\x1f";
425458 const fmt = `%(refname:short)${sep}%(*objectname:short)${sep}%(objectname:short)${sep}%(contents:subject)${sep}%(taggername)${sep}%(creatordate:iso8601)`;
426459 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();
428461 return out
429462 .split("\n")
430463 .filter(Boolean)
@@ -450,10 +483,7 @@ export const git = {
450483 async defaultBranch(name: string): Promise<string> {
451484 const p = repoPath(name);
452485 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);
457487
458488 // Read what HEAD points to (may be an unborn branch).
459489 let headBranch: string | null = null;
@@ -816,7 +846,10 @@ export const git = {
816846 })
817847 .nothrow()
818848 : 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+ }
820853 const stderr = result.stderr.toString();
821854 if (stderr.includes("already exists")) return "already_exists";
822855 if (
@@ -845,6 +878,7 @@ export const git = {
845878 );
846879 if (exists) return "already_exists";
847880 await $`git -C ${p} update-ref refs/heads/${branchName} ${sha}`;
881+ invalidateRefCache(name);
848882 return "ok";
849883 } catch {
850884 return "error";
@@ -865,6 +899,7 @@ export const git = {
865899 );
866900 if (!exists) return "not_found";
867901 await $`git -C ${p} update-ref -d refs/heads/${branchName}`;
902+ invalidateRefCache(name);
868903 return "ok";
869904 } catch {
870905 return "error";
@@ -889,6 +924,7 @@ export const git = {
889924 if (exists) return "already_exists";
890925 await $`git -C ${p} update-ref refs/heads/${newName} ${sha}`;
891926 await $`git -C ${p} update-ref -d refs/heads/${oldName}`;
927+ invalidateRefCache(name);
892928 return "ok";
893929 } catch {
894930 return "error";
@@ -909,6 +945,7 @@ export const git = {
909945 );
910946 if (!exists) return "not_found";
911947 await $`git -C ${p} tag -d ${tagName}`;
948+ invalidateRefCache(name);
912949 return "ok";
913950 } catch {
914951 return "error";
Msrc/services/sshServer.ts
@@ -6,6 +6,7 @@ import { Server, utils } from "ssh2";
66 import config from "../config.ts";
77 import { ADMIN_USERNAME, paths } from "../constants.ts";
88 import { db } from "../db/index.ts";
9+import { invalidateRefCache } from "./git.ts";
910
1011 /** Compute SHA256 fingerprint from raw SSH public key bytes (the wire-format bytes). */
1112 function fingerprintFromBytes(keyBytes: Buffer): string {
@@ -129,6 +130,9 @@ export async function startSshServer() {
129130 });
130131
131132 proc.on("close", (code: number | null) => {
133+ if (command === "git-receive-pack") {
134+ invalidateRefCache(repo.name);
135+ }
132136 stream.exit(code ?? 0);
133137 stream.end();
134138 });