OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / scripts / keep-latest-only.js

191 lines · javascript ref:
#!/usr/bin/env node
/**
 * keep-latest-only — aggressive deploy-side cleanup.
 *
 * For each app in the published-index, identify the LATEST version
 * (and any pinned versions) and compute the set of tree hashes those
 * versions need. Rewrite refs.json to reference only those hashes.
 * Subsequent `pgh store gc --max-age 0` drops the rest.
 *
 * Rationale:
 *   - `pgh app install <name>` always fetches the latest entry
 *   - rollback uses LOCAL hardlinks, not deploy-server fetches
 *   - holding 33 versions of a 1 GB tree is the v1 bloat pattern
 *     reborn
 *
 * SAFETY:
 *   - Backs up refs.json before rewriting
 *   - Does NOT modify the published-index (versions stay listed even
 *     if their trees get gc'd later — the index is the source of
 *     truth, gc is purely about disk reclamation)
 *   - Does NOT touch any identity / corestore / app install dir
 *   - Idempotent
 *
 * Usage:
 *   node scripts/keep-latest-only.js [--dry-run] [--keep-pinned]
 *                                    [--store-root <path>] [--index <path>]
 *
 *   --keep-pinned         also keep any version marked pinned (default: true)
 *   --no-pinned           drop pinned versions too (most aggressive)
 *
 * Recommended flow:
 *   1. node scripts/keep-latest-only.js --dry-run
 *   2. node scripts/keep-latest-only.js
 *   3. node bin/pgh store gc --max-age 0 --dry-run
 *   4. node bin/pgh store gc --max-age 0
 *   5. restart pgh-sidecar
 */

import fs from 'fs'
import path from 'path'
import { homedir } from 'os'

function flag (name) {
  const i = process.argv.indexOf(name)
  return i === -1 ? undefined : process.argv[i + 1]
}
function bool (name) { return process.argv.includes(name) }

const dryRun = bool('--dry-run')
const dropPinned = bool('--no-pinned')
const STORE = flag('--store-root') || process.env.PGH_STORE_ROOT ||
  path.join(homedir(), '.local', 'share', 'store')
const INDEX = flag('--index') ||
  path.join(homedir(), '.config', 'pgh', 'published-index.json')
const REFS = path.join(STORE, 'refs.json')
const DEPS = path.join(STORE, 'dependencies')

if (!fs.existsSync(INDEX)) {
  console.error('no index at ' + INDEX)
  process.exit(1)
}
if (!fs.existsSync(REFS)) {
  console.error('no refs.json at ' + REFS)
  process.exit(1)
}

const index = JSON.parse(fs.readFileSync(INDEX, 'utf8'))
const before = JSON.parse(fs.readFileSync(REFS, 'utf8'))

console.log('=== keep-latest-only ===')
console.log('  store:     ' + STORE)
console.log('  index:     ' + INDEX)
console.log('  dryRun:    ' + dryRun)
console.log('  keepPinned:' + (!dropPinned))
console.log('')

// Build the kept set: latest version of each app + (optionally) pinned versions
const kept = new Map() // hash -> Set(appNames)
let appsConsidered = 0

function addHash (hash, appName) {
  if (!hash) return
  if (!kept.has(hash)) kept.set(hash, new Set())
  kept.get(hash).add(appName)
}

function addVersionTrees (appName, v) {
  if (!v) return
  addHash(v.rootTreeHash, appName)
  const lockfile = v.lockfile || {}
  for (const dep of lockfile.deps || []) {
    addHash(dep.tree, appName)
    if (dep.prebuilds && typeof dep.prebuilds === 'object') {
      for (const archHash of Object.values(dep.prebuilds)) {
        addHash(archHash, appName)
      }
    }
  }
}

for (const [appName, info] of Object.entries(index.apps || {})) {
  appsConsidered++
  const versions = (info.versions || []).slice().sort((a, b) => {
    const ta = Date.parse(a.publishedAt || '') || 0
    const tb = Date.parse(b.publishedAt || '') || 0
    return tb - ta // newest first
  })

  if (versions.length === 0) continue

  // Always keep the latest version
  addVersionTrees(appName, versions[0])

  // Optionally keep pinned versions for explicit rollback runway
  if (!dropPinned) {
    for (const v of versions) {
      if (v.pinned && v !== versions[0]) addVersionTrees(appName, v)
    }
  }
}

console.log('considered apps:           ' + appsConsidered)
console.log('hashes in kept set:        ' + kept.size)
console.log('hashes in current refs:    ' + Object.keys(before).length)
console.log('hashes on disk:            ' +
  (fs.existsSync(DEPS) ? fs.readdirSync(DEPS).length : 0))

// Compute disk-byte impact
let bytesKept = 0
let bytesDropped = 0
let countDropped = 0
if (fs.existsSync(DEPS)) {
  for (const hash of fs.readdirSync(DEPS)) {
    let bytes = 0
    try {
      bytes = dirSize(path.join(DEPS, hash))
    } catch {}
    if (kept.has(hash)) bytesKept += bytes
    else { bytesDropped += bytes; countDropped++ }
  }
}

console.log('')
console.log('  total kept:    ' + fmtBytes(bytesKept))
console.log('  total dropped: ' + fmtBytes(bytesDropped) + '  (' + countDropped + ' trees)')

if (dryRun) {
  console.log('')
  console.log('  dry-run: no changes written.')
  process.exit(0)
}

const backup = REFS + '.backup-' + new Date().toISOString().replace(/[:.]/g, '-')
fs.copyFileSync(REFS, backup)
console.log('  backup: ' + backup)

const newRefs = {}
for (const [hash, apps] of kept.entries()) {
  newRefs[hash] = [...apps]
}
fs.writeFileSync(REFS, JSON.stringify(newRefs, null, 2))
console.log('  wrote refs.json (' + kept.size + ' entries)')
console.log('')
console.log('  next: pgh store gc --max-age 0   to actually free the bytes')

// ── helpers ──────────────────────────────────────────────────

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 dirSize (dir) {
  let total = 0
  function walk (p) {
    let stat
    try { stat = fs.lstatSync(p) } catch { return }
    if (stat.isSymbolicLink()) return
    if (stat.isFile()) { total += stat.size; return }
    if (stat.isDirectory()) {
      let entries
      try { entries = fs.readdirSync(p) } catch { return }
      for (const e of entries) walk(path.join(p, e))
    }
  }
  walk(dir)
  return total
}