| … | … | @@ -187,12 +187,27 @@ export function syncHandlers (svc) { |
| 187 | 187 | // commits, lastCommit, and a 365-day activity bucket array for the |
| 188 | 188 | // /repos landing-page sparkline. Shelling out to git per repo so |
| 189 | 189 | // 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. | |
| 190 | 200 | name: 'list-repos-detailed', |
| 191 | fn: async () => { | |
| 201 | fn: async (ctx, params = {}) => { | |
| 192 | 202 | const sync = svc._sync |
| 193 | 203 | if (!sync) return { repos: [], active: false } |
| 194 | 204 | 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) | |
| 196 | 211 | return { active: true, ...status, repos } |
| 197 | 212 | } |
| 198 | 213 | }, |
| … | … | @@ -112,12 +112,42 @@ function renderActivityGraph (data) { |
| 112 | 112 | * Sort most-recently-pushed first; repos with no commits sink to the bottom. |
| 113 | 113 | * Exported so the /repos page and the /api/repos/page fragment route order |
| 114 | 114 | * 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. | |
| 115 | 128 | */ |
| 129 | function 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 | ||
| 138 | function 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 | ||
| 116 | 145 | export function sortReposByRecency (repos) { |
| 117 | 146 | 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) | |
| 121 | 151 | }) |
| 122 | 152 | } |
| 123 | 153 | |
| … | … | @@ -173,7 +203,9 @@ export async function loadRepoList (svc, ctx) { |
| 173 | 203 | if (!sync) return { repos: [], serving: 0, serverKey: null, active: false } |
| 174 | 204 | |
| 175 | 205 | 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 || {}) | |
| 177 | 209 | return { |
| 178 | 210 | repos: result.repos || [], |
| 179 | 211 | serving: result.serving || 0, |
| … | … | @@ -192,6 +224,35 @@ export async function loadRepoList (svc, ctx) { |
| 192 | 224 | } |
| 193 | 225 | } |
| 194 | 226 | |
| 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 | */ | |
| 239 | async 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 | ||
| 195 | 256 | /** |
| 196 | 257 | * One page of cards as an HTML fragment, for GET /api/repos/page. |
| 197 | 258 | * `limit` of 0 (or Infinity) means "everything left". |
| … | … | @@ -201,9 +262,10 @@ export async function renderRepoPage (svc, ctx, { offset = 0, limit = PAGE_SIZE |
| 201 | 262 | const { repos } = await loadRepoList(svc, ctx) |
| 202 | 263 | const sorted = sortReposByRecency(repos) |
| 203 | 264 | const page = pageOf(sorted, offset, limit) |
| 265 | const enriched = await enrichPage(svc, ctx, page.repos) | |
| 204 | 266 | return { |
| 205 | html: renderRepoCards(page.repos, prefix), | |
| 206 | count: page.repos.length, | |
| 267 | html: renderRepoCards(enriched, prefix), | |
| 268 | count: enriched.length, | |
| 207 | 269 | nextOffset: page.nextOffset, |
| 208 | 270 | remaining: page.remaining, |
| 209 | 271 | total: sorted.length |
| … | … | @@ -220,9 +282,12 @@ export async function renderReposIndex (svc, ctx) { |
| 220 | 282 | |
| 221 | 283 | const { repos, serving, serverKey } = await loadRepoList(svc, ctx) |
| 222 | 284 | |
| 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. | |
| 224 | 288 | const sorted = sortReposByRecency(repos) |
| 225 | 289 | const first = pageOf(sorted, 0, PAGE_SIZE) |
| 290 | const firstEnriched = await enrichPage(svc, ctx, first.repos) | |
| 226 | 291 | |
| 227 | 292 | // The reader picks how much to pull next. "all" is offered because on a |
| 228 | 293 | // fleet this size someone scanning for a repo they cannot name should not |
| … | … | @@ -261,7 +326,7 @@ ${CARD_STYLES} |
| 261 | 326 | |
| 262 | 327 | ${active && sorted.length > 0 ? ` |
| 263 | 328 | <div class="repo-grid" id="repo-grid"> |
| 264 | ${renderRepoCards(first.repos, prefix)} | |
| 329 | ${renderRepoCards(firstEnriched, prefix)} | |
| 265 | 330 | </div> |
| 266 | 331 | ${moreControl} |
| 267 | 332 | <script src="${prefix}/static/pgv3-repos.js${ASSET_QUERY}" defer></script> |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pear-git-v3", |
| 3 | "version": "0.1.238", | |
| 3 | "version": "0.1.239", | |
| 4 | 4 | "description": "P2P git forge — multi-writer issue/org/release/peer-mirror daemon. Built bottom-up after the failed v2.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -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 | ||
| 14 | import test from 'brittle' | |
| 15 | import { mkdtemp, mkdir, writeFile } from '#node:fs/promises' | |
| 16 | import { tmpdir } from '#node:os' | |
| 17 | import { join } from '#node:path' | |
| 18 | import { execFileSync } from '../lib/_compat-exec.js' | |
| 19 | import process from '#node:process' | |
| 20 | ||
| 21 | import { syncHandlers } from '../lib/services/sync.js' | |
| 22 | import { renderRepoPage } from '../lib/web/pages/repos.js' | |
| 23 | import { getRepoActivityStats, resetRepoActivityCacheForTests } from '../lib/services/repo-activity.js' | |
| 24 | ||
| 25 | function 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 | ||
| 39 | async 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 | */ | |
| 61 | function 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 | ||
| 94 | test('/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 | }) |