/**
* ref-publish-reconciler — gip-overlay origin-side reconcile.
*
* The COMPLEMENT of RefReconciler. RefReconciler projects the autobase
* ref-view ONTO the bare repo (view → disk, the mirror side). This projects
* the bare repo INTO the ref-view (disk → view, the writer/origin side).
*
* Why it exists (Keet's "make sure each peer is in the right state"):
* ref propagation is event-driven — a push fires `ref-update-observed`, which
* calls publishRefUpdate(). But that event can be MISSED: the daemon was down
* when the push landed, or the push went straight to the bare repo and only
* the (possibly stale/dead) post-receive hook fired into the void. When that
* happens the bare repo advances on disk but the autobase ref-view — the P2P
* source of truth that mirrors replicate — never moves, so the commit never
* propagates and nothing replays it.
*
* This closes that gap: on boot (and any time it's invoked) the writer node
* compares each repo's on-disk refs against what it has announced and
* re-announces anything the disk is ahead on. Idempotent: a ref already at
* the announced oid is a no-op. Safe: writer-only; the FF guard in
* tracker.updateRef rejects non-fast-forward divergence (a locally-accepted
* but globally-rejected force-push) — those are logged and skipped, never
* forced. It never deletes refs.
*/
import { execFile } from '../_compat-exec.js'
import { publishRefUpdate } from '../gip-publish.js'
import { ZERO_OID } from 'hyper-issues'
const NOOP_LOG = { info () {}, warn () {} }
// Heads + tags on a bare repo as Map(refname -> oid). Internal/out-of-band
// refs (refs/release, refs/pgh/*, etc.) are deliberately excluded — only the
// standard refs clients clone are propagated.
export async function listDiskRefs (repoPath, git = execFile) {
let out = ''
try {
// ASYNC by default (see _compat-exec): this is now on the push hot path
// via ref-update-observed, and a blocking spawn there stalls every
// in-flight HTTP request for the child's lifetime. An injected SYNC shim
// still works — awaiting a plain value resolves immediately.
out = await git('git', ['--git-dir', repoPath, 'for-each-ref',
'--format=%(refname) %(objectname)', 'refs/heads/', 'refs/tags/'], { encoding: 'utf8' }) || ''
} catch {
return new Map()
}
const refs = new Map()
for (const line of out.toString().split('\n')) {
const sp = line.indexOf(' ')
if (sp <= 0) continue
const ref = line.slice(0, sp).trim()
const oid = line.slice(sp + 1).trim()
if (ref && oid) refs.set(ref, oid)
}
return refs
}
/**
* Reconcile ONE repo's on-disk refs into its autobase ref-view.
*
* @param {object} o
* o.repoPath bare repo (--git-dir)
* o.repoName repo name
* o.tracker per-repo HyperIssues (must be writable to publish)
* o.overlay HyperOverlayV2-shaped
* o.spoolDir bundle spool dir
* o.userId signing user id
* o.git execFile-shaped (injectable for tests)
* o.log { info, warn }
* @returns {Promise<{ published: number, skipped: number, inSync: number }>}
*/
export async function reconcileRepoRefs (o = {}) {
const { repoPath, repoName, tracker, overlay, spoolDir, userId } = o
const git = o.git || execFile
const log = o.log || NOOP_LOG
if (!tracker || !tracker.writable) return { published: 0, skipped: 0, inSync: 0 }
const disk = await listDiskRefs(repoPath, git)
const announced = new Map()
try {
for (const r of await tracker.listRefs()) if (r && r.ref && r.hash) announced.set(r.ref, r.hash)
} catch (err) {
log.warn('[ref-reconcile] listRefs failed for ' + repoName + ': ' + err.message)
return { published: 0, skipped: 0, inSync: 0 }
}
let published = 0; let skipped = 0; let inSync = 0
for (const [ref, diskOid] of disk) {
const annOid = announced.get(ref) || null
if (annOid === diskOid) { inSync++; continue }
try {
const res = await publishRefUpdate({
repoPath,
repoName,
ref,
oldOid: annOid || ZERO_OID,
newOid: diskOid,
tracker,
overlay,
spoolDir,
userId
})
if (res) {
published++
log.info('[ref-reconcile] re-announced ' + repoName + ' ' + ref + ' ' +
(annOid ? annOid.slice(0, 8) : '∅') + '→' + diskOid.slice(0, 8))
} else {
// empty delta — disk not actually ahead of the announced oid
skipped++
}
} catch (err) {
// FF guard rejected (non-FF divergence) or bundle failed — never force.
skipped++
log.warn('[ref-reconcile] skip ' + repoName + ' ' + ref + ': ' + err.message)
}
}
return { published, skipped, inSync }
}
/**
* Boot pass: reconcile every materialized bare repo under reposDir. Writer
* nodes re-announce disk-ahead refs; on a mirror every tracker is read-only so
* this is a no-op (the mirror gets refs from the view via RefReconciler).
* Fire-and-forget friendly — resilient per repo, returns a summary.
*
* @param {object} o
* o.reposDir dir of <name>.git bare repos
* o.issueRegistry getForRepo(name) -> per-repo tracker
* o.overlay, o.spoolDir, o.userId as reconcileRepoRefs
* o.fs, o.git, o.log
*/
export async function reconcileAllRepoRefs (o = {}) {
const fs = o.fs || (await import('#node:fs')).default
const log = o.log || NOOP_LOG
const { reposDir, issueRegistry, overlay, spoolDir, userId } = o
let entries = []
try { entries = fs.readdirSync(reposDir) } catch { return { repos: 0, published: 0 } }
// Tombstone gate. This readdir is resurrection path 2: it re-publishes refs
// for EVERY <name>.git on disk, so a repo retired on the log came back to
// life on the next boot. Skipping retired names here does not delete the
// directory — the operator removes bytes by hand; this only stops the
// daemon re-adopting one that is still sitting there.
const isRetired = typeof o.isRetired === 'function' ? o.isRetired : () => false
let repos = 0; let published = 0; let skippedRetired = 0
for (const d of entries) {
if (!d.endsWith('.git')) continue
const name = d.replace(/\.git$/, '')
if (isRetired(name)) { skippedRetired++; continue }
repos++
try {
const tracker = await issueRegistry.getForRepo(name)
const r = await reconcileRepoRefs({
repoPath: reposDir + '/' + d,
repoName: name,
tracker,
overlay,
spoolDir,
userId,
git: o.git,
log
})
published += r.published
} catch (err) {
log.warn('[ref-reconcile] repo ' + name + ' failed: ' + err.message)
}
}
if (published > 0) log.info('[ref-reconcile] boot pass: re-announced ' + published + ' ref(s) across ' + repos + ' repo(s)')
if (skippedRetired > 0) log.info('[ref-reconcile] boot pass: skipped ' + skippedRetired + ' retired repo(s) — bytes left on disk for manual removal')
return { repos, published, skippedRetired }
}