OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / retired-repos-scan.test.js

78 lines · javascript ref:
/**
 * 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')
})