OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 commits / 91715d9

0.1.239: /repos enriches one page, not the whole fleet
renderReposIndex sorted on lastCommit.timestamp, which only exists after enrichRepos() git-walks a repo's FULL history. Picking the top 25 therefore meant walking all of them and discarding the rest: 147 repos walked to render 25. The earlier paging work cut the payload (207KB -> 57KB) but left the input at full fleet size, so the compute cost was untouched. Cold -- OS page cache evicted, which is the state a first visit actually finds -- `git log --all` across the live fleet measured 28.3s. That is the "initial load is slow" report. Now ordering comes from `lastUpdated` (hyper-sync 0.1.6: newest ref mtime), which status() already holds in memory, and only the rendered page is enriched. list-repos-detailed takes an optional `names` subset; loadRepoList switches to the cheap list-repos. Same shape the repo autocomplete already used: rank on cheap fields, cap, then enrich. Measured against the live 147-repo fleet: 147 git walks (2269ms) -> 25 (163ms), an 83% reduction, with the total still correctly reported as 147. lastUpdated is also the better sort key: force-pushing an old commit IS recent activity, and committer dates are clock-controlled, so a single future-dated commit used to pin a repo to the top permanently. sortReposByRecency reads either record shape -- lastUpdated when present, lastCommit (scaled from git's seconds to ms) otherwise -- so the pre-existing pagination contract still holds. That test caught a real regression when the emptiness check was branch-count-only; it now accepts either signal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
91715d9f3479b1947d083a1de8ac269c561ca741
by plankatron <fleeky@gmail.com> · 2026-08-29T15:36:41.000Z

Changed files (4) +204 -11

lib/services/sync.js +17 -2
……@@ -187,12 +187,27 @@ export function syncHandlers (svc) {
187187 // commits, lastCommit, and a 365-day activity bucket array for the
188188 // /repos landing-page sparkline. Shelling out to git per repo so
189189 // we cache results keyed on pack mtime — see repo-activity.js.
190 //
191 // `names` restricts the git work to a subset. The landing page orders
192 // on the cheap `lastUpdated` from status() and then needs git data for
193 // ONE page of cards — walking every repo just to pick the top 25 was
194 // the cold cost this avoids (147 repos, 28.3s cold, to render 25).
195 // Omitted means the whole fleet, so existing callers are unchanged.
196 //
197 // Filtering against the real repo list means git fan-out stays bounded
198 // by fleet size no matter what a caller sends — the same bound as the
199 // no-args call this RPC already accepted anonymously.
190200 name: 'list-repos-detailed',
191 fn: async () => {
201 fn: async (ctx, params = {}) => {
192202 const sync = svc._sync
193203 if (!sync) return { repos: [], active: false }
194204 const status = sync.status()
195 const repos = await enrichRepos(status, sync.repos)
205 let subject = status
206 if (Array.isArray(params.names)) {
207 const wanted = new Set(params.names)
208 subject = { ...status, repos: status.repos.filter((r) => wanted.has(r.name)) }
209 }
210 const repos = await enrichRepos(subject, sync.repos)
196211 return { active: true, ...status, repos }
197212 }
198213 },
lib/web/pages/repos.js +73 -8
……@@ -112,12 +112,42 @@ function renderActivityGraph (data) {
112112 * Sort most-recently-pushed first; repos with no commits sink to the bottom.
113113 * Exported so the /repos page and the /api/repos/page fragment route order
114114 * identically — a mismatch would make paging skip or repeat repos.
115 *
116 * Ordered on `lastUpdated` (newest ref/HEAD/packed-refs mtime), which
117 * status() already has in memory, NOT on `lastCommit.timestamp`. lastCommit
118 * only exists after enrichRepos() git-walks a repo's full history, so using
119 * it as the sort key forced walking every repo to pick a page of 25.
120 *
121 * It is also the better signal for "most recently pushed": force-pushing an
122 * old commit IS recent activity, and committer dates are clock-controlled,
123 * so one future-dated commit used to pin a repo to the top permanently.
124 *
125 * Branch count stands in for "has commits" — free from status(), whereas a
126 * commit count is not. Reads correctly on both cheap and enriched records,
127 * since enrichRepos spreads the base fields through.
115128 */
129function repoRecency (r) {
130 // Milliseconds from whichever signal the record carries. lastUpdated is an
131 // mtime in ms; lastCommit.timestamp is git's %at in SECONDS (timeAgo treats
132 // it that way too), so it is scaled rather than compared raw.
133 if (r.lastUpdated) return r.lastUpdated
134 if (r.lastCommit && r.lastCommit.timestamp) return r.lastCommit.timestamp * 1000
135 return 0
136}
137
138function repoHasHistory (r) {
139 // Branch count is the free proxy on a cheap record; lastCommit is the
140 // direct answer on an enriched one. Either is sufficient.
141 if (r.branches > 0) return true
142 return !!(r.lastCommit && r.lastCommit.timestamp)
143}
144
116145export function sortReposByRecency (repos) {
117146 return [...repos].sort((a, b) => {
118 const aTs = (a.lastCommit && a.lastCommit.timestamp) || 0
119 const bTs = (b.lastCommit && b.lastCommit.timestamp) || 0
120 return bTs - aTs
147 const aHas = repoHasHistory(a)
148 const bHas = repoHasHistory(b)
149 if (aHas !== bHas) return aHas ? -1 : 1
150 return repoRecency(b) - repoRecency(a)
121151 })
122152}
123153
……@@ -173,7 +203,9 @@ export async function loadRepoList (svc, ctx) {
173203 if (!sync) return { repos: [], serving: 0, serverKey: null, active: false }
174204
175205 try {
176 const result = await svc.dispatch('list-repos-detailed', {}, ctx || {})
206 // Deliberately the CHEAP listing: in-memory status, no git. This decides
207 // ordering and which page to show; only that page then gets enriched.
208 const result = await svc.dispatch('list-repos', {}, ctx || {})
177209 return {
178210 repos: result.repos || [],
179211 serving: result.serving || 0,
……@@ -192,6 +224,35 @@ export async function loadRepoList (svc, ctx) {
192224 }
193225}
194226
227/**
228 * Add the git-derived card fields (description, commits, lastCommit,
229 * activity) to exactly the repos being rendered.
230 *
231 * This is the whole point of the paging split: ordering comes from the cheap
232 * listing, so the expensive per-repo git walk is paid only for cards that
233 * actually reach the reader. The same shape the repo autocomplete already
234 * uses — rank on cheap fields, cap, then enrich.
235 *
236 * Degrades rather than fails: if the dispatch errors, cards still render
237 * from the cheap record, just without commit counts or a sparkline.
238 */
239async function enrichPage (svc, ctx, repos) {
240 if (!repos.length) return repos
241 try {
242 const result = await svc.dispatch(
243 'list-repos-detailed',
244 { names: repos.map((r) => r.name) },
245 ctx || {}
246 )
247 const byName = new Map((result.repos || []).map((r) => [r.name, r]))
248 // Map over the page, not over the response: preserves the page's order
249 // and survives a repo disappearing between the two dispatches.
250 return repos.map((r) => byName.get(r.name) || r)
251 } catch {
252 return repos
253 }
254}
255
195256/**
196257 * One page of cards as an HTML fragment, for GET /api/repos/page.
197258 * `limit` of 0 (or Infinity) means "everything left".
……@@ -201,9 +262,10 @@ export async function renderRepoPage (svc, ctx, { offset = 0, limit = PAGE_SIZE
201262 const { repos } = await loadRepoList(svc, ctx)
202263 const sorted = sortReposByRecency(repos)
203264 const page = pageOf(sorted, offset, limit)
265 const enriched = await enrichPage(svc, ctx, page.repos)
204266 return {
205 html: renderRepoCards(page.repos, prefix),
206 count: page.repos.length,
267 html: renderRepoCards(enriched, prefix),
268 count: enriched.length,
207269 nextOffset: page.nextOffset,
208270 remaining: page.remaining,
209271 total: sorted.length
……@@ -220,9 +282,12 @@ export async function renderReposIndex (svc, ctx) {
220282
221283 const { repos, serving, serverKey } = await loadRepoList(svc, ctx)
222284
223 // Sort by most-recent commit desc; empty/quiet repos sink to the bottom.
285 // Sort by most-recent activity desc; empty/quiet repos sink to the bottom.
286 // Ordering uses only cheap in-memory fields, so the git walk below is paid
287 // for PAGE_SIZE cards rather than the whole fleet.
224288 const sorted = sortReposByRecency(repos)
225289 const first = pageOf(sorted, 0, PAGE_SIZE)
290 const firstEnriched = await enrichPage(svc, ctx, first.repos)
226291
227292 // The reader picks how much to pull next. "all" is offered because on a
228293 // fleet this size someone scanning for a repo they cannot name should not
……@@ -261,7 +326,7 @@ ${CARD_STYLES}
261326
262327${active && sorted.length > 0 ? `
263328<div class="repo-grid" id="repo-grid">
264 ${renderRepoCards(first.repos, prefix)}
329 ${renderRepoCards(firstEnriched, prefix)}
265330</div>
266331${moreControl}
267332<script src="${prefix}/static/pgv3-repos.js${ASSET_QUERY}" defer></script>
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pear-git-v3",
3 "version": "0.1.238",
3 "version": "0.1.239",
44 "description": "P2P git forge — multi-writer issue/org/release/peer-mirror daemon. Built bottom-up after the failed v2.",
55 "type": "module",
66 "main": "index.js",
test/repos-page-enrich-scope.test.js +113 -0
……@@ -0,0 +1,113 @@
1/**
2 * The /repos landing page must git-walk only the repos it renders.
3 *
4 * The page renders PAGE_SIZE cards but sorted on `lastCommit.timestamp`,
5 * which only exists AFTER enrichRepos() has walked a repo's full history.
6 * So picking the top 25 meant enriching all of them first and discarding
7 * the rest: on the live backend, 147 repos walked to show 25, which cold
8 * (page cache evicted) measured 28.3s of `git log --all`.
9 *
10 * `computes` counts real git passes, so this asserts the scope of the work
11 * mechanically instead of inferring it from wall-clock timing.
12 */
13
14import test from 'brittle'
15import { mkdtemp, mkdir, writeFile } from '#node:fs/promises'
16import { tmpdir } from '#node:os'
17import { join } from '#node:path'
18import { execFileSync } from '../lib/_compat-exec.js'
19import process from '#node:process'
20
21import { syncHandlers } from '../lib/services/sync.js'
22import { renderRepoPage } from '../lib/web/pages/repos.js'
23import { getRepoActivityStats, resetRepoActivityCacheForTests } from '../lib/services/repo-activity.js'
24
25function git (cwd, args) {
26 return execFileSync('git', args, {
27 cwd,
28 stdio: ['ignore', 'pipe', 'ignore'],
29 env: {
30 ...process.env,
31 GIT_AUTHOR_NAME: 't',
32 GIT_AUTHOR_EMAIL: 't@t',
33 GIT_COMMITTER_NAME: 't',
34 GIT_COMMITTER_EMAIL: 't@t'
35 }
36 }).toString()
37}
38
39async function makeBareRepo (root, name, n) {
40 const barePath = join(root, name + '.git')
41 const workPath = join(root, name + '-work')
42 await mkdir(barePath, { recursive: true })
43 await mkdir(workPath, { recursive: true })
44 git(barePath, ['init', '--bare', '-q'])
45 git(workPath, ['init', '-q'])
46 for (let i = 0; i < n; i++) {
47 await writeFile(join(workPath, 'f'), 'v' + i)
48 git(workPath, ['add', 'f'])
49 git(workPath, ['commit', '-q', '-m', 'c' + i])
50 }
51 const branch = git(workPath, ['rev-parse', '--abbrev-ref', 'HEAD']).trim()
52 git(workPath, ['remote', 'add', 'origin', barePath])
53 git(workPath, ['push', '-q', 'origin', branch])
54 return barePath
55}
56
57/**
58 * Minimal svc wired to the REAL sync RPC handlers and the REAL page code,
59 * so this exercises the actual dispatch path rather than a mock of it.
60 */
61function makeSvc (root, names) {
62 const repos = new Map()
63 for (const name of names) repos.set(name, { path: join(root, name + '.git') })
64
65 const svc = {
66 _sync: {
67 repos,
68 status () {
69 return {
70 repos: names.map((name, i) => ({
71 name,
72 serving: true,
73 branches: 1,
74 // Distinct, descending so ordering is deterministic and the
75 // first page is a known subset.
76 lastUpdated: 1_700_000_000_000 - i * 60_000
77 })),
78 serving: names.length,
79 serverKey: 'k'.repeat(64)
80 }
81 }
82 }
83 }
84
85 const handlers = syncHandlers(svc)
86 svc.dispatch = async (name, params = {}, ctx = {}) => {
87 const h = handlers.find((x) => x.name === name)
88 if (!h) throw new Error('no handler: ' + name)
89 return h.fn(ctx, params)
90 }
91 return svc
92}
93
94test('/repos page enriches only the repos it renders', async (t) => {
95 const root = await mkdtemp(join(tmpdir(), 'repos-scope-'))
96 const names = ['a', 'b', 'c', 'd', 'e']
97 for (const n of names) await makeBareRepo(root, n, 2)
98
99 const svc = makeSvc(root, names)
100
101 resetRepoActivityCacheForTests()
102 const page = await renderRepoPage(svc, {}, { offset: 0, limit: 2 })
103
104 // Guard: loadRepoList swallows a dispatch failure and falls back to the
105 // unenriched status, which would show 0 computes and pass this test for
106 // entirely the wrong reason. Prove real cards were rendered first.
107 t.ok(page.html && page.html.includes('repo-card'), 'rendered real cards')
108 t.is(page.count, 2, 'rendered exactly one page of 2')
109 t.is(page.total, names.length, 'knows the full total without walking it')
110
111 const { computes } = getRepoActivityStats()
112 t.is(computes, 2, 'git-walked ONLY the 2 rendered repos (was: all ' + names.length + ')')
113})