OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / git-bundle.js

114 lines · javascript ref:
/**
 * git-bundle — gip-overlay P3 object transport (the git half).
 *
 * Objects move between nodes as `git bundle` deltas. The producer bundles the
 * objects a push added (oldOid..newOid); the fetcher unbundles them into the
 * local bare repo. `git` does all the git work on both ends (no JS git engine)
 * — these helpers just shell out via the cross-runtime exec shim.
 *
 * A delta bundle records its prerequisite (oldOid) in its header, so:
 *   - unbundling fails cleanly when the base objects are absent, which is what
 *     drives the bundle CHAIN (fetch the prerequisite's bundle first), and
 *   - `bundlePrerequisites()` lets the fetcher know what a delta needs before
 *     attempting to apply it.
 *
 * `git bundle create` requires a REF (a bare sha is "Refusing to create empty
 * bundle"), so we pin the exact newOid behind a throwaway ref — that also
 * stops a concurrent push from widening the delta to the ref's latest tip.
 */

import fs from '#node:fs'

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

function isZero (oid) { return !oid || oid === ZERO_OID }

/**
 * Bundle the objects reachable from newOid but not from oldOid.
 * @param {object} o
 *   o.repoPath  bare repo (--git-dir)
 *   o.ref       the ref being advanced (for the temp-ref label only)
 *   o.oldOid    previous oid; ZERO/null ⇒ full history from newOid
 *   o.newOid    target oid to bundle up to
 *   o.outPath   where to write the .bundle
 *   o.git       exec shim (injectable; defaults to execFileSync)
 * @returns {Promise<{ path: string } | null>}  null for an empty delta (no-op move)
 */
export async function createDeltaBundle (o = {}) {
  const { repoPath, oldOid, newOid, outPath } = o
  const git = o.git || execFileSync
  if (!repoPath) throw new Error('createDeltaBundle requires repoPath')
  if (!newOid) throw new Error('createDeltaBundle requires newOid')
  if (newOid === oldOid) return null // nothing added

  // Pin newOid behind a throwaway ref: gives `bundle create` a ref to record
  // and freezes the tip against concurrent pushes.
  const tmpRef = 'refs/gip-overlay/bundle-tmp-' + newOid
  git('git', ['--git-dir', repoPath, 'update-ref', tmpRef, newOid], { encoding: 'utf8' })
  try {
    const args = ['--git-dir', repoPath, 'bundle', 'create', outPath, tmpRef]
    if (!isZero(oldOid)) args.push('--not', oldOid)
    git('git', args, { encoding: 'utf8' })
  } finally {
    try { git('git', ['--git-dir', repoPath, 'update-ref', '-d', tmpRef], { encoding: 'utf8' }) } catch {}
  }
  return { path: outPath }
}

/**
 * Unbundle a bundle's objects into a bare repo. Throws (message carries git's
 * stderr) if a delta's prerequisite objects are absent — the caller resolves
 * the chain and retries.
 * @returns {Promise<{ tips: Array<{ oid, ref }> }>}
 */
export async function applyBundle (o = {}) {
  const { repoPath, bundlePath } = o
  const git = o.git || execFileSync
  if (!repoPath) throw new Error('applyBundle requires repoPath')
  if (!bundlePath) throw new Error('applyBundle requires bundlePath')
  let out
  try {
    out = git('git', ['--git-dir', repoPath, 'bundle', 'unbundle', bundlePath], { encoding: 'utf8' }).toString()
  } catch (err) {
    const detail = (err.stderr ? err.stderr.toString() : '') || err.message
    const e = new Error('bundle unbundle failed: ' + detail.trim())
    e.cause = err
    throw e
  }
  // unbundle prints "<oid> <ref>" per tip
  const tips = []
  for (const line of out.split('\n')) {
    const m = line.trim().match(/^([0-9a-f]{40,64})\s+(\S+)/)
    if (m) tips.push({ oid: m[1], ref: m[2] })
  }
  return { tips }
}

/**
 * Read the prerequisite oids a bundle requires (the `-<sha>` header lines).
 * Empty array for a self-contained (creation) bundle.
 * @returns {string[]}
 */
export function bundlePrerequisites (o = {}) {
  const { bundlePath } = o
  if (!bundlePath) throw new Error('bundlePrerequisites requires bundlePath')
  const fd = fs.openSync(bundlePath, 'r')
  try {
    const buf = Buffer.alloc(65536)
    const n = fs.readSync(fd, buf, 0, buf.length, 0)
    const text = buf.subarray(0, n).toString('latin1')
    const prereqs = []
    for (const line of text.split('\n')) {
      if (line === '') break        // blank line ends the header (PACK follows)
      if (line[0] === '-') {
        const m = line.slice(1).match(/^([0-9a-f]{40,64})\b/)
        if (m) prereqs.push(m[1])
      }
    }
    return prereqs
  } finally {
    fs.closeSync(fd)
  }
}