/**
* 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))
}
}
}
}