OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / commands / overlay.js

427 lines · javascript ref:
/**
 * pgh overlay — git-LFS-style large-asset routing built on hyper-overlay.
 *
 * Files live on disk in working trees. Sidecar holds a per-repo FileIndex
 * (path → oid metadata) and serves files by oid over a Protomux channel
 * (`pgh-overlay/v1`) on the publisher's overlay topic. No content-hash
 * storage anywhere — this CLI talks to sidecar via UDS for everything.
 *
 * Subcommands:
 *   pgh overlay init [--peer=<serverKey>]
 *       Writes .pear-git/overlay.json (peer topic + repo name).
 *       Registers the `hyper-overlay` git filter (clean/smudge → pgh
 *       overlay clean/smudge) with required=true so checkout fails loud
 *       if pgh isn't installed. Tells sidecar the repo's working-tree
 *       absolute path so it can serve files.
 *
 *   pgh overlay track <pattern>...
 *       Appends one filter line per pattern to .gitattributes (creates
 *       file if needed). E.g. `pgh overlay track '*.res' 'assets/**'`.
 *
 *   pgh overlay sync
 *       Walks pointer-shaped files in the working tree. For each, asks
 *       sidecar to fetch bytes by oid (locally if registered, else via
 *       overlay protocol from the peer in overlay.json). Replaces
 *       pointer file with bytes. Fails loud on no-peer or bad-oid.
 *
 *   pgh overlay clean <path>      (stdin → stdout, called by git filter)
 *       Reads stdin (file bytes), asks sidecar to register {repo, path}
 *       at oid, writes pointer text to stdout.
 *
 *   pgh overlay smudge <path>     (stdin → stdout, called by git filter)
 *       Reads stdin (pointer text), asks sidecar for bytes, writes
 *       bytes to stdout. On EOVERLAYNOPEER, errors out (no silent
 *       pass-through — design locked 2026-05-06).
 *
 *   pgh overlay status
 *       Prints sidecar overlay status + this repo's known files.
 *
 * Config files (in repo, tracked in git):
 *   .pear-git/overlay.json   { repo, peerServerKey, version: 1 }
 *   .gitattributes            filter declarations per pattern
 */

import process from '#node:process'
import fs from '#node:fs'
import path from '#node:path'
import os, { homedir } from '#node:os'
import { execSync } from '../_compat-exec.js'
import { pipeline } from '#node:stream/promises'
import { Transform } from '#node:stream'
import { Client } from 'hyper-svc'
import { isAbstract } from '../daemon-client.js'

// Hash + chunk + pointer primitives are owned by hyper-overlay (canonical
// source). Importing keeps pgh's clean/smudge wire-compatible with every
// other hyper-overlay consumer (sidecar, future tools, web UI).
import {
  createStreamingHasher,
  chunkStream,
  selectTier
} from 'hyper-overlay/chunker'
import {
  serialize as serializePointer,
  parse as parsePointer,
  isPointer,
  INLINE_MAX
} from 'hyper-overlay/pointer'

const OVERLAY_JSON = '.pear-git/overlay.json'
const SIDECAR_SOCK = process.env.PGH_SIDECAR_SOCK || path.join(homedir(), '.pgh', 'svc.sock')

async function sidecarClient () {
  // overlay subcommands talk to pgh-sidecar (not pear-git-v3), so they
  // open their own UDS client. Dispatcher's pure-local routing passes
  // null for `client` since the receiver is fixed.
  if (!isAbstract(SIDECAR_SOCK) && !fs.existsSync(SIDECAR_SOCK)) {
    throw new Error('pgh-sidecar not running (no socket at ' + SIDECAR_SOCK + ')')
  }
  return Client.connectUds(SIDECAR_SOCK)
}

function repoRoot (cwd) {
  let dir = path.resolve(cwd || process.cwd())
  while (true) {
    if (fs.existsSync(path.join(dir, '.git'))) return dir
    const parent = path.dirname(dir)
    if (parent === dir) return path.resolve(cwd || process.cwd())
    dir = parent
  }
}

function readOverlayJson (root) {
  const fp = path.join(root, OVERLAY_JSON)
  if (!fs.existsSync(fp)) return null
  return JSON.parse(fs.readFileSync(fp, 'utf8'))
}

function writeOverlayJson (root, data) {
  const fp = path.join(root, OVERLAY_JSON)
  fs.mkdirSync(path.dirname(fp), { recursive: true })
  fs.writeFileSync(fp, JSON.stringify(data, null, 2) + '\n')
}

function appendGitattributes (root, pattern) {
  const fp = path.join(root, '.gitattributes')
  let text = ''
  try { text = fs.readFileSync(fp, 'utf8') } catch {}
  const line = `${pattern} filter=hyper-overlay diff=hyper-overlay merge=hyper-overlay -text`
  const lines = text.split('\n')
  if (lines.some(l => l.trim() === line)) return false
  if (text.length && !text.endsWith('\n')) text += '\n'
  text += line + '\n'
  fs.writeFileSync(fp, text)
  return true
}

function registerGitFilter (root) {
  // Per-repo .git/config so the filter is scoped to this repo (not user-wide).
  // required=true — git fails loudly when pgh isn't on PATH on the
  // checkout machine. Explicit choice from the design lock 2026-05-06.
  execSync('git config filter.hyper-overlay.clean "pgh overlay clean %f"', { cwd: root })
  execSync('git config filter.hyper-overlay.smudge "pgh overlay smudge %f"', { cwd: root })
  execSync('git config filter.hyper-overlay.required true', { cwd: root })
}

async function readStdin () {
  // Used only for SMALL stdin (pointer text). Use streamStdinHashed
  // for arbitrary-size input.
  return new Promise((resolve, reject) => {
    const chunks = []
    process.stdin.on('data', c => chunks.push(c))
    process.stdin.on('end', () => resolve(Buffer.concat(chunks)))
    process.stdin.on('error', reject)
  })
}

// ── Subcommands ───────────────────────────────────────────────

async function cmdInit (_, args) {
  const peerIdx = args.findIndex(a => a.startsWith('--peer='))
  const peerArg = peerIdx >= 0 ? args[peerIdx].slice('--peer='.length) : null

  const root = repoRoot()
  if (!fs.existsSync(path.join(root, '.git'))) {
    throw new Error('not a git repo (no .git/ found in or above ' + process.cwd() + ')')
  }

  const repoName = path.basename(root)
  const client = await sidecarClient()
  try {
    let peerServerKey = peerArg
    if (!peerServerKey) {
      const info = await client.call('overlay-info', { repo: repoName })
      peerServerKey = info.serverKey
    }

    writeOverlayJson(root, {
      repo: repoName,
      peerServerKey,
      version: 1
    })

    registerGitFilter(root)

    await client.call('overlay-init', { repo: repoName, repoRoot: root })

    return { repo: repoName, root, peerServerKey, configured: '.pear-git/overlay.json + .gitattributes filter + .git/config' }
  } finally {
    try { await client.close() } catch {}
  }
}

async function cmdTrack (_, args) {
  const root = repoRoot()
  if (args.length === 0) throw new Error('usage: pgh overlay track <pattern>...')
  const added = []
  const skipped = []
  for (const pattern of args) {
    if (appendGitattributes(root, pattern)) added.push(pattern)
    else skipped.push(pattern)
  }
  return { added, alreadyTracked: skipped, file: '.gitattributes' }
}

async function cmdClean (_, args) {
  // Two-pass streaming clean — works for arbitrarily large files.
  //   Pass 0: stdin → tmpfile (size unknown until EOF).
  //   Pass 1: tmpfile → chunkStream → both file-level hash AND per-chunk metadata.
  // The file-level hash uses createStreamingHasher({ size }) which prepends
  // LEAF_TYPE || uint64-LE(size) — matches `hashChunk(file)` exactly, the
  // canonical hyper-overlay oid form. Memory footprint ≈ 2 × tier maxSize
  // (a few MB worst case at tier 3), regardless of file size.
  const filePath = args[0] || '<unknown>'
  const root = repoRoot()
  const overlay = readOverlayJson(root)
  if (!overlay) throw new Error('overlay not initialized in this repo (no .pear-git/overlay.json)')

  const tmpDir = path.join(os.tmpdir(), 'pgh-overlay')
  fs.mkdirSync(tmpDir, { recursive: true })
  const tmpFile = path.join(tmpDir,
    'clean-' + process.pid + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10))

  try {
    // Pass 0: stdin → tmpfile
    const writer = fs.createWriteStream(tmpFile)
    // eslint-disable-next-line no-undef
    await pipeline(process.stdin, writer)
    const size = fs.statSync(tmpFile).size
    const tier = selectTier(size)

    // Pass 1: chunk + hash in one read.
    // chunkStream yields { hash, offset, length, data } — keep all four;
    // sidecar stores {hash, offset, length} (FileIndex chunkmap schema),
    // pointer projects to {hash, size: length} (upstream pointer schema).
    const hasher = createStreamingHasher({ size })
    const chunkmap = []
    const reader = fs.createReadStream(tmpFile)
    for await (const c of chunkStream(reader, { tier })) {
      hasher.update(c.data)
      chunkmap.push({ hash: c.hash, offset: c.offset, length: c.length })
    }
    const oid = hasher.digest()
    const chunkCount = chunkmap.length

    // Sidecar's overlay-put-file accepts any chunkmap size — the chunked
    // metadata persists on FileIndex's chunkmap:<path> regardless of count.
    // Past versions threw for chunkCount > INLINE_MAX assuming the manifest
    // path was needed, but for our flow the smudge/sync side fetches by
    // oid (overlay-locate) — the inline chunk list in the pointer is just
    // an optimization for chunk-by-hash dedup readers, not a fetch path.
    const client = await sidecarClient()
    try {
      await client.call('overlay-put-file', {
        repo: overlay.repo,
        path: filePath,
        oid,
        size,
        mtime: Date.now(),
        chunkmap
      })
    } finally {
      try { await client.close() } catch {}
    }

    // Pointer omits inline chunks when chunkCount > INLINE_MAX —
    // serializePointer's `if (chunks && chunkCount <= INLINE_MAX)` branch
    // skips the per-chunk lines, leaving just header + oid + size + chunks
    // count. Readers fetch by oid; chunks line is metadata-only.
    const pointerChunks = chunkCount <= INLINE_MAX
      ? chunkmap.map(c => ({ hash: c.hash, size: c.length }))
      : null
    process.stdout.write(serializePointer({ oid, size, chunkCount, chunks: pointerChunks }))
  } finally {
    try { fs.unlinkSync(tmpFile) } catch {}
  }
}

// Stream bytes for a pointer's oid into a writable.
//
// Single UDS round-trip: `overlay-locate` with `ensure: true` makes the
// sidecar (a) serve the local file if present, or (b) join the peer's
// overlay topic + auto-sync the file to disk via OverlayProtocol,
// then return the absolute path. CLI streams from that path with
// LEAF_TYPE-prefixed hash verification — works for files of any size
// (no UDS bytes path, no Buffer/base64 ceiling).
async function streamBytesForOid (client, peerServerKey, oid, size, dst) {
  const located = await client.call('overlay-locate', {
    oid,
    peer: peerServerKey,
    ensure: true
  })
  if (!located || !located.found || !located.absPath) {
    throw new Error('EOVERLAYNOTFOUND: overlay-locate returned no path for ' + oid.slice(0, 12) + '…')
  }
  const reader = fs.createReadStream(located.absPath)
  const hasher = createStreamingHasher({ size })
  let bytesSeen = 0
  const tap = new Transform({
    transform (chunk, _enc, cb) {
      hasher.update(chunk)
      bytesSeen += chunk.length
      this.push(chunk)
      cb()
    }
  })
  await pipeline(reader, tap, dst)
  const seenOid = hasher.digest()
  if (seenOid !== oid) {
    throw new Error('EOVERLAYBADOID: file at ' + located.absPath +
      ' hashed to ' + seenOid.slice(0, 12) + '… expected ' + oid.slice(0, 12) + '…')
  }
  return { source: located.source || 'unknown', bytes: bytesSeen }
}

async function cmdSmudge (_, args) {
  const filePath = args[0] || '<unknown>'
  const root = repoRoot()
  const overlay = readOverlayJson(root)
  if (!overlay) throw new Error('overlay not initialized in this repo (no .pear-git/overlay.json)')

  const text = (await readStdin()).toString('utf8')
  if (!isPointer(text)) {
    process.stdout.write(text)
    return
  }

  const { oid, size } = parsePointer(text)

  const client = await sidecarClient()
  try {
    await streamBytesForOid(client, overlay.peerServerKey, oid, size, process.stdout)
  } finally {
    try { await client.close() } catch {}
  }
}

async function cmdSync (_, args) {
  const root = repoRoot()
  const overlay = readOverlayJson(root)
  if (!overlay) throw new Error('overlay not initialized in this repo (no .pear-git/overlay.json)')

  let fetched = 0
  let errors = 0

  const client = await sidecarClient()
  try {
    async function walk (dir) {
      const entries = fs.readdirSync(dir, { withFileTypes: true })
      for (const e of entries) {
        if (e.name === '.git' || e.name === '.pear-git' || e.name === 'node_modules') continue
        const full = path.join(dir, e.name)
        if (e.isDirectory()) await walk(full)
        else if (e.isFile()) {
          let head
          try {
            const fd = fs.openSync(full, 'r')
            const buf = Buffer.alloc(256)
            const n = fs.readSync(fd, buf, 0, 256, 0)
            fs.closeSync(fd)
            head = buf.slice(0, n).toString('utf8')
          } catch { continue }
          if (!isPointer(head)) continue
          const text = fs.readFileSync(full, 'utf8')
          try {
            const { oid, size } = parsePointer(text)
            // Stream from sidecar (or local disk via overlay-locate) into a
            // .partial sibling, atomic-rename on success. Never buffers
            // the whole file in memory. Partial cleanup on error.
            const partial = full + '.overlay-partial'
            const writer = fs.createWriteStream(partial)
            try {
              const r = await streamBytesForOid(
                client, overlay.peerServerKey, oid, size, writer
              )
              if (r.bytes !== size) throw new Error('size mismatch (got ' + r.bytes + ', expected ' + size + ')')
            } catch (err) {
              try { fs.unlinkSync(partial) } catch {}
              throw err
            }
            fs.renameSync(partial, full)
            fetched++
            console.log('fetched: ' + path.relative(root, full) + '  ' + size + ' bytes')
          } catch (err) {
            console.error('failed: ' + path.relative(root, full) + '  ' + err.message)
            errors++
          }
        }
      }
    }
    await walk(root)
  } finally {
    try { await client.close() } catch {}
  }
  console.log(`overlay sync: ${fetched} fetched, ${errors} errors`)
  if (errors > 0) {
    const e = new Error(`${errors} pointer file(s) failed to sync — see errors above`)
    e.code = 'EOVERLAYSYNC'
    throw e
  }
  return { fetched, errors }
}

async function cmdStatus (_, args) {
  const root = repoRoot()
  const overlay = readOverlayJson(root)
  const client = await sidecarClient()
  try {
    const sidecarStatus = await client.call('overlay-status', {})
    const result = { sidecar: sidecarStatus, repo: null }
    if (overlay) {
      const list = await client.call('overlay-list-files', { repo: overlay.repo })
      result.repo = {
        name: overlay.repo,
        peerServerKey: overlay.peerServerKey,
        fileCount: list.files ? list.files.length : 0
      }
    }
    return result
  } finally {
    try { await client.close() } catch {}
  }
}

export const usage = `pgh overlay — git-LFS-style large-asset routing (hyper-overlay)

  pgh overlay init [--peer=<serverKey>]    Set up the overlay git filter
  pgh overlay track <pattern>...           Track paths via the overlay filter
  pgh overlay sync                          Push/pull tracked files
  pgh overlay status                        Show tracked files + state
  pgh overlay clean <path>                  (git filter hook; stdin→stdout)
  pgh overlay smudge <path>                 (git filter hook; stdin→stdout)`

export async function run (client, args) {
  const sub = args[0]
  const subArgs = args.slice(1)
  switch (sub) {
    case 'init': return cmdInit(client, subArgs)
    case 'track': return cmdTrack(client, subArgs)
    case 'clean': return cmdClean(client, subArgs)
    case 'smudge': return cmdSmudge(client, subArgs)
    case 'sync': return cmdSync(client, subArgs)
    case 'status': return cmdStatus(client, subArgs)
    default:
      throw new Error('unknown overlay subcommand: ' + sub + '\n' + usage)
  }
}