// Repo-name autocomplete must not walk git over every repo per keystroke.
//
// search-all-repos backs the nav search box, which fires on a 150ms debounce
// after 2 characters (lib/web/static/pgv3-search.js). It called enrichRepos()
// over the FULL repo set to get `description` and `commits`, then threw away
// everything that did not match and capped the rest at 8.
//
// Warm that is cheap — the enrich cache absorbs it. Cold it is the same
// full-fleet git pass as the /repos landing page, now on a keystroke path: a
// visitor who types four characters into the search box while the boot
// pre-warm is still running kicks off more full passes.
//
// Only the results actually RETURNED need `commits` (the expensive field).
// Name and branch count are already in sync.status(); description is a small
// file read (and free when the entry is cached). So filter and rank first,
// cap, and enrich only what is about to be sent.
import test from 'brittle'
import { mkdtemp, rm, mkdir, writeFile } from '#node:fs/promises'
import { tmpdir } from '#node:os'
import { join } from '#node:path'
import process from '#node:process'
import { repoHandlers } from '../lib/handlers/repos.js'
import { execFileSync } from '../lib/_compat-exec.js'
import { getRepoActivityStats, resetRepoActivityCacheForTests } from '../lib/services/repo-activity.js'
function git (cwd, args) {
return execFileSync('git', args, {
cwd,
stdio: ['ignore', 'pipe', 'ignore'],
env: {
...process.env,
GIT_AUTHOR_NAME: 't',
GIT_AUTHOR_EMAIL: 't@t',
GIT_COMMITTER_NAME: 't',
GIT_COMMITTER_EMAIL: 't@t'
}
}).toString()
}
async function makeBareRepo (root, name, n, description) {
const barePath = join(root, name + '.git')
const workPath = join(root, name + '-work')
await mkdir(barePath, { recursive: true })
await mkdir(workPath, { recursive: true })
git(barePath, ['init', '--bare', '-q'])
git(workPath, ['init', '-q'])
for (let i = 0; i < n; i++) {
await writeFile(join(workPath, 'f'), 'v' + i)
git(workPath, ['add', 'f'])
git(workPath, ['commit', '-q', '-m', 'c' + i])
}
const branch = git(workPath, ['rev-parse', '--abbrev-ref', 'HEAD']).trim()
git(workPath, ['remote', 'add', 'origin', barePath])
git(workPath, ['push', '-q', 'origin', branch])
if (description) await writeFile(join(barePath, 'description'), description)
return barePath
}
function handlerFor (root, names) {
const repos = new Map()
for (const name of names) repos.set(name, { path: join(root, name + '.git') })
const svc = {
_sync: {
status: () => ({ active: true, repos: names.map((name) => ({ name, serving: true, branches: 1 })) }),
repos
}
}
const found = repoHandlers(svc).find((h) => h.name === 'search-all-repos')
return found.fn
}
test('search enriches only the repos it returns', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'ra-search-'))
t.teardown(() => rm(root, { recursive: true, force: true }))
// Two match "widget", six do not. A full-fleet enrich computes all eight.
const names = ['widget-core', 'widget-ui', 'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta']
for (const n of names) await makeBareRepo(root, n, 2)
resetRepoActivityCacheForTests()
const search = handlerFor(root, names)
const result = await search({}, { query: 'widget' })
t.is(result.repos.length, 2, 'both widget repos found')
t.alike(result.repos.map((r) => r.name).sort(), ['widget-core', 'widget-ui'], 'the right ones')
t.is(result.repos[0].commits, 2, 'commit count still populated for a returned repo')
t.ok(result.repos[0].branches >= 1, 'branch count still populated')
const stats = getRepoActivityStats()
t.is(stats.computes, 2, 'git walked for the 2 matches only, not all 8 repos')
})
test('search enriches at most `limit` repos even when many match', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'ra-search2-'))
t.teardown(() => rm(root, { recursive: true, force: true }))
const names = ['pear-a', 'pear-b', 'pear-c', 'pear-d', 'pear-e', 'pear-f']
for (const n of names) await makeBareRepo(root, n, 1)
resetRepoActivityCacheForTests()
const search = handlerFor(root, names)
const result = await search({}, { query: 'pear', limit: 2 })
t.is(result.repos.length, 2, 'capped at the requested limit')
t.is(getRepoActivityStats().computes, 2, 'the 4 repos that were ranked out cost no git at all')
})
test('a description-only match is still found and ranked below a name match', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'ra-search3-'))
t.teardown(() => rm(root, { recursive: true, force: true }))
await makeBareRepo(root, 'tooling', 1, 'helpers for the sparkle renderer')
await makeBareRepo(root, 'sparkle', 1, 'unrelated blurb')
const names = ['tooling', 'sparkle']
resetRepoActivityCacheForTests()
const search = handlerFor(root, names)
const result = await search({}, { query: 'sparkle' })
t.is(result.repos.length, 2, 'name match and description match both returned')
t.is(result.repos[0].name, 'sparkle', 'name match ranks first')
t.is(result.repos[1].name, 'tooling', 'description match ranks second')
t.is(result.repos[1].description, 'helpers for the sparkle renderer', 'description carried through')
})
test('a query matching nothing costs no git at all', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'ra-search4-'))
t.teardown(() => rm(root, { recursive: true, force: true }))
const names = ['alpha', 'beta', 'gamma']
for (const n of names) await makeBareRepo(root, n, 1)
resetRepoActivityCacheForTests()
const search = handlerFor(root, names)
const result = await search({}, { query: 'nothingmatchesthis' })
t.is(result.repos.length, 0, 'no results')
t.is(getRepoActivityStats().computes, 0, 'and no repo was walked')
})