/**
* A bare `sync.scan()` must not resurrect a tombstoned repo in the LISTING.
*
* hyper-sync's scan() does not merge — it REPLACES `this.repos` with a fresh
* map built from the repos directory (node_modules/hyper-sync/index.js:101),
* and status() iterates exactly that map. startSyncService prunes retired
* names after its own boot scan and after each watcher rescan, but pruneRetired
* is a closure reachable from nowhere else, so the three call sites that scan
* directly —
*
* services/sync.js 'scan-repos' (pgh repo scan)
* services/sync.js 'create-repo'
* pear-git.js:3059 the post-pull rescan
*
* — put every tombstoned bare dir straight back into the listing.
*
* Observed in production 2026-09-02: the origin booted, logged "[retired] 15
* repo(s) tombstoned", and listed 132 repos. `scan-repos` ran twice a few
* minutes later and the listing became 148 — the 15 retired repos were back
* on `pgh repo` and on the SSR /repos page. They stayed unserved (serve() is
* only reached via paths that do prune), so this is a listing leak, not an
* announcement one: the page advertises repos the operator retired.
*/
import test from 'brittle'
import { mkdtempSync, mkdirSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import { execFile } from '../lib/_compat-exec.js'
import { startSyncService } from '../lib/services/sync.js'
import { RetiredRepos, OP_RETIRED } from '../lib/services/retired-repos.js'
let n = 0
function tmpDir () {
return mkdtempSync(join(tmpdir(), 'pgv3-retired-scan-' + (n++) + '-'))
}
const listed = (sync) => sync.status().repos.map((r) => r.name).sort()
test('scan() does not resurrect a retired repo in the listing', async (t) => {
t.timeout(60000)
const reposDir = join(tmpDir(), 'git-repos')
mkdirSync(reposDir, { recursive: true })
for (const name of ['alpha', 'beta']) {
await execFile('git', ['init', '--bare', '-q', join(reposDir, name + '.git')])
}
const retired = new RetiredRepos()
retired.applyEntry({ op: OP_RETIRED, name: 'alpha' }, 0)
// offline: no Hyperswarm, no DHT. scan/status/prune are unaffected by it.
const sync = await startSyncService({
reposDir,
offline: true,
isRetired: retired.isRetired
})
t.teardown(async () => {
try { sync._reposWatcher.cancelRescan() } catch {}
try { sync._reposWatcher.close() } catch {}
try { await sync.close() } catch {}
})
t.alike(listed(sync), ['beta'], 'boot: the tombstoned repo is not listed')
// The watcher prunes; the point of this test is the callers that don't.
// Stop it so the assertion below cannot be rescued by a debounce tick.
sync._reposWatcher.cancelRescan()
sync._reposWatcher.close()
// Exactly what 'scan-repos' / 'create-repo' / the post-pull rescan do.
await sync.scan()
t.alike(listed(sync), ['beta'],
'after a direct scan(): still not listed, and live repos survive')
})