OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / services / ref-publish-reconciler.js

173 lines · javascript ref:
/**
 * 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 }
}