OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / git-overlay-fetch.js

168 lines · javascript ref:
/**
 * git-overlay-fetch — gip-overlay P3 fetcher (the ensureObjects RefReconciler
 * was built to accept).
 *
 * To materialize a target oid the fetcher resolves the bundle CHAIN from the
 * per-repo autobase: walk oid -> baseOid (recorded by add-bundle, P3b) back to
 * genesis (ZERO_OID) OR to an oid whose objects are already present locally,
 * then fetch each delta bundle from the overlay (multi-source, hash-verified)
 * and `git bundle unbundle` them base-first into the bare repo.
 *
 * git does all the git work; the overlay just moves bundle bytes. Decoupled
 * from the swarm via the injected `overlay` (anything with a
 * `fetchFile(contentHash) -> { destPath } | null` method — HyperOverlayV2 in
 * production).
 */

import fs from '#node:fs'
import path from '#node:path'

import { execFileSync } from './_compat-exec.js'
import { applyBundle } from './git-bundle.js'
import { ZERO_OID } from 'hyper-issues'

/**
 * Drop a fetched bundle once its objects are in the bare repo.
 *
 * A bundle is a TRANSFER artifact, not state: after `unbundle` the objects live
 * in the repo, and the overlay's `destDir` is not a `syncBaseDir`, so the file is
 * never re-served from there. Keeping it leaked 17 GB on stortron — every bundle
 * applied since June, still on disk.
 *
 * Two paths must NOT be unlinked, because they are canonical bytes rather than a
 * throwaway copy:
 *   - `res.local` — hyper-overlay short-circuits a content hash it already holds
 *     and hands back where those bytes actually live (this node's own producer
 *     spool, say). Deleting that is the trap the release-puller hit.
 *   - a mirror-spool path — content-addressed and durably re-served.
 * Both are excluded by requiring the throwaway `blob-<hash>-<ts>-<rand>` name
 * that `_destDir` fetches use.
 */
export function discardFetchedBundle (res, log) {
  if (!res || !res.destPath || res.local === true) return false
  if (!path.basename(res.destPath).startsWith('blob-')) return false
  try {
    fs.unlinkSync(res.destPath)
    return true
  } catch (err) {
    // Never let cleanup break a fetch that already succeeded.
    if (log && log.warn) log.warn('bundle cleanup failed: ' + (err && err.message))
    return false
  }
}

export function hasObject (repoPath, oid, git) {
  try {
    git('git', ['--git-dir', repoPath, 'cat-file', '-e', oid + '^{object}'], { encoding: 'utf8' })
    return true
  } catch {
    return false
  }
}

/**
 * Ensure `oid`'s objects exist in the bare repo at `repoPath`, fetching and
 * applying the bundle chain as needed.
 * @param {object} o  { oid, tracker, overlay, repoPath, git?, log? }
 */
export async function materializeObject (o = {}) {
  const { oid, tracker, overlay, repoPath } = o
  const git = o.git || execFileSync
  const log = o.log || { warn () {}, info () {} }
  if (!oid || !tracker || !overlay || !repoPath) {
    throw new Error('materializeObject requires { oid, tracker, overlay, repoPath }')
  }
  if (hasObject(repoPath, oid, git)) return

  // Resolve the chain: base-first list of bundle entries to apply.
  const chain = []
  const guard = new Set()
  let cur = oid
  while (true) {
    if (hasObject(repoPath, cur, git)) break // base already present — stop
    if (guard.has(cur)) throw new Error('bundle chain cycle at ' + cur)
    guard.add(cur)
    const entry = await tracker.getBundle(cur)
    if (!entry) throw new Error('no bundle recorded for oid ' + cur)
    chain.unshift({ oid: cur, ...entry })
    if (!entry.baseOid || entry.baseOid === ZERO_OID) break // creation bundle — self-contained
    cur = entry.baseOid
  }

  // Fetch + apply base-first; each delta's prerequisite is satisfied by the
  // one before it (git bundle unbundle enforces this).
  for (const entry of chain) {
    const res = await overlay.fetchFile(entry.contentHash)
    if (!res || !res.destPath) {
      throw new Error('overlay fetch failed for bundle ' + entry.contentHash + ' (oid ' + entry.oid + ')')
    }
    await applyBundle({ repoPath, bundlePath: res.destPath, git })
    log.info('applied bundle ' + entry.contentHash + ' -> ' + entry.oid)
    discardFetchedBundle(res, log)
  }

  if (!hasObject(repoPath, oid, git)) {
    throw new Error('object ' + oid + ' still absent after applying bundle chain')
  }
}

/**
 * Build the ensureObjects(oids) callback RefReconciler expects, bound to a
 * tracker + overlay + bare repo.
 */
export function makeOverlayEnsureObjects (o = {}) {
  const { tracker, overlay, repoPath } = o
  return async function ensureObjects (oids) {
    for (const oid of oids) {
      await materializeObject({ oid, tracker, overlay, repoPath, git: o.git, log: o.log })
    }
  }
}

/**
 * ensureObjects with TWO sources, tried in order — the object-completeness
 * reconcile the RefReconciler drives on boot + every tracker `update`:
 *
 *   1. Overlay bundle chain (PREFERRED): repos published with gip/LFS-style
 *      overlay bundles resolve here (dedup + delta). Per-oid best-effort — a
 *      repo that doesn't use overlay bundles just misses here and falls through.
 *   2. Punch:// PACK pull FALLBACK: for repos whose git objects ride the
 *      separate hyper-sync PACK clone (no overlay bundle), fetch the whole repo
 *      from a peer. This is CURSOR-INDEPENDENT (unlike the reverse-puller's
 *      log replay), so it heals refs-but-no-objects for org joiners regardless
 *      of when the org was joined or whether a prior pull failed. pear-git #3.
 *
 * @param {object} o { tracker, overlay, repoPath, repoName, getSync, git?, log? }
 *   getSync() -> the hyper-sync server (lazy: it's created after the manager).
 */
export function makeReconcilingEnsureObjects (o = {}) {
  const { tracker, overlay, repoPath, repoName, getSync } = o
  const git = o.git || execFileSync
  const log = o.log || { warn () {}, info () {} }
  return async function ensureObjects (oids) {
    // 1. Overlay bundle chain (preferred), per-oid best-effort.
    for (const oid of oids) {
      if (hasObject(repoPath, oid, git)) continue
      try {
        await materializeObject({ oid, tracker, overlay, repoPath, git, log })
      } catch { /* no bundle for this oid — fall through to the PACK pull */ }
    }
    // 2. Punch:// PACK pull fallback for anything still missing.
    const stillMissing = () => oids.some((oid) => !hasObject(repoPath, oid, git))
    if (!stillMissing()) return
    const sync = typeof getSync === 'function' ? getSync() : null
    if (!sync || !sync.remotes || sync.remotes.size === 0) return
    for (const remoteName of sync.remotes.keys()) {
      try {
        await sync.pull(remoteName, repoName)
        if (!stillMissing()) {
          log.info('reconcile: pulled git objects for ' + repoName + ' from ' + remoteName)
          return
        }
      } catch (err) {
        log.warn('reconcile pull ' + repoName + ' via ' + remoteName + ' failed: ' + (err && err.message))
      }
    }
  }
}