| … | … | @@ -330,33 +330,47 @@ export function repoHandlers (svc) { |
| 330 | 330 | const s = sync() |
| 331 | 331 | if (!s) return { repos: [], query: q, total: 0 } |
| 332 | 332 | |
| 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) | |
| 336 | 340 | |
| 337 | 341 | const scored = [] |
| 338 | for (const r of enriched) { | |
| 342 | for (const r of candidates) { | |
| 339 | 343 | const name = String(r.name || '').toLowerCase() |
| 340 | 344 | const desc = String(r.description || '').toLowerCase() |
| 341 | 345 | const nameHit = name.includes(q) |
| 342 | 346 | const descHit = desc.includes(q) |
| 343 | 347 | 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 }) | |
| 351 | 349 | } |
| 352 | 350 | |
| 353 | 351 | scored.sort((a, b) => { |
| 354 | 352 | 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) | |
| 356 | 354 | }) |
| 357 | 355 | |
| 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. | |
| 358 | 359 | 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 } | |
| 360 | 374 | } |
| 361 | 375 | }, |
| 362 | 376 | |
| … | … | @@ -669,6 +683,54 @@ export function repoHandlers (svc) { |
| 669 | 683 | } |
| 670 | 684 | }, |
| 671 | 685 | |
| 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 | ||
| 672 | 734 | { |
| 673 | 735 | // Surface THIS daemon's own tracker local key for a repo — the key an |
| 674 | 736 | // owner needs to addWriter this daemon onto the shared per-repo autobase. |
| … | … | @@ -182,6 +182,37 @@ export async function enrichRepos (status, reposMap) { |
| 182 | 182 | return out |
| 183 | 183 | } |
| 184 | 184 | |
| 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 | */ | |
| 202 | export 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 | ||
| 185 | 216 | async function getRepoDetails (name, repoPath) { |
| 186 | 217 | // Cache key: pack mtime (changes on new commits) or 0 if no pack yet. |
| 187 | 218 | let packMtime = 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 | ||
| 18 | import test from 'brittle' | |
| 19 | import { mkdtemp, rm, mkdir, writeFile } from '#node:fs/promises' | |
| 20 | import { tmpdir } from '#node:os' | |
| 21 | import { join } from '#node:path' | |
| 22 | import process from '#node:process' | |
| 23 | ||
| 24 | import { repoHandlers } from '../lib/handlers/repos.js' | |
| 25 | import { execFileSync } from '../lib/_compat-exec.js' | |
| 26 | import { getRepoActivityStats, resetRepoActivityCacheForTests } from '../lib/services/repo-activity.js' | |
| 27 | ||
| 28 | function 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 | ||
| 42 | async 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 | ||
| 61 | function 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 | ||
| 74 | test('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 | ||
| 96 | test('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 | ||
| 112 | test('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 | ||
| 131 | test('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 | }) |