// Refs arriving by REPLICATION must invalidate the repo-activity cache.
//
// invalidateRepoActivity was called from exactly one place: ref-update-observed,
// i.e. the PUSH path. A node that receives refs through the autobase view never
// fires it — and materializing a ref does not restamp objects/pack either, so
// the activity cache's mtime key still matches and enrichRepos keeps serving
// the old commit count until the (generous) backstop TTL expires.
//
// Measured on the live public backend before this fix:
// real commits on main 20
// cached commits 19
// cached mtime == live objects/pack mtime (so the key MATCHED)
// age 5.5h and not self-correcting
// 7rnx.net/git fronts that node, so the public repo page showed 19.
//
// RefReconciler now reports "this pass changed a ref" via onChange, and the
// manager wires that to invalidateRepoActivity(repoName).
import test from 'brittle'
import { mkdtempSync, writeFileSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import { RefReconciler } from '../lib/services/ref-reconciler.js'
import { execFileSync } from '../lib/_compat-exec.js'
function git (args, opts = {}) {
return execFileSync('git', args, { encoding: 'utf8', ...opts }).toString().trim()
}
function tmp () { return mkdtempSync(join(tmpdir(), 'pgv3-refinv-')) }
// Minimal tracker: the autobase ref-view the reconciler projects onto disk.
function fakeTracker (refs) {
const listeners = []
return {
base: { on (_e, fn) { listeners.push(fn) }, off () {} },
async listRefs () { return refs.slice() },
_fire () { for (const fn of listeners) fn() }
}
}
async function makeRepo () {
const dir = tmp()
const bare = join(dir, 'repo.git')
const work = join(dir, 'work')
git(['init', '--bare', '-q', bare])
git(['init', '-q', work])
git(['-C', work, 'config', 'user.email', 't@t'])
git(['-C', work, 'config', 'user.name', 'T'])
git(['-C', work, 'config', 'commit.gpgsign', 'false'])
writeFileSync(join(work, 'a.txt'), 'one\n')
git(['-C', work, 'add', '.'])
git(['-C', work, 'commit', '-q', '-m', 'c1'])
const o1 = git(['-C', work, 'rev-parse', 'HEAD'])
git(['-C', work, 'push', '-q', bare, 'HEAD:refs/heads/main'])
writeFileSync(join(work, 'a.txt'), 'two\n')
git(['-C', work, 'commit', '-q', '-am', 'c2'])
const o2 = git(['-C', work, 'rev-parse', 'HEAD'])
// Push the SECOND commit's objects into the bare repo but leave the ref at
// o1 — this is the replication shape: objects present, ref behind.
git(['-C', work, 'push', '-q', bare, 'HEAD:refs/heads/incoming'])
git(['--git-dir', bare, 'update-ref', '-d', 'refs/heads/incoming'])
return { bare, o1, o2 }
}
test('a ref materialized by replication fires onChange', async (t) => {
const { bare, o1, o2 } = await makeRepo()
const fired = []
const tracker = fakeTracker([{ ref: 'refs/heads/main', hash: o1 }])
const rec = new RefReconciler({
tracker,
repoPath: bare,
onChange: () => { fired.push('change') }
})
// First pass: the bare repo is already at o1, so nothing changes.
await rec.reconcile()
t.is(git(['--git-dir', bare, 'rev-parse', 'refs/heads/main']), o1, 'ref starts at o1')
t.is(fired.length, 0, 'no onChange when the pass is a no-op — cache is not needlessly dropped')
// The view advances (a peer pushed elsewhere and it replicated to us).
tracker.listRefs = async () => [{ ref: 'refs/heads/main', hash: o2 }]
await rec.reconcile()
t.is(git(['--git-dir', bare, 'rev-parse', 'refs/heads/main']), o2, 'ref advanced to o2 on disk')
t.is(fired.length, 1, 'onChange fired exactly once for the change')
})
test('deleting a materialized ref also fires onChange', async (t) => {
const { bare, o1 } = await makeRepo()
const fired = []
const tracker = fakeTracker([{ ref: 'refs/heads/main', hash: o1 }])
const rec = new RefReconciler({ tracker, repoPath: bare, onChange: () => fired.push('change') })
await rec.reconcile()
fired.length = 0
// View drops the ref → reconciler deletes it from the bare repo.
tracker.listRefs = async () => []
await rec.reconcile()
t.is(fired.length, 1, 'a delete is a content change too')
})
test('a failing onChange never breaks reconciliation', async (t) => {
// Cache invalidation is a nicety; projecting refs onto the bare repo is not.
const { bare, o1, o2 } = await makeRepo()
const tracker = fakeTracker([{ ref: 'refs/heads/main', hash: o1 }])
const rec = new RefReconciler({
tracker,
repoPath: bare,
onChange: () => { throw new Error('cache exploded') }
})
await rec.reconcile()
tracker.listRefs = async () => [{ ref: 'refs/heads/main', hash: o2 }]
await rec.reconcile()
t.is(git(['--git-dir', bare, 'rev-parse', 'refs/heads/main']), o2,
'the ref still advanced despite the hook throwing')
})