// gip-overlay P2 — bare-repo reconciliation.
//
// The autobase ref-view is authority; the on-disk bare repo follows it.
// RefReconciler watches a per-repo HyperIssues tracker and, on every view
// update, forces the bare repo's refs to match the view via `git update-ref`
// — fetching missing objects (overlay in prod, injected here) before moving
// a ref, and never pointing a ref at an absent object. Forcing to the view
// value is also how a locally-accepted-but-globally-rejected push is rolled
// back. Real git + a real tracker, no mocks.
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'
import { execFileSync } from '../lib/_compat-exec.js'
import { RefReconciler } from '../lib/services/ref-reconciler.js'
function git (args, opts = {}) {
return execFileSync('git', args, { encoding: 'utf8', ...opts }).toString().trim()
}
// Build a bare repo that CONTAINS the objects for two commits (oid1 -> oid2)
// but has NO refs/heads/main — so the reconciler is what materializes the ref
// from the view. Objects survive the ref deletion (no gc runs).
function seedRepo () {
const dir = mkdtempSync(join(tmpdir(), 'pgv3-ref-'))
const bare = join(dir, 'r.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 oid1 = git(['-C', work, 'rev-parse', 'HEAD'])
writeFileSync(join(work, 'a.txt'), 'two\n')
git(['-C', work, 'commit', '-q', '-am', 'c2'])
const oid2 = git(['-C', work, 'rev-parse', 'HEAD'])
// Push both commits' objects into the bare repo, then drop the ref so only
// the objects remain.
git(['-C', work, 'push', '-q', bare, 'HEAD:refs/heads/main'])
git(['--git-dir', bare, 'update-ref', '-d', 'refs/heads/main'])
return { dir, bare, work, oid1, oid2 }
}
function bareRef (bare, ref) {
try { return git(['--git-dir', bare, 'rev-parse', '--verify', '--quiet', ref]) || null }
catch { return null }
}
async function makeTracker (t, namespace = 'issues:repo:r') {
const store = new Corestore(mkdtempSync(join(tmpdir(), 'pgv3-ref-store-')))
const tracker = new HyperIssues(store, { namespace })
await tracker.ready()
t.teardown(async () => {
try { await tracker.close() } catch {}
try { await store.close() } catch {}
})
return tracker
}
test('reconcile materializes a view ref into the bare repo', async (t) => {
const repo = seedRepo()
const tracker = await makeTracker(t)
const rec = new RefReconciler({ tracker, repoPath: repo.bare })
t.is(bareRef(repo.bare, 'refs/heads/main'), null, 'precondition: no ref, objects present')
await tracker.updateRef('refs/heads/main', repo.oid1, 'user-1')
await rec.reconcile()
t.is(bareRef(repo.bare, 'refs/heads/main'), repo.oid1, 'bare ref now matches the view')
})
test('reconcile fast-forwards the bare ref when the view moves', async (t) => {
const repo = seedRepo()
const tracker = await makeTracker(t, 'issues:repo:r2')
const rec = new RefReconciler({ tracker, repoPath: repo.bare })
await tracker.updateRef('refs/heads/main', repo.oid1, 'user-1')
await rec.reconcile()
t.is(bareRef(repo.bare, 'refs/heads/main'), repo.oid1)
await tracker.updateRef('refs/heads/main', repo.oid2, 'user-1', repo.oid1) // FF in view
await rec.reconcile()
t.is(bareRef(repo.bare, 'refs/heads/main'), repo.oid2, 'bare ref followed the view forward')
})
test('reconcile rolls a divergent local ref back to the view value', async (t) => {
const repo = seedRepo()
const tracker = await makeTracker(t, 'issues:repo:r3')
const rec = new RefReconciler({ tracker, repoPath: repo.bare })
await tracker.updateRef('refs/heads/main', repo.oid1, 'user-1')
await rec.reconcile()
t.is(bareRef(repo.bare, 'refs/heads/main'), repo.oid1)
// A locally-accepted-but-globally-rejected push moved the bare ref ahead.
git(['--git-dir', repo.bare, 'update-ref', 'refs/heads/main', repo.oid2])
t.is(bareRef(repo.bare, 'refs/heads/main'), repo.oid2, 'bare diverged from view')
await rec.reconcile() // view still says oid1
t.is(bareRef(repo.bare, 'refs/heads/main'), repo.oid1, 'reconcile rolled bare back to the view value')
})
test('reconcile deletes a bare ref it materialized once the view drops it', async (t) => {
const repo = seedRepo()
const tracker = await makeTracker(t, 'issues:repo:r4')
const rec = new RefReconciler({ tracker, repoPath: repo.bare })
await tracker.updateRef('refs/heads/main', repo.oid1, 'user-1')
await rec.reconcile()
t.is(bareRef(repo.bare, 'refs/heads/main'), repo.oid1)
await tracker.deleteRef('refs/heads/main', 'user-1')
await rec.reconcile()
t.is(bareRef(repo.bare, 'refs/heads/main'), null, 'bare ref deleted to match the view')
})
test('reconcile never points a ref at an absent object (skips, no corruption)', async (t) => {
const repo = seedRepo()
const tracker = await makeTracker(t, 'issues:repo:r5')
const missing = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'
const asked = []
const rec = new RefReconciler({
tracker,
repoPath: repo.bare,
ensureObjects: async (oids) => { asked.push(...oids) } // overlay stub: fetches nothing
})
await tracker.updateRef('refs/heads/ghost', missing, 'user-1')
await rec.reconcile()
t.is(bareRef(repo.bare, 'refs/heads/ghost'), null, 'ref not created for a missing object')
t.alike(asked, [missing], 'ensureObjects was asked for the missing oid')
})
test('reconcile materializes after ensureObjects supplies the missing object', async (t) => {
// Seed a bare repo that holds ONLY oid1's objects (not oid2), so moving the
// ref to oid2 genuinely requires ensureObjects to fetch it first.
const dir = mkdtempSync(join(tmpdir(), 'pgv3-ref-'))
const bare = join(dir, 'r.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 oid1 = git(['-C', work, 'rev-parse', 'HEAD'])
writeFileSync(join(work, 'a.txt'), 'two\n')
git(['-C', work, 'commit', '-q', '-am', 'c2'])
const oid2 = git(['-C', work, 'rev-parse', 'HEAD'])
// bring ONLY oid1 into the bare repo, then drop the ref (objects remain)
git(['-C', work, 'push', '-q', bare, oid1 + ':refs/heads/_seed'])
git(['--git-dir', bare, 'update-ref', '-d', 'refs/heads/_seed'])
const tracker = await makeTracker(t, 'issues:repo:r6')
let supplied = false
const rec = new RefReconciler({
tracker,
repoPath: bare,
ensureObjects: async (oids) => {
// overlay stand-in: fetch the missing objects from the work clone
git(['--git-dir', bare, 'fetch', '-q', work, ...oids])
supplied = true
}
})
await tracker.updateRef('refs/heads/main', oid2, 'user-1')
await rec.reconcile()
t.ok(supplied, 'ensureObjects was invoked for the missing object')
t.is(bareRef(bare, 'refs/heads/main'), oid2, 'ref materialized after the fetch')
})
test('reconcile repoints a dangling HEAD to the materialized branch', async (t) => {
const repo = seedRepo()
const tracker = await makeTracker(t, 'issues:repo:r-head')
const rec = new RefReconciler({ tracker, repoPath: repo.bare })
// Force the dangling-HEAD case: HEAD -> refs/heads/master, but master never
// exists (the branch is `main`). This is what `git init --bare` leaves on a
// reconciler-materialized repo, and why `git rev-parse HEAD` / `git clone`
// find no default branch even though the objects + `main` ref are present.
git(['--git-dir', repo.bare, 'symbolic-ref', 'HEAD', 'refs/heads/master'])
t.is(git(['--git-dir', repo.bare, 'symbolic-ref', 'HEAD']), 'refs/heads/master', 'precondition: HEAD dangling at master')
await tracker.updateRef('refs/heads/main', repo.oid1, 'user-1')
await rec.reconcile()
t.is(bareRef(repo.bare, 'refs/heads/main'), repo.oid1, 'main ref materialized')
t.is(git(['--git-dir', repo.bare, 'symbolic-ref', 'HEAD']), 'refs/heads/main', 'dangling HEAD repointed to the existing branch (main)')
})