From 4baff6fbaf0571e4b732ac9fd4b0b7ce5727f1c8 Mon Sep 17 00:00:00 2001 From: beardthelion Date: Mon, 21 Sep 2026 21:38:01 -0500 Subject: [PATCH] kanban gc: validate retention flags before the workspace sweep (cherry picked from commit f7e6505fff08955ee0b46c9972002ab1c015e287) --- hermes_cli/kanban_ops.py | 8 ++++---- tests/hermes_cli/test_kanban_gc_retention.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/hermes_cli/kanban_ops.py b/hermes_cli/kanban_ops.py index f9e641644c..d0b3587bcd 100644 --- a/hermes_cli/kanban_ops.py +++ b/hermes_cli/kanban_ops.py @@ -305,6 +305,10 @@ def _cmd_watch(args: argparse.Namespace) -> int: def _cmd_gc(args: argparse.Namespace) -> int: """Remove archived tasks' scratch workspaces, old events, and old worker logs.""" import shutil + event_days = getattr(args, "event_retention_days", 30) + log_days = getattr(args, "log_retention_days", 30) + if event_days < 0 or log_days < 0: + return _err("kanban gc: retention days must be >= 0 (0 disables that sweep)", 2) scratch_root = kb.workspaces_root() removed_ws = 0 with kbc.connect_closing() as conn: @@ -338,10 +342,6 @@ def _cmd_gc(args: argparse.Namespace) -> int: shutil.rmtree(path, ignore_errors=True) removed_ws += 1 - event_days = getattr(args, "event_retention_days", 30) - log_days = getattr(args, "log_retention_days", 30) - if event_days < 0 or log_days < 0: - return _err("kanban gc: retention days must be >= 0 (0 disables that sweep)", 2) removed_events = 0 if event_days: with kbc.connect_closing() as conn: diff --git a/tests/hermes_cli/test_kanban_gc_retention.py b/tests/hermes_cli/test_kanban_gc_retention.py index f34e8c9c93..7d212d322a 100644 --- a/tests/hermes_cli/test_kanban_gc_retention.py +++ b/tests/hermes_cli/test_kanban_gc_retention.py @@ -71,6 +71,24 @@ def test_cmd_gc_negative_days_errors_and_deletes_nothing(board, capsys): assert log.exists() +def test_cmd_gc_negative_days_leaves_workspaces_untouched(board): + """Invalid retention must refuse before ANY sweep: the workspace collection + runs first in the command body, so this fixture proves ordering, not just + event/log preservation.""" + with kbc.connect_closing() as conn: + tid = kb.create_task(conn, title="archived with workspace") + with kb.write_txn(conn): + conn.execute( + "UPDATE tasks SET status='archived', workspace_kind='scratch' WHERE id=?", + (tid,), + ) + ws = kb.workspaces_root() / tid + ws.mkdir(parents=True) + (ws / "scratch.txt").write_text("keep me") + assert kanban_ops._cmd_gc(_args(event_days=-1)) != 0 + assert (ws / "scratch.txt").exists() + + def test_cmd_gc_zero_days_disables_sweeps(board): with kbc.connect_closing() as conn: tid = _done_task_with_old_event(conn)