feat(snapshot): gcSnapshots + ocp update --rollback --gc + auto-GC; v3.16.0 (#94)

Adds snapshot garbage collection with retention policy: keep last 5,
keep snapshots within 30 days, always keep the most recent. Configurable
via keepCount / keepDays opts.

Wire-up:
- ocp update --rollback --gc (manual trigger; --dry-run supported)
- runFullUpgrade auto-GC after successful upgrade (best-effort,
  swallows errors)

4 unit tests: keepCount enforcement, keepDays override, never-delete-
most-recent safety, dry-run mode.

Bumps to v3.16.0 (bundles PR #93 --check oauth + this GC feature).

No cli.js citation needed: this is OCP-internal snapshot lifecycle with
no corresponding cli.js operation.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dtzp555-max
2026-05-11 07:33:13 +10:00
committed by GitHub
co-authored by taodeng Claude Sonnet 4.6
parent a8601a6d30
commit 7a69d72886
6 changed files with 171 additions and 6 deletions
+64 -1
View File
@@ -1,4 +1,4 @@
import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync } from "node:fs";
import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync, rmSync } from "node:fs";
import { join } from "node:path";
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) {
@@ -50,3 +50,66 @@ export function listSnapshots(homeDir) {
.map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs }))
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Garbage-collect old upgrade snapshots.
*
* Retention rule (a snapshot is KEPT if any of these is true):
* - It is among the last `keepCount` snapshots (sorted oldest→newest)
* - Its timestamp is within `keepDays` of `now`
* - It is the single most-recent snapshot (always-keep safety net)
*
* @param {string} homeDir - Root containing ~/.ocp/
* @param {object} opts
* @param {number} [opts.keepCount=5] - Minimum count to keep
* @param {number} [opts.keepDays=30] - Keep snapshots newer than N days
* @param {boolean} [opts.dryRun=false] - If true, report plan but don't delete
* @param {Date} [opts.now=new Date()] - Override clock for testing
* @returns {{kept: Array, removed: Array, dryRun: boolean}}
*/
export function gcSnapshots(homeDir, opts = {}) {
const keepCount = opts.keepCount ?? 5;
const keepDays = opts.keepDays ?? 30;
const dryRun = !!opts.dryRun;
const now = opts.now || new Date();
const all = listSnapshots(homeDir); // sorted oldest→newest
if (all.length === 0) return { kept: [], removed: [], dryRun };
if (all.length === 1) return { kept: all, removed: [], dryRun }; // always keep most recent
const cutoffMs = now.getTime() - keepDays * 24 * 60 * 60 * 1000;
const lastN = new Set(all.slice(-keepCount).map(s => s.path));
const kept = [], removed = [];
for (let i = 0; i < all.length; i++) {
const s = all[i];
const isMostRecent = i === all.length - 1;
const isInLastN = lastN.has(s.path);
const isWithinDays = parseSnapshotTimestamp(s.name) >= cutoffMs;
if (isMostRecent || isInLastN || isWithinDays) {
kept.push(s);
} else {
removed.push(s);
}
}
if (!dryRun) {
for (const s of removed) {
try {
rmSync(s.path, { recursive: true, force: true });
} catch (err) {
console.error(`[snapshot] warn: could not remove ${s.path} (${err.code || err.message})`);
}
}
}
return { kept, removed, dryRun };
}
function parseSnapshotTimestamp(name) {
// upgrade-snapshot-2026-05-11T08:30:00Z → epoch ms
const m = name.match(/upgrade-snapshot-(.+)$/);
if (!m) return 0;
const t = Date.parse(m[1]);
return Number.isFinite(t) ? t : 0;
}