/** pgh store list|size|gc|pin|unpin — manage the tree store */
import { TreeStore } from '../store.js'
import { withRegistry } from './releases.js'
// Default age-gate for `pgh store gc`. 14 days matches the retention
// policy adopted on the retention-policy branch — short enough to keep
// the store small, long enough that a publish that just landed but
// hasn't been linked into an app yet survives. Override per-invocation
// via --max-age <days>.
const DEFAULT_GC_MAX_AGE_DAYS = 14
export const usage = `pgh store — manage the content-addressed tree store
pgh store list Enumerate every tree in the store
pgh store size Total bytes + tree counts
pgh store gc [--dry-run] [--max-age <d>] Remove unreferenced, unpinned trees;
--max-age <d> grace period in days
(default: ${DEFAULT_GC_MAX_AGE_DAYS}). 0 = no grace.
[--keep-releases <n>] Also keep each app's newest n releases
and drop trees only older releases use,
whatever their age.
pgh store pin <hash> Mark tree as ungcable
pgh store unpin <hash> Remove pin
pgh store show <hash> Show details for one tree
pgh store verify Audit every blob; report any whose
bytes no longer match their content-hash
pgh store repair [--dry-run] Quarantine corrupt blobs + invalidate
the trees that hardlink them`
function fmtBytes (n) {
if (n < 1024) return n + ' B'
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'
if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB'
return (n / 1073741824).toFixed(2) + ' GB'
}
function flagValue (args, name) {
const idx = args.indexOf(name)
if (idx === -1) return undefined
return args[idx + 1]
}
export async function run (client, args) {
const store = new TreeStore()
const sub = args[0]
if (!sub || sub === 'help') {
return usage
}
if (sub === 'size' || sub === 'stats') {
const s = store.stats()
return {
treeCount: s.treeCount,
referencedCount: s.referencedCount,
unreferencedCount: s.unreferencedCount,
pinnedCount: s.pinnedCount,
totalBytes: s.totalBytes,
totalSize: fmtBytes(s.totalBytes)
}
}
if (sub === 'list' || sub === 'ls') {
return store.listTrees().map(t => ({
hash: t.hash.slice(0, 12),
size: fmtBytes(t.size),
refs: t.refs.length,
refList: t.refs.join(','),
pinned: t.pinned
}))
}
if (sub === 'show') {
const hash = args[1]
if (!hash) throw new Error('usage: pgh store show <hash>')
const match = store.listTrees().find(t => t.hash === hash || t.hash.startsWith(hash))
if (!match) throw new Error('no tree matching hash prefix: ' + hash)
return {
hash: match.hash,
size: fmtBytes(match.size),
sizeBytes: match.size,
refs: match.refs,
pinned: match.pinned,
meta: match.meta,
path: store.treePath(match.hash)
}
}
if (sub === 'gc') {
const dryRun = args.includes('--dry-run')
const ageRaw = flagValue(args, '--max-age')
let maxAgeDays = DEFAULT_GC_MAX_AGE_DAYS
if (ageRaw !== undefined) {
const parsed = parseInt(ageRaw, 10)
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error('--max-age requires a non-negative integer (days)')
}
maxAgeDays = parsed
}
const keepRaw = flagValue(args, '--keep-releases')
let keepReleases
let releases
if (keepRaw !== undefined) {
keepReleases = parseInt(keepRaw, 10)
if (!Number.isFinite(keepReleases) || keepReleases < 1) {
throw new Error('--keep-releases requires a positive integer')
}
// With the sidecar up, IT runs the gc: it holds the registry, and the
// full release list (every entry's dep closure) overflows the UDS frame.
const remote = await withRegistry(async (reg) => {
if (typeof reg.storeGc === 'function') return reg.storeGc({ dryRun, keepReleases })
releases = await reg.list()
return null
})
if (remote) {
return {
candidates: remote.candidates.length,
removed: remote.removed.length,
kept: remote.kept,
blobsRemoved: remote.blobsRemoved,
maxAgeDays: 14,
keepReleases,
dryRun,
by: 'pgh-sidecar',
removedHashes: dryRun ? remote.candidates : remote.removed
}
}
}
const result = store.gc({ dryRun, maxAgeDays, keepReleases, releases })
return {
candidates: result.candidates.length,
removed: result.removed.length,
kept: result.kept.length,
maxAgeDays,
keepReleases: keepReleases || null,
dryRun,
removedHashes: dryRun ? result.candidates : result.removed
}
}
if (sub === 'pin') {
const hash = args[1]
if (!hash) throw new Error('usage: pgh store pin <hash>')
const match = store.listTrees().find(t => t.hash === hash || t.hash.startsWith(hash))
if (!match) throw new Error('no tree matching hash prefix: ' + hash)
store.pin(match.hash)
return { pinned: match.hash }
}
if (sub === 'unpin') {
const hash = args[1]
if (!hash) throw new Error('usage: pgh store unpin <hash>')
const match = store.listTrees().find(t => t.hash === hash || t.hash.startsWith(hash))
if (!match) throw new Error('no tree matching hash prefix: ' + hash)
store.unpin(match.hash)
return { unpinned: match.hash }
}
if (sub === 'verify') {
const { checked, corrupt } = store.verifyBlobs()
return {
checked,
corrupt: corrupt.length,
ok: corrupt.length === 0,
corruptBlobs: corrupt.map(c => ({
expected: c.expected.slice(0, 16) + '…',
actual: c.actual.slice(0, 16) + '…',
path: c.path
}))
}
}
if (sub === 'repair') {
const dryRun = args.includes('--dry-run')
const result = store.repair({ dryRun })
return {
checked: result.checked,
corruptBlobs: result.corruptBlobs.length,
removedBlobs: result.removedBlobs.length,
invalidatedTrees: result.invalidatedTrees.length,
invalidatedHashes: result.invalidatedTrees,
dryRun: result.dryRun,
note: result.invalidatedTrees.length
? 'Invalidated trees must be re-fetched from a clean peer or re-published (their correct bytes are gone from this machine).'
: 'Store is consistent — nothing to repair.'
}
}
throw new Error('unknown store subcommand: ' + sub)
}