fix(checkpoints): bind an empty orphan preview to an empty deletion allowlist

Follow-up for salvaged PR #69141, addressing the last open review point:
cmd_prune() only set orphan_allowlist inside 'if orphans or pre_v2_orphans',
so a zero-orphan preview passed the unrestricted None sentinel down to
prune_checkpoints(), authorizing deletion of any project that became
orphaned between the preview and the rescan — with zero confirmation
calls. The allowlist is now bound unconditionally for every non-force
run (empty preview => empty allowlist); --force keeps None. Adds the
zero-orphan-preview timing regression plus allowlist-identity tests.
This commit is contained in:
teknium1 2026-07-24 12:06:58 -07:00 committed by Teknium
parent c004c74df6
commit 6c14b12d1f
2 changed files with 63 additions and 6 deletions

View file

@ -148,12 +148,15 @@ def cmd_prune(args: argparse.Namespace) -> int:
if not _confirm("Delete these orphan projects?"):
print("Aborted.")
return 1
# Bind the deletion to exactly what was just displayed and
# confirmed — a project that becomes orphaned only *after* this
# preview (e.g. its workdir disappears while waiting on input())
# must not be swept up under this same confirmation.
orphan_allowlist = {p["hash"] for p in orphans}
orphan_allowlist.update(p["path"] for p in pre_v2_orphans)
# Bind the deletion to exactly what was just displayed (and, when
# non-empty, confirmed) — a project that becomes orphaned only
# *after* this preview (e.g. its workdir disappears while waiting on
# input()) must not be swept up under this same run. This is set
# unconditionally for every non-force run: an EMPTY preview binds to
# an EMPTY allowlist, so a zero-orphan preview can never authorize
# deletion of orphans discovered by the later rescan.
orphan_allowlist = {p["hash"] for p in orphans}
orphan_allowlist.update(p["path"] for p in pre_v2_orphans)
print("Pruning checkpoint store…")
print(f" retention_days: {retention_days}")

View file

@ -206,3 +206,57 @@ def test_no_orphans_skips_prompt(monkeypatch, capsys):
assert rc == 0
assert len(prune_calls) == 1
# ─── allowlist binding: preview set == deletion set, even when empty ───────
def test_empty_preview_binds_empty_allowlist(monkeypatch, capsys):
"""Zero-orphan-preview timing regression (PR #69141 review).
When the non-force preview shows zero orphans, no prompt runs but the
later rescan inside prune_checkpoints() may discover a project that
became orphaned *after* the preview. That undisplayed, unconfirmed orphan
must not be deletable: the allowlist passed down must be the exact
(empty) displayed set, never the unrestricted None sentinel.
"""
import hermes_cli.checkpoints as checkpoints_cli
prune_calls: list = []
_patch_checkpoint_manager(monkeypatch, _V2_ORPHAN_ONLY_STATUS, prune_calls)
rc = checkpoints_cli.cmd_prune(_ns())
assert rc == 0
assert len(prune_calls) == 1
assert prune_calls[0]["orphan_allowlist"] == set()
def test_nonempty_preview_allowlist_matches_displayed_set(monkeypatch, capsys):
import hermes_cli.checkpoints as checkpoints_cli
prune_calls: list = []
_patch_checkpoint_manager(monkeypatch, _MIXED_STATUS, prune_calls)
monkeypatch.setattr("builtins.input", lambda _prompt: "y")
rc = checkpoints_cli.cmd_prune(_ns())
assert rc == 0
assert len(prune_calls) == 1
assert prune_calls[0]["orphan_allowlist"] == {
"abc123",
"/home/user/.hermes/checkpoints/deadbeefcafebabe",
}
def test_force_leaves_allowlist_unrestricted(monkeypatch, capsys):
import hermes_cli.checkpoints as checkpoints_cli
prune_calls: list = []
_patch_checkpoint_manager(monkeypatch, _MIXED_STATUS, prune_calls)
rc = checkpoints_cli.cmd_prune(_ns(force=True))
assert rc == 0
assert len(prune_calls) == 1
assert prune_calls[0]["orphan_allowlist"] is None