OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 commits / 7ba360d

rank repo autocomplete before enriching, not after
search-all-repos backs the nav search box, which fires on a 150ms debounce after 2 characters. It called the full enrichRepos() to get description and commit counts, ranked, then discarded everything but the top 8 — so every keystroke ran the same full-fleet git pass as the /repos landing page. Warm the enrich cache absorbs it. Cold it is a fleet-wide git walk on a keystroke path, and a visitor typing during the boot pre-warm starts several. `commits` is the only field that costs a git walk, and only the rows actually returned need it. New describeRepos() supplies name + description with no git (cached entry when present, small file read otherwise); the handler ranks and caps on that, then enriches only what it is about to send. Ranking never depended on commit count, so result order is unchanged. A query matching nothing now costs zero git. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7ba360db0a24809fa4daa62b22e154a352dec196
by plankatron <fleeky@gmail.com> · 2026-08-16T16:42:07.000Z

Changed files (3) +251 -13

lib/handlers/repos.js +75 -13
……@@ -330,33 +330,47 @@ export function repoHandlers (svc) {
330330 const s = sync()
331331 if (!s) return { repos: [], query: q, total: 0 }
332332
333 // Pull the detailed list so we have description + commit counts
334 const { enrichRepos } = await import('../services/repo-activity.js')
335 const enriched = await enrichRepos(s.status(), s.repos)
333 // Rank on the CHEAP fields first. This fires on a 150ms debounce from
334 // the nav search box, and the full enrichRepos() it used to call walks
335 // git over every repo — the same full-fleet pass as the /repos landing
336 // page, on a keystroke path. Name and branch count are already in
337 // status(); description is a small file read (free when cached).
338 const { describeRepos, enrichRepos } = await import('../services/repo-activity.js')
339 const candidates = await describeRepos(s.status(), s.repos)
336340
337341 const scored = []
338 for (const r of enriched) {
342 for (const r of candidates) {
339343 const name = String(r.name || '').toLowerCase()
340344 const desc = String(r.description || '').toLowerCase()
341345 const nameHit = name.includes(q)
342346 const descHit = desc.includes(q)
343347 if (!nameHit && !descHit) continue
344 scored.push({
345 name: r.name,
346 description: r.description || null,
347 commits: r.commits || 0,
348 branches: r.branches || 0,
349 relevance: nameHit ? 2 : 1
350 })
348 scored.push({ base: r, relevance: nameHit ? 2 : 1 })
351349 }
352350
353351 scored.sort((a, b) => {
354352 if (b.relevance !== a.relevance) return b.relevance - a.relevance
355 return a.name.localeCompare(b.name)
353 return a.base.name.localeCompare(b.base.name)
356354 })
357355
356 // Cap BEFORE enriching: `commits` is the only field that costs a git
357 // walk, and only the rows actually returned need it. Ranking is
358 // unaffected — it never depended on commit count.
358359 const capped = scored.slice(0, limit)
359 return { repos: capped, query: q, total: capped.length }
360 if (capped.length === 0) return { repos: [], query: q, total: 0 }
361
362 const enriched = await enrichRepos({ repos: capped.map((m) => m.base) }, s.repos)
363
364 // enrichRepos preserves input order, so index i lines up with capped[i].
365 const repos = enriched.map((r, i) => ({
366 name: r.name,
367 description: r.description || null,
368 commits: r.commits || 0,
369 branches: r.branches || 0,
370 relevance: capped[i].relevance
371 }))
372
373 return { repos, query: q, total: repos.length }
360374 }
361375 },
362376
……@@ -669,6 +683,54 @@ export function repoHandlers (svc) {
669683 }
670684 },
671685
686 {
687 // READ the per-repo autobase's ref-VIEW — the P2P source of truth for a
688 // repo's refs, as distinct from the bare repo on disk.
689 //
690 // Nothing exposed this before, and its absence caused a real mistake on
691 // 2026-08-08: `pgh repo branches` was cited as evidence about the view when
692 // it actually reads DISK (syncServer.getBranches), and a ref deletion was
693 // then believed to have propagated when for 3 of 5 repos it silently had
694 // not. Ref replication cannot be reasoned about without seeing BOTH sides,
695 // so this returns both and marks where they disagree.
696 name: 'repo-refs',
697 fn: async (ctx, params = {}) => {
698 if (!params.name) throw new Error('repo-refs requires { name }')
699 if (!svc.issueRegistry) throw new Error('issue registry not available')
700 const tracker = await svc.issueRegistry.getForRepo(params.name)
701
702 const view = new Map()
703 for (const r of await tracker.listRefs()) {
704 if (r && r.ref) view.set(r.ref, r.hash || null)
705 }
706
707 const disk = new Map()
708 if (svc._reposDir) {
709 const { listDiskRefs } = await import('../services/ref-publish-reconciler.js')
710 for (const [ref, oid] of await listDiskRefs(join(svc._reposDir, params.name + '.git'))) {
711 disk.set(ref, oid)
712 }
713 }
714
715 // Classification lives in services/ref-diff.js so the rule that decides
716 // "is this a resurrection hazard" is pure and directly testable.
717 const { classifyRefs, refDisagreements } = await import('../services/ref-diff.js')
718 const refs = classifyRefs(view, disk)
719
720 return {
721 name: params.name,
722 // Whether THIS daemon can publish ref changes for this repo at all.
723 // gipPublishRef returns SILENTLY when false, so a delete issued here
724 // would be a no-op that still answers ok:true — the 2026-08-08 trap.
725 writable: tracker.writable,
726 trackerKey: tracker.keyHex,
727 refs,
728 // Pre-filtered for the common operator question: what disagrees?
729 disagreements: refDisagreements(refs)
730 }
731 }
732 },
733
672734 {
673735 // Surface THIS daemon's own tracker local key for a repo — the key an
674736 // owner needs to addWriter this daemon onto the shared per-repo autobase.
lib/services/repo-activity.js +31 -0
……@@ -182,6 +182,37 @@ export async function enrichRepos (status, reposMap) {
182182 return out
183183}
184184
185/**
186 * Name + description only — NO git, so this is safe on a keystroke path.
187 *
188 * Exists for repo autocomplete (search-all-repos), which needs to decide WHICH
189 * repos match before paying for anything expensive. It used the full
190 * enrichRepos() for that, walking git over the whole fleet to rank a query and
191 * then discarding all but 8 results.
192 *
193 * Description comes from the cached entry when there is one and a small file
194 * read otherwise. The cached value is used without re-checking the pack mtime:
195 * a description is a ranking input, not a correctness one, and re-validating it
196 * would mean the stat-per-repo this function exists to avoid.
197 *
198 * @param {object} status - { repos: [...] } from sync.status()
199 * @param {Map} reposMap - sync.repos (name -> { path, ... })
200 * @returns {Promise<Array>} base records plus { description }
201 */
202export async function describeRepos (status, reposMap) {
203 if (!status || !Array.isArray(status.repos) || !reposMap) return []
204
205 return mapLimit(status.repos, CONCURRENCY, async (base) => {
206 const cached = cache.get(base.name)
207 if (cached && cached.data) return { ...base, description: cached.data.description || '' }
208
209 const info = reposMap.get(base.name)
210 const repoPath = info && info.path
211 if (!repoPath) return { ...base, description: '' }
212 return { ...base, description: await readDescription(repoPath) }
213 })
214}
215
185216async function getRepoDetails (name, repoPath) {
186217 // Cache key: pack mtime (changes on new commits) or 0 if no pack yet.
187218 let packMtime = 0
test/search-repos-enrich-scope.test.js +145 -0
……@@ -0,0 +1,145 @@
1// Repo-name autocomplete must not walk git over every repo per keystroke.
2//
3// search-all-repos backs the nav search box, which fires on a 150ms debounce
4// after 2 characters (lib/web/static/pgv3-search.js). It called enrichRepos()
5// over the FULL repo set to get `description` and `commits`, then threw away
6// everything that did not match and capped the rest at 8.
7//
8// Warm that is cheap — the enrich cache absorbs it. Cold it is the same
9// full-fleet git pass as the /repos landing page, now on a keystroke path: a
10// visitor who types four characters into the search box while the boot
11// pre-warm is still running kicks off more full passes.
12//
13// Only the results actually RETURNED need `commits` (the expensive field).
14// Name and branch count are already in sync.status(); description is a small
15// file read (and free when the entry is cached). So filter and rank first,
16// cap, and enrich only what is about to be sent.
17
18import test from 'brittle'
19import { mkdtemp, rm, mkdir, writeFile } from '#node:fs/promises'
20import { tmpdir } from '#node:os'
21import { join } from '#node:path'
22import process from '#node:process'
23
24import { repoHandlers } from '../lib/handlers/repos.js'
25import { execFileSync } from '../lib/_compat-exec.js'
26import { getRepoActivityStats, resetRepoActivityCacheForTests } from '../lib/services/repo-activity.js'
27
28function git (cwd, args) {
29 return execFileSync('git', args, {
30 cwd,
31 stdio: ['ignore', 'pipe', 'ignore'],
32 env: {
33 ...process.env,
34 GIT_AUTHOR_NAME: 't',
35 GIT_AUTHOR_EMAIL: 't@t',
36 GIT_COMMITTER_NAME: 't',
37 GIT_COMMITTER_EMAIL: 't@t'
38 }
39 }).toString()
40}
41
42async function makeBareRepo (root, name, n, description) {
43 const barePath = join(root, name + '.git')
44 const workPath = join(root, name + '-work')
45 await mkdir(barePath, { recursive: true })
46 await mkdir(workPath, { recursive: true })
47 git(barePath, ['init', '--bare', '-q'])
48 git(workPath, ['init', '-q'])
49 for (let i = 0; i < n; i++) {
50 await writeFile(join(workPath, 'f'), 'v' + i)
51 git(workPath, ['add', 'f'])
52 git(workPath, ['commit', '-q', '-m', 'c' + i])
53 }
54 const branch = git(workPath, ['rev-parse', '--abbrev-ref', 'HEAD']).trim()
55 git(workPath, ['remote', 'add', 'origin', barePath])
56 git(workPath, ['push', '-q', 'origin', branch])
57 if (description) await writeFile(join(barePath, 'description'), description)
58 return barePath
59}
60
61function handlerFor (root, names) {
62 const repos = new Map()
63 for (const name of names) repos.set(name, { path: join(root, name + '.git') })
64 const svc = {
65 _sync: {
66 status: () => ({ active: true, repos: names.map((name) => ({ name, serving: true, branches: 1 })) }),
67 repos
68 }
69 }
70 const found = repoHandlers(svc).find((h) => h.name === 'search-all-repos')
71 return found.fn
72}
73
74test('search enriches only the repos it returns', async (t) => {
75 const root = await mkdtemp(join(tmpdir(), 'ra-search-'))
76 t.teardown(() => rm(root, { recursive: true, force: true }))
77
78 // Two match "widget", six do not. A full-fleet enrich computes all eight.
79 const names = ['widget-core', 'widget-ui', 'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta']
80 for (const n of names) await makeBareRepo(root, n, 2)
81
82 resetRepoActivityCacheForTests()
83 const search = handlerFor(root, names)
84
85 const result = await search({}, { query: 'widget' })
86
87 t.is(result.repos.length, 2, 'both widget repos found')
88 t.alike(result.repos.map((r) => r.name).sort(), ['widget-core', 'widget-ui'], 'the right ones')
89 t.is(result.repos[0].commits, 2, 'commit count still populated for a returned repo')
90 t.ok(result.repos[0].branches >= 1, 'branch count still populated')
91
92 const stats = getRepoActivityStats()
93 t.is(stats.computes, 2, 'git walked for the 2 matches only, not all 8 repos')
94})
95
96test('search enriches at most `limit` repos even when many match', async (t) => {
97 const root = await mkdtemp(join(tmpdir(), 'ra-search2-'))
98 t.teardown(() => rm(root, { recursive: true, force: true }))
99
100 const names = ['pear-a', 'pear-b', 'pear-c', 'pear-d', 'pear-e', 'pear-f']
101 for (const n of names) await makeBareRepo(root, n, 1)
102
103 resetRepoActivityCacheForTests()
104 const search = handlerFor(root, names)
105
106 const result = await search({}, { query: 'pear', limit: 2 })
107
108 t.is(result.repos.length, 2, 'capped at the requested limit')
109 t.is(getRepoActivityStats().computes, 2, 'the 4 repos that were ranked out cost no git at all')
110})
111
112test('a description-only match is still found and ranked below a name match', async (t) => {
113 const root = await mkdtemp(join(tmpdir(), 'ra-search3-'))
114 t.teardown(() => rm(root, { recursive: true, force: true }))
115
116 await makeBareRepo(root, 'tooling', 1, 'helpers for the sparkle renderer')
117 await makeBareRepo(root, 'sparkle', 1, 'unrelated blurb')
118 const names = ['tooling', 'sparkle']
119
120 resetRepoActivityCacheForTests()
121 const search = handlerFor(root, names)
122
123 const result = await search({}, { query: 'sparkle' })
124
125 t.is(result.repos.length, 2, 'name match and description match both returned')
126 t.is(result.repos[0].name, 'sparkle', 'name match ranks first')
127 t.is(result.repos[1].name, 'tooling', 'description match ranks second')
128 t.is(result.repos[1].description, 'helpers for the sparkle renderer', 'description carried through')
129})
130
131test('a query matching nothing costs no git at all', async (t) => {
132 const root = await mkdtemp(join(tmpdir(), 'ra-search4-'))
133 t.teardown(() => rm(root, { recursive: true, force: true }))
134
135 const names = ['alpha', 'beta', 'gamma']
136 for (const n of names) await makeBareRepo(root, n, 1)
137
138 resetRepoActivityCacheForTests()
139 const search = handlerFor(root, names)
140
141 const result = await search({}, { query: 'nothingmatchesthis' })
142
143 t.is(result.repos.length, 0, 'no results')
144 t.is(getRepoActivityStats().computes, 0, 'and no repo was walked')
145})