// ref-publish-reconciler — origin-side reconcile (Keet "right state" pattern).
//
// Scenario: a ref advances on disk but the producer event was MISSED (daemon
// down / dead hook), so the autobase ref-view never moved and the commit never
// propagated. The reconcile must notice disk-is-ahead and re-announce it.
import test from 'brittle'
import Corestore from 'corestore'
import { mkdtempSync, writeFileSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import { HyperIssues } from 'hyper-issues'
// hyper-overlay exports the class as `HyperOverlay`; alias it the same way
// lib/pear-git.js does so the V2-era call sites below keep reading naturally.
import { HyperOverlay as HyperOverlayV2 } from 'hyper-overlay'
import { execFileSync } from '../lib/_compat-exec.js'
import { publishRefUpdate } from '../lib/gip-publish.js'
import { reconcileRepoRefs } from '../lib/services/ref-publish-reconciler.js'
function git (args, opts = {}) {
return execFileSync('git', args, { encoding: 'utf8', ...opts }).toString().trim()
}
function headOf (refs, ref) {
const r = refs.find((x) => x.ref === ref)
return r && r.hash
}
test('reconcile re-announces a disk-ahead ref the producer missed', async (t) => {
const dir = mkdtempSync(join(tmpdir(), 'pgv3-reconcile-'))
const origin = join(dir, 'origin.git')
const work = join(dir, 'work')
git(['init', '--bare', '-q', origin])
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', origin, 'HEAD:refs/heads/main'])
const store = new Corestore(join(dir, 'store'))
const overlay = new HyperOverlayV2(store, { namespace: 'overlay-v2', destDir: join(dir, 'fetch') })
await overlay.ready()
const tracker = new HyperIssues(store, { namespace: 'issues:repo:reconcile-test' })
await tracker.ready()
t.teardown(async () => {
try { await tracker.close() } catch {}
try { await overlay.close() } catch {}
try { await store.close() } catch {}
})
const common = {
repoPath: origin,
repoName: 'reconcile-test',
tracker,
overlay,
spoolDir: join(dir, 'gip-bundles'),
userId: 'u-test'
}
// 1. Normal publish of o1 — the event fired, view == o1.
await publishRefUpdate({ ...common, ref: 'refs/heads/main', oldOid: null, newOid: o1 })
t.is(headOf(await tracker.listRefs(), 'refs/heads/main'), o1, 'view at o1 after normal publish')
// 2. Advance the bare repo to o2 but DO NOT announce — the missed event.
writeFileSync(join(work, 'a.txt'), 'two\n')
git(['-C', work, 'commit', '-q', '-am', 'c2'])
const o2 = git(['-C', work, 'rev-parse', 'HEAD'])
git(['-C', work, 'push', '-q', origin, 'HEAD:refs/heads/main'])
t.is(headOf(await tracker.listRefs(), 'refs/heads/main'), o1, 'view STILL at o1 (event missed); disk is ahead at o2')
// 3. Reconcile: disk (o2) ahead of view (o1) → re-announce.
const r1 = await reconcileRepoRefs(common)
t.is(r1.published, 1, 'reconcile published the missed ref')
t.is(headOf(await tracker.listRefs(), 'refs/heads/main'), o2, 'view caught up to o2 after reconcile')
// 4. Idempotent — second pass is a no-op.
const r2 = await reconcileRepoRefs(common)
t.is(r2.published, 0, 'second reconcile publishes nothing')
t.is(r2.inSync, 1, 'ref reported in-sync')
})
test('reconcile is a no-op on a read-only (mirror) tracker', async (t) => {
const r = await reconcileRepoRefs({
repoPath: '/nonexistent.git',
repoName: 'x',
tracker: { writable: false },
overlay: {},
spoolDir: '/tmp/x',
userId: 'u'
})
t.alike(r, { published: 0, skipped: 0, inSync: 0 }, 'mirror tracker → no publish, no disk access')
})