/**
* Repo tombstones (services/retired-repos.js) + the no-delete invariant.
*
* Issue #135: deleting a bare repo by hand did not stick — four paths
* resurrected or retained it. A tombstone propagates on the RepoEventLog so
* every peer stops touching the repo, and THEN an operator removes bytes by
* hand on each peer.
*
* The last test in this file is the important one: pear-git-v3 must never be
* able to delete a bare repo itself. It is a regression guard, not a
* description — if someone adds that capability, this fails.
*/
import test from 'brittle'
import { readdirSync, readFileSync, statSync } from '#node:fs'
import { join } from '#node:path'
import { fileURLToPath } from '#node:url'
import { dirname } from '#node:path'
import {
RetiredRepos, foldRetired, OP_CREATED, OP_UPDATED, OP_RETIRED
} from '../lib/services/retired-repos.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const LIB = join(__dirname, '..', 'lib')
// A stand-in RepoEventLog: entries() yields decoded entries in seq order.
function fakeLog (entries) {
return {
async * entries () { for (const e of entries) yield e }
}
}
test('op codes match the wire codec', (t) => {
// repo-event-log-messages.js documents 0=created, 1=updated, 2=deleted.
t.is(OP_CREATED, 0)
t.is(OP_UPDATED, 1)
t.is(OP_RETIRED, 2)
})
test('a retire op tombstones the repo', (t) => {
const set = new RetiredRepos()
set.applyEntry({ op: OP_CREATED, name: 'alpha' }, 0)
t.absent(set.has('alpha'), 'created is not retired')
set.applyEntry({ op: OP_RETIRED, name: 'alpha' }, 1)
t.ok(set.has('alpha'), 'retired after op 2')
t.alike(set.names(), ['alpha'])
t.is(set.size, 1)
})
test('a later create un-retires — the mistake is reversible', (t) => {
const set = new RetiredRepos()
set.applyEntry({ op: OP_RETIRED, name: 'alpha' }, 0)
t.ok(set.has('alpha'))
set.applyEntry({ op: OP_CREATED, name: 'alpha' }, 1)
t.absent(set.has('alpha'), 'un-retired by a later op 0')
t.is(set.size, 0)
set.applyEntry({ op: OP_UPDATED, name: 'alpha' }, 2)
t.absent(set.has('alpha'), 'op 1 also un-retires')
})
test('older entries never clobber newer state (out-of-order replay)', (t) => {
const set = new RetiredRepos()
set.applyEntry({ op: OP_RETIRED, name: 'alpha' }, 5)
t.ok(set.has('alpha'))
// A backlog entry arriving late must not resurrect it.
set.applyEntry({ op: OP_CREATED, name: 'alpha' }, 2)
t.ok(set.has('alpha'), 'stale seq 2 must not override seq 5')
set.applyEntry({ op: OP_CREATED, name: 'alpha' }, 9)
t.absent(set.has('alpha'), 'newer seq 9 does override')
})
test('unknown repos are not retired; junk entries are ignored', (t) => {
const set = new RetiredRepos()
t.absent(set.has('never-seen'))
t.absent(set.applyEntry(null, 0), 'null entry')
t.absent(set.applyEntry({ op: OP_RETIRED }, 1), 'entry with no name')
t.is(set.size, 0)
})
test('folding a whole log rebuilds the set — derived, not stored', async (t) => {
const set = await foldRetired(fakeLog([
{ op: OP_CREATED, name: 'alpha' },
{ op: OP_CREATED, name: 'beta' },
{ op: OP_RETIRED, name: 'alpha' },
{ op: OP_CREATED, name: 'gamma' },
{ op: OP_RETIRED, name: 'gamma' },
{ op: OP_CREATED, name: 'gamma' }
]))
t.alike(set.names(), ['alpha'], 'alpha retired; gamma re-created; beta never retired')
// Replay is idempotent — the log is replayed from seq 0 on every boot.
const again = await foldRetired(fakeLog([
{ op: OP_CREATED, name: 'alpha' },
{ op: OP_CREATED, name: 'beta' },
{ op: OP_RETIRED, name: 'alpha' },
{ op: OP_CREATED, name: 'gamma' },
{ op: OP_RETIRED, name: 'gamma' },
{ op: OP_CREATED, name: 'gamma' }
]))
t.alike(again.names(), set.names(), 'same fold, same result')
})
test('isRetired is a bindable predicate for the gate sites', (t) => {
const set = new RetiredRepos()
set.applyEntry({ op: OP_RETIRED, name: 'alpha' }, 0)
const pred = set.isRetired
t.ok(pred('alpha'))
t.absent(pred('beta'))
})
test('folding an absent log is safe', async (t) => {
const set = await foldRetired(null)
t.is(set.size, 0, 'no log -> empty set, no throw')
})
// ───────────────────────────────────────────────────────────────────────────
test('INVARIANT: pear-git-v3 cannot delete a bare repo', (t) => {
// The never-delete rule exists for two reasons: nothing should delete repos
// on its own initiative, and no code bug should be able to mass-delete them.
// Retiring is metadata only. If this test fails, someone gave the daemon the
// ability to remove repo bytes — that is the thing we decided it must never
// have, so the fix is to remove the capability, not to relax this test.
const DELETE_CALLS = /\b(rmSync|rmdirSync|unlinkSync|rimraf)\s*\(|\brm\s*\(|\bunlink\s*\(/
const REPO_PATHS = /reposDir|git-repos|PEAR_GIT_REPOS|\.git['"`]|repoPath/
const offenders = []
const walk = (dir) => {
for (const e of readdirSync(dir)) {
const p = join(dir, e)
if (statSync(p).isDirectory()) {
if (e === 'node_modules' || e === 'static') continue
walk(p)
continue
}
if (!e.endsWith('.js')) continue
const src = readFileSync(p, 'utf8')
src.split('\n').forEach((line, i) => {
if (line.trim().startsWith('*') || line.trim().startsWith('//')) return
if (DELETE_CALLS.test(line) && REPO_PATHS.test(line)) {
offenders.push(`${p.slice(LIB.length + 1)}:${i + 1} ${line.trim()}`)
}
})
}
}
walk(LIB)
t.alike(offenders, [],
'no code path may delete repo bytes — found:\n ' + offenders.join('\n '))
})