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