> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
Tras un rollback las generaciones más nuevas quedan inalcanzables (no hay redo).
prune(state, keep) borra esas huérfanas (la cadena viva = current+ancestros vía
parent siempre se conserva); --keep N recorta además la profundidad de rollback.
live_chain() expuesto. 2 tests nuevos + paso prune en upgrade-e2e-test.sh (3->1
gens, árbol vivo intacto).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index 03f93ddd..c6e31cbf 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -277,6 +277,13 @@ enum UpgradeCmd {
Rollback,
/// Muestra la generación viva y el historial.
Status,
+ /// Borra generaciones que el rollback ya no alcanza (huérfanas tras un rollback). Recupera espacio.
+ Prune {
+ /// Recorta también la cadena viva a sus N generaciones más nuevas (limita la profundidad de
+ /// rollback). Sin flag, conserva toda la cadena y borra sólo las huérfanas.
+ #[arg(long)]
+ keep: Option<usize>,
+ },
}
#[derive(Subcommand)]
@@ -871,6 +878,11 @@ fn main() -> anyhow::Result<()> {
println!(" {mark} {} → {} ({} ficheros)", g.id, g.tree_dir, g.files.len());
}
}
+ UpgradeCmd::Prune { keep } => {
+ let r = hammer_upgrade::prune(&state_root, keep)?;
+ println!("✓ prune: {} borradas, {} conservadas", r.removed.len(), r.kept.len());
+ for id in &r.removed { println!(" - {id}"); }
+ }
}
}
}
diff --git a/crates/hammer-upgrade/src/lib.rs b/crates/hammer-upgrade/src/lib.rs
index 030096a3..10092880 100644
--- a/crates/hammer-upgrade/src/lib.rs
+++ b/crates/hammer-upgrade/src/lib.rs
@@ -188,6 +188,57 @@ fn next_id(state_root: &Path) -> Result<u64> {
Ok(list(state_root)?.iter().map(|m| m.id).max().map_or(1, |m| m + 1))
}
+/// La **cadena viva**: la generación actual seguida de sus ancestros vía `parent`, de la más nueva a
+/// la más vieja. Son las únicas generaciones que el `rollback` puede necesitar (no hay "redo": tras un
+/// rollback las generaciones más nuevas quedan **huérfanas**). Vacía si no hay `current`.
+pub fn live_chain(state_root: &Path) -> Result<Vec<u64>> {
+ let mut chain = Vec::new();
+ let mut cur = current(state_root)?;
+ let mut guard = 0usize; // anti-ciclo defensivo (un manifiesto manipulado no debe colgar el proceso)
+ while let Some(id) = cur {
+ if chain.contains(&id) || guard > 100_000 {
+ break;
+ }
+ chain.push(id);
+ guard += 1;
+ cur = get(state_root, id)?.and_then(|m| m.parent);
+ }
+ Ok(chain)
+}
+
+/// Reporte de un [`prune`].
+#[derive(Debug, Clone, Default)]
+pub struct PruneReport {
+ pub removed: Vec<u64>,
+ pub kept: Vec<u64>,
+}
+
+/// Recupera espacio borrando generaciones (manifiesto + `backup/`) que el rollback ya no puede
+/// alcanzar. **Siempre** se conservan las de la [cadena viva](live_chain); el resto (huérfanas tras un
+/// rollback) se borran. Si `keep` es `Some(n)`, además se **recorta la cadena** a sus `n` generaciones
+/// más nuevas — esto limita la profundidad de rollback disponible (como `delete-generations` en NixOS),
+/// así que `None` (conservar toda la cadena) es lo seguro por defecto. Idempotente.
+pub fn prune(state_root: &Path, keep: Option<usize>) -> Result<PruneReport> {
+ let chain = live_chain(state_root)?;
+ let keep_set: std::collections::BTreeSet<u64> = match keep {
+ Some(n) => chain.iter().take(n).copied().collect(),
+ None => chain.iter().copied().collect(),
+ };
+ let mut report = PruneReport::default();
+ for m in list(state_root)? {
+ if keep_set.contains(&m.id) {
+ report.kept.push(m.id);
+ continue;
+ }
+ let gen_base = generations_dir(state_root).join(m.id.to_string());
+ std::fs::remove_dir_all(&gen_base)?;
+ report.removed.push(m.id);
+ }
+ report.removed.sort_unstable();
+ report.kept.sort_unstable();
+ Ok(report)
+}
+
// ---------------------------------------------------------------------------------------------------
// Enumeración de árboles.
// ---------------------------------------------------------------------------------------------------
@@ -715,4 +766,50 @@ mod tests {
assert_eq!(evs.len(), 2);
assert!(evs.iter().all(|e| matches!(&e.by.source, Source::HammerHydrate { artifact } if artifact == &dir)));
}
+
+ #[test]
+ fn prune_removes_orphans_but_keeps_live_chain() {
+ let tmp = tempfile::tempdir().unwrap();
+ let store = tmp.path().join("store");
+ let root = tmp.path().join("root");
+ let state = tmp.path().join("state");
+ let v1 = seal_tree(&store, &h64("a1"), "v1", |w| write(&w.join("f"), b"1"));
+ let v2 = seal_tree(&store, &h64("a2"), "v2", |w| write(&w.join("f"), b"2"));
+ let v3 = seal_tree(&store, &h64("a3"), "v3", |w| write(&w.join("f"), b"3"));
+
+ apply(&store, &v1, None, &root, &state, None).unwrap(); // gen 1
+ apply(&store, &v2, None, &root, &state, None).unwrap(); // gen 2 (parent 1)
+ apply(&store, &v3, None, &root, &state, None).unwrap(); // gen 3 (parent 2)
+ // rollback dos veces ⇒ current=1; las gens 2 y 3 quedan HUÉRFANAS (rollback no las alcanza).
+ rollback(&root, &state, None).unwrap();
+ rollback(&root, &state, None).unwrap();
+ assert_eq!(current(&state).unwrap(), Some(1));
+ assert_eq!(live_chain(&state).unwrap(), vec![1]);
+
+ let r = prune(&state, None).unwrap();
+ assert_eq!(r.removed, vec![2, 3], "huérfanas borradas");
+ assert_eq!(r.kept, vec![1], "cadena viva conservada");
+ assert_eq!(list(&state).unwrap().iter().map(|m| m.id).collect::<Vec<_>>(), vec![1]);
+ // idempotente.
+ assert!(prune(&state, None).unwrap().removed.is_empty());
+ }
+
+ #[test]
+ fn prune_with_keep_trims_chain_depth() {
+ let tmp = tempfile::tempdir().unwrap();
+ let store = tmp.path().join("store");
+ let root = tmp.path().join("root");
+ let state = tmp.path().join("state");
+ for (hx, nm, by) in [("b1", "v1", b"1"), ("b2", "v2", b"2"), ("b3", "v3", b"3")] {
+ let d = seal_tree(&store, &h64(hx), nm, |w| write(&w.join("f"), by));
+ apply(&store, &d, None, &root, &state, None).unwrap();
+ }
+ // current=3, cadena=[3,2,1]. keep=Some(2) ⇒ conserva 3 y 2, borra 1.
+ assert_eq!(live_chain(&state).unwrap(), vec![3, 2, 1]);
+ let r = prune(&state, Some(2)).unwrap();
+ assert_eq!(r.kept, vec![2, 3]);
+ assert_eq!(r.removed, vec![1]);
+ // gen 1 borrada del disco; 2 y 3 siguen.
+ assert_eq!(list(&state).unwrap().iter().map(|m| m.id).collect::<Vec<_>>(), vec![2, 3]);
+ }
}
diff --git a/docs/13-release-engineering.md b/docs/13-release-engineering.md
index c2252e92..d2c2aae8 100644
--- a/docs/13-release-engineering.md
+++ b/docs/13-release-engineering.md
@@ -79,8 +79,11 @@ kernel en `/boot`, los módulos GRUB en `/boot/grub/i386-pc` y los pasos `grub-m
`HammerHydrate{artifact=tree_dir}` (replay-able). Verificación opcional de integridad: si el llamador
anuncia el `of_tree` esperado (de un índice de **mirror E3** o release firmada), el apply lo exige antes
de tocar el root. Maneja ficheros regulares + symlinks; valida E2E en host con `scripts/upgrade-e2e-test.sh`
- (apply v1→v2→rollback→rollback contra el binario real). **Endurecimiento pendiente:** journal de
- *intención* + replay para sobrevivir un kernel-panic a media escritura; GC de generaciones viejas
- (prune); cableado del init de boot para que el rollback sea seleccionable al arranque.
+ (apply v1→v2→rollback→rollback contra el binario real). **GC de generaciones ✅** (`hammer upgrade
+ prune [--keep N]` / `hammer_upgrade::prune`): borra las generaciones que el rollback ya no alcanza
+ (huérfanas tras un rollback — las de la [cadena viva](#) siempre se conservan); `--keep N` además
+ recorta la cadena a sus N más nuevas (limita la profundidad de rollback, como `delete-generations` en
+ NixOS). **Endurecimiento pendiente:** journal de *intención* + replay para sobrevivir un kernel-panic a
+ media escritura; cableado del init de boot para que el rollback sea seleccionable al arranque.
- **E5 — ISO/medio de arranque:** medio live para correr el instalador (xorriso/grub-mkrescue;
herramental a construir).
diff --git a/scripts/upgrade-e2e-test.sh b/scripts/upgrade-e2e-test.sh
index 7512b94a..f556520e 100755
--- a/scripts/upgrade-e2e-test.sh
+++ b/scripts/upgrade-e2e-test.sh
@@ -99,5 +99,14 @@ LINES="$(wc -l < "$JOURNAL/mutations.jsonl")"
[ "$LINES" -gt 0 ] || { echo "FALLO: diario vacío" >&2; exit 1; }
echo " ok: $LINES eventos en el diario"
+echo "==> prune: tras los dos rollback completos las gens 1 y 2 quedaron huérfanas (current=3)"
+GENS_ANTES="$(ls "$STATE/generations" | wc -l)"
+up prune
+GENS_DESPUES="$(ls "$STATE/generations" | wc -l)"
+[ "$GENS_ANTES" -gt "$GENS_DESPUES" ] || { echo "FALLO: prune no borró huérfanas ($GENS_ANTES→$GENS_DESPUES)" >&2; exit 1; }
+echo " ok: prune $GENS_ANTES→$GENS_DESPUES generación(es)"
+# el árbol vivo (gen 3 = v1) sigue intacto tras el prune.
+assert "$ROOT/usr/bin/ls" "LS-v1" "post-prune"
+
echo
-echo "✓✓ E4 upgrade E2E VERDE: apply/rollback atómicos + restauración exacta + idempotencia + diario"
+echo "✓✓ E4 upgrade E2E VERDE: apply/rollback atómicos + restauración exacta + idempotencia + diario + prune"