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;
export const MAX_FILE_CACHE = 500;
export const MAX_DIFF_CACHE = 500;
export const MAX_PATCH_CACHE = 100;
export const MAX_BRANCH_CACHE = 200;
export const MAX_TAG_CACHE = 200;
export const REF_CACHE_TTL_MS = 30_000;
// Paths — derived from config.DATA_DIR via getters so they reflect overrides.
export const paths = {
▾Msrc/routes/git.ts
@@ -4,6 +4,7 @@ import * as argon2 from "argon2";
import { Elysia, t } from "elysia";
import { ADMIN_USERNAME, paths, VALID_REPO_NAME_RE } from "../constants.ts";
import { db } from "../db";
import { invalidateRefCache } from "../services/git.ts";
function pktLine(str: string): Buffer {
const len = Buffer.byteLength(str, "utf-8") + 4;
@@ -168,6 +169,7 @@ export const gitRoutes = new Elysia()
["git", "receive-pack", "--stateless-rpc", repo.repoPath],
body,
);
invalidateRefCache(repo.name);
return new Response(result, {
headers: {
"Content-Type": "application/x-git-receive-pack-result",
▾Msrc/routes/repos.tsx
@@ -15,7 +15,7 @@ import {
import { db } from "../db/index.ts";
import { redirect } from "../lib/redirect.ts";
import { requireAdmin, resolveSession } from "../middleware/session.ts";
import { git, repoPath } from "../services/git.ts";
import { git, repoPath, type TreeEntry } from "../services/git.ts";
import {
hasBinaryContent,
prepareDiff,
@@ -61,25 +61,22 @@ async function mimeForContent(
: "text/plain; charset=utf-8";
}
const README_NAMES = ["README.md", "readme.md", "README", "readme"];
async function readReadme(
repo: string,
ref: string,
dir = "",
knownEntries: TreeEntry[],
): Promise<{ content: Buffer; filename: string } | null> {
const prefix = dir ? `${dir}/` : "";
const names = ["README.md", "readme.md", "README", "readme"];
const results = await Promise.all(
names.map((n) => git.show(repo, ref, `${prefix}${n}`)),
);
for (let i = 0; i < results.length; i++) {
if (results[i]) {
return {
content: results[i] as Buffer,
filename: `${prefix}${names[i]}`,
};
}
}
return null;
// Fast path: we already have the tree listing — find the README name and
// fetch only that one file, avoiding up to 3 wasted git-show calls.
const entryNames = new Set(knownEntries.map((e) => e.name));
const name = README_NAMES.find((n) => entryNames.has(n));
if (!name) return null;
const content = await git.show(repo, ref, `${prefix}${name}`);
return content ? { content, filename: `${prefix}${name}` } : null;
}
export const repoRoutes = new Elysia()
@@ -298,7 +295,12 @@ export const repoRoutes = new Elysia()
entries = lsResult;
branches = branchResult;
tags = tagResult;
const readme = await readReadme(repo.name, repo.default_branch);
const readme = await readReadme(
repo.name,
repo.default_branch,
"",
lsResult,
);
if (readme) {
const key = resolved
? `readme:${repo.name}:${resolved}:`
@@ -391,7 +393,7 @@ export const repoRoutes = new Elysia()
git.branches(repo.name),
git.tags(repo.name),
]);
const readme = await readReadme(repo.name, params.ref);
const readme = await readReadme(repo.name, params.ref, "", entries);
const readmeHtml = readme
? renderMarkdown(
readme.content.toString("utf-8"),
@@ -437,7 +439,12 @@ export const repoRoutes = new Elysia()
},
});
}
const readme = await readReadme(repo.name, params.ref, subpath);
const readme = await readReadme(
repo.name,
params.ref,
subpath,
entries,
);
const readmeHtml = readme
? renderMarkdown(
readme.content.toString("utf-8"),
▾Msrc/services/git.ts
@@ -1,7 +1,12 @@
import path from "node:path";
import { $ as _$ } from "bun";
import { paths } from "../constants.ts";
import {
MAX_BRANCH_CACHE,
MAX_TAG_CACHE,
paths,
REF_CACHE_TTL_MS,
} from "../constants.ts";
const gitEnv = {
...process.env,
@@ -20,6 +25,15 @@ const $ = _$.env(gitEnv);
// (e.g. two patches being merged simultaneously, which would corrupt the index).
const repoWriteLocks = new Map<string, Promise<void>>();
// Short-lived caches for ref lists — these change only on push/branch ops.
const branchCache = new Map<string, { value: string[]; expiresAt: number }>();
const tagCache = new Map<string, { value: string[]; expiresAt: number }>();
export function invalidateRefCache(name: string): void {
branchCache.delete(name);
tagCache.delete(name);
}
async function withRepoLock<T>(name: string, fn: () => Promise<T>): Promise<T> {
const prev = repoWriteLocks.get(name) ?? Promise.resolve();
let unlock!: () => void;
@@ -365,30 +379,49 @@ export const git = {
},
async branches(name: string): Promise<string[]> {
const now = Date.now();
const cached = branchCache.get(name);
if (cached && cached.expiresAt > now) return cached.value;
const p = repoPath(name);
try {
// %(refname:short) must be a variable — Bun Shell parses bare `()` as subshell syntax
const fmt = "%(refname:short)";
const out = await $`git -C ${p} branch --format=${fmt}`.text();
return out.split("\n").filter(Boolean);
const value = out.split("\n").filter(Boolean);
branchCache.set(name, { value, expiresAt: now + REF_CACHE_TTL_MS });
if (branchCache.size > MAX_BRANCH_CACHE) {
branchCache.delete(branchCache.keys().next().value!);
}
return value;
} catch {
return [];
}
},
async tags(name: string): Promise<string[]> {
const now = Date.now();
const cached = tagCache.get(name);
if (cached && cached.expiresAt > now) return cached.value;
const p = repoPath(name);
try {
const fmt = "%(refname:short)";
const out =
await $`git -C ${p} for-each-ref --format=${fmt} refs/tags/`.text();
return out.split("\n").filter(Boolean);
const value = out.split("\n").filter(Boolean);
tagCache.set(name, { value, expiresAt: now + REF_CACHE_TTL_MS });
if (tagCache.size > MAX_TAG_CACHE) {
tagCache.delete(tagCache.keys().next().value!);
}
return value;
} catch {
return [];
}
},
async branchesWithInfo(name: string): Promise<BranchInfo[]> {
async branchesWithInfo(
name: string,
maxCount = 1000,
): Promise<BranchInfo[]> {
const p = repoPath(name);
try {
// Use actual unit separator byte (\x1f) — git for-each-ref does not
@@ -396,7 +429,7 @@ export const git = {
const sep = "\x1f";
const fmt = `%(refname:short)${sep}%(objectname:short)${sep}%(contents:subject)${sep}%(authorname)${sep}%(authordate:iso8601)`;
const out =
await $`git -C ${p} for-each-ref --format=${fmt} refs/heads/`.text();
await $`git -C ${p} for-each-ref --sort=-creatordate --count=${maxCount} --format=${fmt} refs/heads/`.text();
return out
.split("\n")
.filter(Boolean)
@@ -415,7 +448,7 @@ export const git = {
}
},
async tagsWithInfo(name: string): Promise<TagInfo[]> {
async tagsWithInfo(name: string, maxCount = 1000): Promise<TagInfo[]> {
const p = repoPath(name);
try {
// Use actual unit separator byte (\x1f) — git for-each-ref does not
@@ -424,7 +457,7 @@ export const git = {
const sep = "\x1f";
const fmt = `%(refname:short)${sep}%(*objectname:short)${sep}%(objectname:short)${sep}%(contents:subject)${sep}%(taggername)${sep}%(creatordate:iso8601)`;
const out =
await $`git -C ${p} for-each-ref --format=${fmt} refs/tags/`.text();
await $`git -C ${p} for-each-ref --sort=-creatordate --count=${maxCount} --format=${fmt} refs/tags/`.text();
return out
.split("\n")
.filter(Boolean)
@@ -450,10 +483,7 @@ export const git = {
async defaultBranch(name: string): Promise<string> {
const p = repoPath(name);
try {
const fmt = "%(refname:short)";
const branchesOut =
await $`git -C ${p} branch --format=${fmt}`.text();
const branches = branchesOut.split("\n").filter(Boolean);
const branches = await git.branches(name);
// Read what HEAD points to (may be an unborn branch).
let headBranch: string | null = null;
@@ -816,7 +846,10 @@ export const git = {
})
.nothrow()
: await $`git -C ${p} tag ${tagName} ${ref}`.nothrow();
if (result.exitCode === 0) return "ok";
if (result.exitCode === 0) {
invalidateRefCache(repoName);
return "ok";
}
const stderr = result.stderr.toString();
if (stderr.includes("already exists")) return "already_exists";
if (
@@ -845,6 +878,7 @@ export const git = {
);
if (exists) return "already_exists";
await $`git -C ${p} update-ref refs/heads/${branchName} ${sha}`;
invalidateRefCache(name);
return "ok";
} catch {
return "error";
@@ -865,6 +899,7 @@ export const git = {
);
if (!exists) return "not_found";
await $`git -C ${p} update-ref -d refs/heads/${branchName}`;
invalidateRefCache(name);
return "ok";
} catch {
return "error";
@@ -889,6 +924,7 @@ export const git = {
if (exists) return "already_exists";
await $`git -C ${p} update-ref refs/heads/${newName} ${sha}`;
await $`git -C ${p} update-ref -d refs/heads/${oldName}`;
invalidateRefCache(name);
return "ok";
} catch {
return "error";
@@ -909,6 +945,7 @@ export const git = {
);
if (!exists) return "not_found";
await $`git -C ${p} tag -d ${tagName}`;
invalidateRefCache(name);
return "ok";
} catch {
return "error";
▾Msrc/services/sshServer.ts
@@ -6,6 +6,7 @@ import { Server, utils } from "ssh2";
import config from "../config.ts";
import { ADMIN_USERNAME, paths } from "../constants.ts";
import { db } from "../db/index.ts";
import { invalidateRefCache } from "./git.ts";
/** Compute SHA256 fingerprint from raw SSH public key bytes (the wire-format bytes). */
function fingerprintFromBytes(keyBytes: Buffer): string {
@@ -129,6 +130,9 @@ export async function startSshServer() {
});
proc.on("close", (code: number | null) => {
if (command === "git-receive-pack") {
invalidateRefCache(repo.name);
}
stream.exit(code ?? 0);
stream.end();
});