feat(cleanup): prompt to remove per-bottle state, separately from containers
`cli.py cleanup` already enumerated orphan containers + networks and asked for confirmation before nuking them. Per-bottle state under ~/.claude-bottle/state/ wasn't touched — accumulated forever, including orphans from old code paths. Add state to the cleanup flow with its own prompt: the trade-off is different from containers (which are pure debris) because a state dir may carry a resumable bottle (capability-block rebuild + transcript snapshot) the operator still wants. Output shows the resumable / orphan / rebuilt-Dockerfile / transcript / preserve-marker flags for each state dir so the operator sees what they'd lose. Both sections are skippable independently — answering "n" to containers doesn't skip the state prompt. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,19 @@
|
||||
"""cleanup: stop and remove all orphaned claude-bottle resources
|
||||
(containers + networks) left behind by previous bottles."""
|
||||
(containers + networks) left behind by previous bottles, plus
|
||||
optionally the per-bottle state dirs under ~/.claude-bottle/state/.
|
||||
|
||||
State cleanup is prompted separately from container cleanup because
|
||||
the trade-off is different: containers + networks are pure debris,
|
||||
but a state dir may carry a resumable bottle (capability-block
|
||||
rebuild + transcript snapshot) the operator still wants."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .. import supervise as _supervise
|
||||
from ..backend import get_bottle_backend
|
||||
from ..log import info
|
||||
from ._common import read_tty_line
|
||||
@@ -13,19 +22,76 @@ from ._common import read_tty_line
|
||||
def cmd_cleanup(_argv: list[str]) -> int:
|
||||
backend = get_bottle_backend()
|
||||
plan = backend.prepare_cleanup()
|
||||
state_dirs = _enumerate_state_dirs()
|
||||
|
||||
if plan.empty:
|
||||
if plan.empty and not state_dirs:
|
||||
info("no claude-bottle resources to clean up")
|
||||
return 0
|
||||
|
||||
plan.print()
|
||||
sys.stderr.write("claude-bottle: remove all of the above? [y/N] ")
|
||||
if not plan.empty:
|
||||
plan.print()
|
||||
if _prompt_yes("remove all of the above?"):
|
||||
backend.cleanup(plan)
|
||||
info("containers + networks: cleaned")
|
||||
else:
|
||||
info("containers + networks: skipped")
|
||||
|
||||
if state_dirs:
|
||||
_print_state(state_dirs)
|
||||
if _prompt_yes(
|
||||
"remove per-bottle state? (loses resumable bottles)",
|
||||
):
|
||||
for d in state_dirs:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
info(f"state: removed {len(state_dirs)} dir(s)")
|
||||
else:
|
||||
info("state: skipped")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
# --- State enumeration + display ------------------------------------------
|
||||
|
||||
|
||||
def _enumerate_state_dirs() -> list[Path]:
|
||||
"""All per-bottle state dirs under ~/.claude-bottle/state/.
|
||||
Sorted for stable preflight output."""
|
||||
state_root = _supervise.claude_bottle_root() / "state"
|
||||
if not state_root.is_dir():
|
||||
return []
|
||||
return sorted(p for p in state_root.iterdir() if p.is_dir())
|
||||
|
||||
|
||||
def _state_summary(path: Path) -> str:
|
||||
"""One-line description suitable for the cleanup prompt. Calls
|
||||
out resumability so the operator can decide whether removing it
|
||||
loses anything they care about."""
|
||||
flags: list[str] = []
|
||||
if (path / "metadata.json").is_file():
|
||||
flags.append("resumable")
|
||||
else:
|
||||
flags.append("no metadata.json (orphan)")
|
||||
if (path / "Dockerfile").is_file():
|
||||
flags.append("rebuilt Dockerfile")
|
||||
if (path / "transcript").is_dir():
|
||||
flags.append("transcript snapshot")
|
||||
if (path / ".preserve").is_file():
|
||||
flags.append("preserve marker")
|
||||
return f"state: {path.name} ({', '.join(flags)})"
|
||||
|
||||
|
||||
def _print_state(dirs: list[Path]) -> None:
|
||||
print(file=sys.stderr)
|
||||
for d in dirs:
|
||||
info(_state_summary(d))
|
||||
print(file=sys.stderr)
|
||||
|
||||
|
||||
# --- Prompt ----------------------------------------------------------------
|
||||
|
||||
|
||||
def _prompt_yes(message: str) -> bool:
|
||||
sys.stderr.write(f"claude-bottle: {message} [y/N] ")
|
||||
sys.stderr.flush()
|
||||
reply = read_tty_line()
|
||||
if reply not in ("y", "Y", "yes", "YES"):
|
||||
info("aborted")
|
||||
return 0
|
||||
|
||||
backend.cleanup(plan)
|
||||
info("done")
|
||||
return 0
|
||||
return reply in ("y", "Y", "yes", "YES")
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Unit: cli/cleanup.py state-dir enumeration + summary.
|
||||
|
||||
The end-to-end cleanup-with-prompt flow is exercised manually;
|
||||
here we cover the state-dir display logic so a regression in the
|
||||
resumable / orphan / rebuild flags surfaces in unit CI."""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from claude_bottle import supervise
|
||||
from claude_bottle.backend.docker import bottle_state
|
||||
from claude_bottle.cli.cleanup import _enumerate_state_dirs, _state_summary
|
||||
|
||||
|
||||
class _FakeHomeMixin:
|
||||
def _setup_fake_home(self):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="cli-cleanup-test.")
|
||||
original = supervise.claude_bottle_root
|
||||
|
||||
def fake_root() -> Path:
|
||||
return Path(self._tmp.name) / ".claude-bottle"
|
||||
|
||||
supervise.claude_bottle_root = fake_root # type: ignore[assignment]
|
||||
self._restore = lambda: setattr(supervise, "claude_bottle_root", original)
|
||||
|
||||
def _teardown_fake_home(self):
|
||||
self._restore()
|
||||
self._tmp.cleanup()
|
||||
|
||||
|
||||
class TestEnumerateStateDirs(_FakeHomeMixin, unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._setup_fake_home()
|
||||
|
||||
def tearDown(self):
|
||||
self._teardown_fake_home()
|
||||
|
||||
def test_empty_when_no_state_root(self):
|
||||
self.assertEqual([], _enumerate_state_dirs())
|
||||
|
||||
def test_lists_each_identity_dir(self):
|
||||
bottle_state.write_per_bottle_dockerfile("dev-aaa", "FROM x\n")
|
||||
bottle_state.write_per_bottle_dockerfile("api-bbb", "FROM y\n")
|
||||
dirs = _enumerate_state_dirs()
|
||||
self.assertEqual(
|
||||
["api-bbb", "dev-aaa"],
|
||||
[p.name for p in dirs],
|
||||
)
|
||||
|
||||
def test_sorted_for_stable_preflight(self):
|
||||
for name in ("z", "a", "m"):
|
||||
bottle_state.write_per_bottle_dockerfile(name, "FROM x\n")
|
||||
names = [p.name for p in _enumerate_state_dirs()]
|
||||
self.assertEqual(["a", "m", "z"], names)
|
||||
|
||||
|
||||
class TestStateSummary(_FakeHomeMixin, unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._setup_fake_home()
|
||||
|
||||
def tearDown(self):
|
||||
self._teardown_fake_home()
|
||||
|
||||
def _path(self, name: str) -> Path:
|
||||
return bottle_state.bottle_state_dir(name)
|
||||
|
||||
def test_orphan_state_dir(self):
|
||||
# Only a Dockerfile, no metadata.json — the "api / dev" shape
|
||||
# that comes from pre-identity-fix code.
|
||||
bottle_state.write_per_bottle_dockerfile("orphan", "FROM old\n")
|
||||
s = _state_summary(self._path("orphan"))
|
||||
self.assertIn("orphan", s)
|
||||
self.assertIn("no metadata.json", s)
|
||||
self.assertIn("rebuilt Dockerfile", s)
|
||||
|
||||
def test_resumable_state_dir(self):
|
||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||
identity="dev-aaa", agent_name="dev",
|
||||
cwd="/proj/A", copy_cwd=True, started_at="t",
|
||||
))
|
||||
s = _state_summary(self._path("dev-aaa"))
|
||||
self.assertIn("resumable", s)
|
||||
self.assertNotIn("rebuilt Dockerfile", s)
|
||||
|
||||
def test_resumable_with_capability_rebuild_and_preserve_marker(self):
|
||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||
identity="dev-bbb", agent_name="dev",
|
||||
cwd="", copy_cwd=False, started_at="t",
|
||||
))
|
||||
bottle_state.write_per_bottle_dockerfile("dev-bbb", "FROM rebuilt\n")
|
||||
bottle_state.transcript_snapshot_dir("dev-bbb").mkdir(parents=True)
|
||||
bottle_state.mark_preserved("dev-bbb")
|
||||
s = _state_summary(self._path("dev-bbb"))
|
||||
self.assertIn("resumable", s)
|
||||
self.assertIn("rebuilt Dockerfile", s)
|
||||
self.assertIn("transcript snapshot", s)
|
||||
self.assertIn("preserve marker", s)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user