perf(homecoming): contributors + coupling cached by branch tip — 1.7s to instant #5530
2 changed files+152−114
Modifiedsrc/routes/contributors.tsx+134−112View fileUnifiedSplit
@@ -11,8 +11,9 @@ import { Hono } from "hono";
1111import { Layout } from "../views/layout";
1212import { RepoHeader, RepoNav, PageHeader, Badge, sharedComponentStyles } from "../views/components";
1313import { getRepoPath, repoExists, getDefaultBranch,
14 gitExecTimeoutMs,
14 gitExecTimeoutMs, resolveRef,
1515} from "../git/repository";
16import { cached, gitCache } from "../lib/cache";
1617import { softAuth } from "../middleware/auth";
1718import type { AuthEnv } from "../middleware/auth";
1819import { db } from "../db";
@@ -41,117 +42,22 @@ contributors.get("/:owner/:repo/contributors", async (c) => {
4142 const ref = (await getDefaultBranch(owner, repo)) || "main";
4243 const repoDir = getRepoPath(owner, repo);
4344
44 // Get shortlog for commit counts
45 const shortlogProc = Bun.spawn(
46 ["git", "shortlog", "-sne", ref],
47 { timeout: gitExecTimeoutMs(), killSignal: "SIGKILL", cwd: repoDir, stdout: "pipe", stderr: "pipe" }
48 );
49 const shortlogOut = await new Response(shortlogProc.stdout).text();
50 await shortlogProc.exited;
51
52 const contribs: Contributor[] = shortlogOut
53 .trim()
54 .split("\n")
55 .filter(Boolean)
56 .map((line) => {
57 const match = line.trim().match(/^(\d+)\t(.+?)\s+<(.+?)>$/);
58 if (!match) return null;
59 return {
60 name: match[2],
61 email: match[3],
62 commits: parseInt(match[1], 10),
63 additions: 0,
64 deletions: 0,
65 lastCommitAt: null,
66 } as Contributor;
67 })
68 .filter((c): c is Contributor => c !== null)
69 .sort((a, b) => b.commits - a.commits);
70
71 // Per-author lines added/removed + most recent commit timestamp.
72 // We use `git log --numstat --format="commit\t%aE\t%aI"` and aggregate by
73 // author email so the totals line up with the shortlog grouping.
74 if (contribs.length > 0) {
75 try {
76 const numstatProc = Bun.spawn(
77 ["git", "log", "--numstat", "--format=__COMMIT__\t%aE\t%aI", ref],
78 { timeout: gitExecTimeoutMs(), killSignal: "SIGKILL", cwd: repoDir, stdout: "pipe", stderr: "pipe" }
79 );
80 const numstatOut = await new Response(numstatProc.stdout).text();
81 await numstatProc.exited;
82
83 const byEmail = new Map<string, { add: number; del: number; last: Date | null }>();
84 let currentEmail: string | null = null;
85 let currentDate: Date | null = null;
86 for (const raw of numstatOut.split("\n")) {
87 const line = raw.trimEnd();
88 if (!line) continue;
89 if (line.startsWith("__COMMIT__\t")) {
90 const parts = line.split("\t");
91 currentEmail = (parts[1] || "").toLowerCase();
92 const iso = parts[2] || "";
93 const d = iso ? new Date(iso) : null;
94 currentDate = d && !Number.isNaN(d.getTime()) ? d : null;
95 if (currentEmail && !byEmail.has(currentEmail)) {
96 byEmail.set(currentEmail, { add: 0, del: 0, last: null });
97 }
98 if (currentEmail) {
99 const bucket = byEmail.get(currentEmail)!;
100 if (currentDate && (!bucket.last || currentDate > bucket.last)) {
101 bucket.last = currentDate;
102 }
103 }
104 continue;
105 }
106 if (!currentEmail) continue;
107 // numstat: "<added>\t<removed>\t<path>" — binary files show "-".
108 const m = line.match(/^(\d+|-)\t(\d+|-)\t/);
109 if (!m) continue;
110 const add = m[1] === "-" ? 0 : parseInt(m[1], 10);
111 const del = m[2] === "-" ? 0 : parseInt(m[2], 10);
112 const bucket = byEmail.get(currentEmail)!;
113 bucket.add += add;
114 bucket.del += del;
115 }
116 for (const ctb of contribs) {
117 const bucket = byEmail.get(ctb.email.toLowerCase());
118 if (bucket) {
119 ctb.additions = bucket.add;
120 ctb.deletions = bucket.del;
121 ctb.lastCommitAt = bucket.last;
122 }
123 }
124 } catch {
125 // numstat is a nice-to-have; if it fails we still render commit counts.
126 }
127 }
128
129 // Get recent commit activity (last 52 weeks)
130 const activityProc = Bun.spawn(
131 [
132 "git",
133 "log",
134 "--format=%aI",
135 "--since=1 year ago",
136 ref,
137 ],
138 { timeout: gitExecTimeoutMs(), killSignal: "SIGKILL", cwd: repoDir, stdout: "pipe", stderr: "pipe" }
139 );
140 const activityOut = await new Response(activityProc.stdout).text();
141 await activityProc.exited;
142
143 // Build weekly commit counts
144 const weekCounts: number[] = new Array(52).fill(0);
145 const now = Date.now();
146 for (const line of activityOut.trim().split("\n").filter(Boolean)) {
147 const date = new Date(line);
148 const weeksAgo = Math.floor(
149 (now - date.getTime()) / (7 * 24 * 60 * 60 * 1000)
150 );
151 if (weeksAgo >= 0 && weeksAgo < 52) {
152 weekCounts[51 - weeksAgo]++;
153 }
154 }
45 // The three git walks below traverse the ENTIRE history on every page
46 // load — 1.7s on this repo's 3,500+ commits, the slowest page a signed-in
47 // developer touched (Homecoming pillar 1, 2026-08-25). Cache the derived
48 // data keyed by the branch TIP, so it recomputes only when commits land:
49 // the tip in the key makes correctness independent of push-side cache
50 // invalidation (which also fires), and the account-matching DB lookup
51 // below stays live — a user registering today must light up on old
52 // commits without waiting for a push.
53 const tipSha = await resolveRef(owner, repo, ref).catch(() => null);
54 const historyData = await cached(
55 gitCache as any,
56 `${owner.toLowerCase()}/${repo.toLowerCase()}:contributors:${ref}:${tipSha ?? "no-tip"}`,
57 () => computeContributorHistory(owner, repo, ref, repoDir)
58 ) as { contribs: Contributor[]; weekCounts: number[] };
59 const contribs = historyData.contribs;
60 const weekCounts = historyData.weekCounts;
15561
15662 // Identity matching — commit author emails are self-reported and trivially
15763 // forgeable (`git config user.email whoever@example.com`), so we only link
@@ -369,6 +275,122 @@ contributors.get("/:owner/:repo/contributors", async (c) => {
369275 );
370276});
371277
278/**
279 * The three full-history git walks, factored out of the route so the
280 * cached() wrapper owns them whole. Shapes and semantics unchanged from
281 * the inline versions (shortlog → contributor list, numstat → per-author
282 * +/−/last-commit, 52-week activity histogram).
283 */
284async function computeContributorHistory(
285 owner: string,
286 repo: string,
287 ref: string,
288 repoDir: string
289): Promise<{ contribs: Contributor[]; weekCounts: number[] }> {
290 const shortlogProc = Bun.spawn(
291 ["git", "shortlog", "-sne", ref],
292 { timeout: gitExecTimeoutMs(), killSignal: "SIGKILL", cwd: repoDir, stdout: "pipe", stderr: "pipe" }
293 );
294 const shortlogOut = await new Response(shortlogProc.stdout).text();
295 await shortlogProc.exited;
296
297 const contribs: Contributor[] = shortlogOut
298 .trim()
299 .split("\n")
300 .filter(Boolean)
301 .map((line) => {
302 const match = line.trim().match(/^(\d+)\t(.+?)\s+<(.+?)>$/);
303 if (!match) return null;
304 return {
305 name: match[2],
306 email: match[3],
307 commits: parseInt(match[1], 10),
308 additions: 0,
309 deletions: 0,
310 lastCommitAt: null,
311 } as Contributor;
312 })
313 .filter((c): c is Contributor => c !== null)
314 .sort((a, b) => b.commits - a.commits);
315
316 // Per-author lines added/removed + most recent commit timestamp,
317 // aggregated by author email so totals line up with the shortlog grouping.
318 if (contribs.length > 0) {
319 try {
320 const numstatProc = Bun.spawn(
321 ["git", "log", "--numstat", "--format=__COMMIT__\t%aE\t%aI", ref],
322 { timeout: gitExecTimeoutMs(), killSignal: "SIGKILL", cwd: repoDir, stdout: "pipe", stderr: "pipe" }
323 );
324 const numstatOut = await new Response(numstatProc.stdout).text();
325 await numstatProc.exited;
326
327 const byEmail = new Map<string, { add: number; del: number; last: Date | null }>();
328 let currentEmail: string | null = null;
329 let currentDate: Date | null = null;
330 for (const raw of numstatOut.split("\n")) {
331 const line = raw.trimEnd();
332 if (!line) continue;
333 if (line.startsWith("__COMMIT__\t")) {
334 const parts = line.split("\t");
335 currentEmail = (parts[1] || "").toLowerCase();
336 const iso = parts[2] || "";
337 const d = iso ? new Date(iso) : null;
338 currentDate = d && !Number.isNaN(d.getTime()) ? d : null;
339 if (currentEmail && !byEmail.has(currentEmail)) {
340 byEmail.set(currentEmail, { add: 0, del: 0, last: null });
341 }
342 if (currentEmail) {
343 const bucket = byEmail.get(currentEmail)!;
344 if (currentDate && (!bucket.last || currentDate > bucket.last)) {
345 bucket.last = currentDate;
346 }
347 }
348 continue;
349 }
350 if (!currentEmail) continue;
351 // numstat: "<added>\t<removed>\t<path>" — binary files show "-".
352 const m = line.match(/^(\d+|-)\t(\d+|-)\t/);
353 if (!m) continue;
354 const add = m[1] === "-" ? 0 : parseInt(m[1], 10);
355 const del = m[2] === "-" ? 0 : parseInt(m[2], 10);
356 const bucket = byEmail.get(currentEmail)!;
357 bucket.add += add;
358 bucket.del += del;
359 }
360 for (const ctb of contribs) {
361 const bucket = byEmail.get(ctb.email.toLowerCase());
362 if (bucket) {
363 ctb.additions = bucket.add;
364 ctb.deletions = bucket.del;
365 ctb.lastCommitAt = bucket.last;
366 }
367 }
368 } catch {
369 // numstat is a nice-to-have; if it fails we still render commit counts.
370 }
371 }
372
373 // 52-week activity histogram.
374 const activityProc = Bun.spawn(
375 ["git", "log", "--format=%aI", "--since=1 year ago", ref],
376 { timeout: gitExecTimeoutMs(), killSignal: "SIGKILL", cwd: repoDir, stdout: "pipe", stderr: "pipe" }
377 );
378 const activityOut = await new Response(activityProc.stdout).text();
379 await activityProc.exited;
380
381 const weekCounts: number[] = new Array(52).fill(0);
382 const now = Date.now();
383 for (const line of activityOut.trim().split("\n").filter(Boolean)) {
384 const date = new Date(line);
385 const weeksAgo = Math.floor((now - date.getTime()) / (7 * 24 * 60 * 60 * 1000));
386 if (weeksAgo >= 0 && weeksAgo < 52) {
387 weekCounts[51 - weeksAgo]++;
388 }
389 }
390
391 return { contribs, weekCounts };
392}
393
372394function relativeTime(d: Date): string {
373395 const diff = Date.now() - d.getTime();
374396 const s = Math.max(0, Math.floor(diff / 1000));
Modifiedsrc/routes/insights.tsx+18−2View fileUnifiedSplit
@@ -29,7 +29,9 @@ import {
2929 repoExists,
3030 getDefaultBranch,
3131 listBranches,
32 resolveRef,
3233} from "../git/repository";
34import { cached, gitCache } from "../lib/cache";
3335import { softAuth, requireAuth } from "../middleware/auth";
3436import type { AuthEnv } from "../middleware/auth";
3537
@@ -597,8 +599,22 @@ insights.get("/:owner/:repo/coupling", async (c) => {
597599 if (!(await repoExists(owner, repo))) return c.notFound();
598600 const ref = (await getDefaultBranch(owner, repo)) || "main";
599601
600 const coupled = await detectCoupledFiles(owner, repo, ref);
601 const story = await getRepoStory(owner, repo, ref);
602 // Full-history analyses, cached keyed by the branch tip (same pattern as
603 // /contributors, 2026-08-25): ~1.7s per load recomputed only when commits
604 // land. The tip in the key keeps correctness independent of push-side
605 // invalidation.
606 const tipSha = await resolveRef(owner, repo, ref).catch(() => null);
607 const cacheKeyBase = `${owner.toLowerCase()}/${repo.toLowerCase()}`;
608 const coupled = (await cached(
609 gitCache as any,
610 `${cacheKeyBase}:coupling:${ref}:${tipSha ?? "no-tip"}`,
611 () => detectCoupledFiles(owner, repo, ref)
612 )) as Awaited<ReturnType<typeof detectCoupledFiles>>;
613 const story = (await cached(
614 gitCache as any,
615 `${cacheKeyBase}:repostory:${ref}:${tipSha ?? "no-tip"}`,
616 () => getRepoStory(owner, repo, ref)
617 )) as Awaited<ReturnType<typeof getRepoStory>>;
602618 const milestones = story.filter((s) => s.significance !== "normal").slice(0, 20);
603619
604620 // Stat-card values derived from the data we already fetched. These line up
605621
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts