From 9f2498397f25c9aa821480a41ac867137eb9d352 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 25 May 2026 23:41:23 -0400 Subject: [PATCH] refactor(cleanup): compose-ls driven, plus orphan state-dir reaping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRD 0018 chunk 4. `claude-bottle cleanup` now derives its work from `docker compose ls --all --format json`, filtered to projects whose name starts with `claude-bottle-`. Per project: one `compose down --volumes` removes the containers + the compose-managed networks atomically. The plan also enumerates three fallback buckets: - Stray containers — `claude-bottle-*` containers with no `com.docker.compose.project` label (left over from pre-compose code paths). Cleared via `docker rm -f`. - Stray networks — `claude-bottle-*` networks with no compose project label. Cleared via `docker network rm`. - Orphan state dirs — per-bottle `~/.claude-bottle/state//` dirs with no live project AND no `.preserve` marker. The `.preserve` marker (capability-block or auto-preserve-on-crash) explicitly opts-out of reaping; manual `rm -rf` is the only path for preserved state. cli/cleanup.py collapses to a single y/N prompt — backend.prepare_cleanup returns everything in one plan, backend.cleanup processes everything, no more double-prompt for state. The CLI-side state-dir enumeration + `_state_summary` flags from PR #25 are gone; the backend's orphan-detection rules subsume them. --- .../backend/docker/bottle_cleanup_plan.py | 47 +++- claude_bottle/backend/docker/cleanup.py | 257 +++++++++++++----- claude_bottle/cli/cleanup.py | 90 ++---- tests/unit/test_cli_cleanup.py | 102 ------- tests/unit/test_docker_cleanup.py | 102 +++++++ 5 files changed, 349 insertions(+), 249 deletions(-) delete mode 100644 tests/unit/test_cli_cleanup.py create mode 100644 tests/unit/test_docker_cleanup.py diff --git a/claude_bottle/backend/docker/bottle_cleanup_plan.py b/claude_bottle/backend/docker/bottle_cleanup_plan.py index fd54ad4..fe5b605 100644 --- a/claude_bottle/backend/docker/bottle_cleanup_plan.py +++ b/claude_bottle/backend/docker/bottle_cleanup_plan.py @@ -1,8 +1,21 @@ """DockerBottleCleanupPlan — concrete subclass of BottleCleanupPlan. -Holds the tuples of container and network names that -DockerBottleBackend.cleanup will remove. The y/N preflight reads -these via `print`; the CLI short-circuits via `empty`. +PRD 0018 chunk 4: cleanup is centered on compose projects. `docker +compose ls` is the source of truth for what's running; the plan +carries the projects to `compose down`, plus three fallback buckets +for legacy / orphan resources: + + - stray_containers: pre-compose `claude-bottle-*` containers not + attached to any compose project. Cleared via `docker rm -f`. + - stray_networks: same idea for networks. Cleared via + `docker network rm`. + - orphan_state_dirs: per-bottle state dirs under + ~/.claude-bottle/state/ that have no live compose project AND + no `.preserve` marker. Reaped via `shutil.rmtree`. + +Compose-managed networks are removed by `compose down --volumes`, +so they don't appear in stray_networks for a normal project — only +truly leftover ones. """ from __future__ import annotations @@ -17,20 +30,30 @@ from .. import BottleCleanupPlan @dataclass(frozen=True) class DockerBottleCleanupPlan(BottleCleanupPlan): """Resources DockerBottleBackend.cleanup will remove. Produced by - `prepare_cleanup` from a snapshot of `docker ps -a` + `docker - network ls`; sorted so the y/N output is stable.""" + `prepare_cleanup`; sorted so the y/N output is stable.""" - containers: tuple[str, ...] - networks: tuple[str, ...] + projects: tuple[str, ...] + stray_containers: tuple[str, ...] + stray_networks: tuple[str, ...] + orphan_state_dirs: tuple[str, ...] @property def empty(self) -> bool: - return not self.containers and not self.networks + return ( + not self.projects + and not self.stray_containers + and not self.stray_networks + and not self.orphan_state_dirs + ) def print(self) -> None: print(file=sys.stderr) - for name in self.containers: - info(f"container: {name}") - for name in self.networks: - info(f"network: {name}") + for name in self.projects: + info(f"compose project: {name}") + for name in self.stray_containers: + info(f"stray container: {name}") + for name in self.stray_networks: + info(f"stray network: {name}") + for name in self.orphan_state_dirs: + info(f"orphan state: {name}") print(file=sys.stderr) diff --git a/claude_bottle/backend/docker/cleanup.py b/claude_bottle/backend/docker/cleanup.py index a0c1fa9..82378d4 100644 --- a/claude_bottle/backend/docker/cleanup.py +++ b/claude_bottle/backend/docker/cleanup.py @@ -1,76 +1,180 @@ """Cleanup + active-listing for the Docker bottle backend. -`prepare_cleanup` enumerates orphaned `claude-bottle-` containers and -networks; `cleanup` removes them. `list_active` queries the same -namespace for ad-hoc inspection. All three share a single concern: -acting on resources whose names start with `claude-bottle-`. +PRD 0018 chunk 4: cleanup is centered on `docker compose ls`. +Pre-compose code paths could leave bare containers / networks +without a compose project; those still show up via the prefix +scan, just as a fallback bucket alongside the project list. + +`prepare_cleanup` enumerates: + + - Live compose projects whose name starts with `claude-bottle-`. + - `claude-bottle-*` containers that aren't part of any compose + project (legacy orphans). + - `claude-bottle-*` networks that aren't tied to a compose + project (legacy orphans; compose-managed networks come down + with `compose down --volumes` and don't appear here). + - State dirs under ~/.claude-bottle/state// with no + live compose project AND no `.preserve` marker. + +`cleanup` removes everything in the plan. + +`list_active` queries the same compose project namespace and prints +each project's services for ad-hoc inspection. """ from __future__ import annotations +import json +import shutil import subprocess +from pathlib import Path -from ...log import info +from ... import supervise as _supervise +from ...log import info, warn from . import util as docker_mod from .bottle_cleanup_plan import DockerBottleCleanupPlan +from .bottle_state import bottle_state_dir, is_preserved + + +_PROJECT_PREFIX = "claude-bottle-" + + +def _list_compose_projects() -> list[str]: + """Return the names of all currently-known compose projects + (running OR stopped) whose name starts with `claude-bottle-`. + `docker compose ls --all` reports both up + exited states.""" + result = subprocess.run( + ["docker", "compose", "ls", "--all", "--format", "json"], + capture_output=True, text=True, check=False, + ) + if result.returncode != 0: + warn(f"docker compose ls failed: {result.stderr.strip()}") + return [] + try: + projects = json.loads(result.stdout or "[]") + except json.JSONDecodeError as e: + warn(f"docker compose ls returned malformed JSON: {e}") + return [] + names: list[str] = [] + for p in projects: + if not isinstance(p, dict): + continue + name = str(p.get("Name", "")) + if name.startswith(_PROJECT_PREFIX): + names.append(name) + return sorted(set(names)) + + +def _list_prefixed_containers() -> list[str]: + """All claude-bottle-prefixed containers, running or stopped.""" + result = subprocess.run( + ["docker", "ps", "-a", + "--filter", f"name=^{_PROJECT_PREFIX}", + "--format", "{{.Names}}\t{{.Label \"com.docker.compose.project\"}}"], + capture_output=True, text=True, check=False, + ) + if result.returncode != 0: + warn(f"docker ps failed: {result.stderr.strip()}") + return [] + out: list[str] = [] + for line in (result.stdout or "").splitlines(): + if not line: + continue + name, _, project = line.partition("\t") + # Stray = no compose label. Compose-managed containers carry + # `com.docker.compose.project=`; we'll reap those via + # `compose down`, not via container rm. + if not project: + out.append(name) + return sorted(set(out)) + + +def _list_prefixed_networks() -> list[str]: + """All claude-bottle-prefixed networks not currently attached + to a compose project. Compose-managed networks have a + `com.docker.compose.project` label; bare ones (from pre-compose + code paths) don't.""" + result = subprocess.run( + ["docker", "network", "ls", + "--filter", f"name={_PROJECT_PREFIX}", + "--format", "{{.Name}}\t{{.Label \"com.docker.compose.project\"}}"], + capture_output=True, text=True, check=False, + ) + if result.returncode != 0: + warn(f"docker network ls failed: {result.stderr.strip()}") + return [] + out: list[str] = [] + for line in (result.stdout or "").splitlines(): + if not line: + continue + name, _, project = line.partition("\t") + if not project: + out.append(name) + return sorted(set(out)) + + +def _list_orphan_state_dirs(live_projects: set[str]) -> list[str]: + """State identities whose compose project isn't running and + that don't have a `.preserve` marker. `.preserve` means the + user (or an auto-preserve-on-crash) wants the state kept for + `resume`.""" + state_root = _supervise.claude_bottle_root() / "state" + if not state_root.is_dir(): + return [] + orphans: list[str] = [] + for child in sorted(state_root.iterdir()): + if not child.is_dir(): + continue + identity = child.name + project = f"{_PROJECT_PREFIX}{identity}" + if project in live_projects: + continue + if is_preserved(identity): + continue + orphans.append(identity) + return orphans def prepare_cleanup() -> DockerBottleCleanupPlan: - """Enumerate all claude-bottle-prefixed containers (running or - stopped) and networks. No removals — caller confirms first.""" + """Enumerate everything cleanup will touch. No removals.""" docker_mod.require_docker() - - # `docker ps -a --filter name=...` uses regex matching; anchor at - # the start so we don't pick up containers that merely contain - # "claude-bottle-" mid-name. - cr = subprocess.run( - [ - "docker", "ps", "-a", - "--filter", "name=^claude-bottle-", - "--format", "{{.Names}}", - ], - capture_output=True, - text=True, - check=True, + projects = _list_compose_projects() + project_set = set(projects) + return DockerBottleCleanupPlan( + projects=tuple(projects), + stray_containers=tuple(_list_prefixed_containers()), + stray_networks=tuple(_list_prefixed_networks()), + orphan_state_dirs=tuple(_list_orphan_state_dirs(project_set)), ) - containers = tuple(sorted( - line for line in (cr.stdout or "").splitlines() if line - )) - - # `docker network ls --filter name=...` uses substring matching. - # "claude-bottle-" is specific enough that false positives are - # not a concern. - nr = subprocess.run( - [ - "docker", "network", "ls", - "--filter", "name=claude-bottle-", - "--format", "{{.Name}}", - ], - capture_output=True, - text=True, - check=True, - ) - networks = tuple(sorted( - line for line in (nr.stdout or "").splitlines() if line - )) - - return DockerBottleCleanupPlan(containers=containers, networks=networks) def cleanup(plan: DockerBottleCleanupPlan) -> None: - """Remove the containers and networks listed in the plan. - Containers first; networks would refuse to delete while containers - are still attached.""" - for name in plan.containers: - info(f"removing container {name}") + """Remove everything in the plan. Projects first (whose `compose + down` reaps their containers + networks atomically), then stray + legacy resources, then orphan state dirs.""" + for project in plan.projects: + info(f"docker compose down ({project})") + result = subprocess.run( + ["docker", "compose", "-p", project, "down", "--volumes"], + capture_output=True, text=True, check=False, + ) + if result.returncode != 0: + warn( + f"compose down failed for {project}: " + f"{result.stderr.strip()}" + ) + + for name in plan.stray_containers: + info(f"removing stray container {name}") subprocess.run( ["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) - for name in plan.networks: - info(f"removing network {name}") + + for name in plan.stray_networks: + info(f"removing stray network {name}") subprocess.run( ["docker", "network", "rm", name], stdout=subprocess.DEVNULL, @@ -78,27 +182,50 @@ def cleanup(plan: DockerBottleCleanupPlan) -> None: check=False, ) + for identity in plan.orphan_state_dirs: + path = bottle_state_dir(identity) + info(f"removing orphan state dir {path}") + try: + shutil.rmtree(path, ignore_errors=True) + except OSError as e: + warn(f"failed to remove {path}: {e}") + def list_active() -> None: - """Print all running claude-bottle containers (name + status). - Prints a single-line banner if there are none.""" + """Print every active claude-bottle compose project + its + services. Empty banner when there are none.""" docker_mod.require_docker() + projects = _list_compose_projects() + # Filter to projects with at least one running container — `compose ls` + # already filters by default to active projects unless `--all` was + # set; double-check by querying status. result = subprocess.run( - [ - "docker", "ps", - "--filter", "name=^claude-bottle-", - "--format", "{{.Names}}\t{{.Status}}", - ], - capture_output=True, - text=True, - check=True, + ["docker", "compose", "ls", "--format", "json"], + capture_output=True, text=True, check=False, ) - containers = (result.stdout or "").strip() - if not containers: - info("no active claude-bottle containers") + running_names: set[str] = set() + if result.returncode == 0: + try: + data = json.loads(result.stdout or "[]") + running_names = { + str(p.get("Name", "")) for p in data if isinstance(p, dict) + } + except json.JSONDecodeError: + pass + active = [p for p in projects if p in running_names] + if not active: + info("no active claude-bottle compose projects") return print() - for line in containers.splitlines(): - name, _, status = line.partition("\t") - info(f"container: {name} status: {status}") + for project in active: + info(f"compose project: {project}") + ps = subprocess.run( + ["docker", "compose", "-p", project, "ps", "--format", + "{{.Service}}\t{{.Name}}\t{{.Status}}"], + capture_output=True, text=True, check=False, + ) + for line in (ps.stdout or "").splitlines(): + service, _, rest = line.partition("\t") + name, _, status = rest.partition("\t") + info(f" {service:12s} {name} ({status})") print() diff --git a/claude_bottle/cli/cleanup.py b/claude_bottle/cli/cleanup.py index 52b861b..fe46ae0 100644 --- a/claude_bottle/cli/cleanup.py +++ b/claude_bottle/cli/cleanup.py @@ -1,19 +1,22 @@ -"""cleanup: stop and remove all orphaned claude-bottle resources -(containers + networks) left behind by previous bottles, plus -optionally the per-bottle state dirs under ~/.claude-bottle/state/. +"""cleanup: stop and remove all orphaned claude-bottle resources. -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.""" +PRD 0018 chunk 4: backend's prepare_cleanup carries everything in +one plan — live compose projects (whose `compose down` removes +containers + networks atomically), legacy stray containers/networks +that aren't in any project, and orphan state dirs (per-bottle +state with no live project AND no `.preserve` marker). One prompt, +one cleanup call. + +State dirs with `.preserve` are intentionally never touched — they +hold capability-block rebuilds or crash snapshots the operator may +want to `resume`. Manual `rm -rf ~/.claude-bottle/state/` +is the path for those. +""" 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 @@ -22,74 +25,21 @@ 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 and not state_dirs: + if plan.empty: info("no claude-bottle resources to clean up") return 0 - 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") + plan.print() + if not _prompt_yes("remove all of the above?"): + info("cleanup: skipped") + return 0 + backend.cleanup(plan) + info("cleanup: done") 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() diff --git a/tests/unit/test_cli_cleanup.py b/tests/unit/test_cli_cleanup.py deleted file mode 100644 index 97c04ac..0000000 --- a/tests/unit/test_cli_cleanup.py +++ /dev/null @@ -1,102 +0,0 @@ -"""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() diff --git a/tests/unit/test_docker_cleanup.py b/tests/unit/test_docker_cleanup.py new file mode 100644 index 0000000..c2485ac --- /dev/null +++ b/tests/unit/test_docker_cleanup.py @@ -0,0 +1,102 @@ +"""Unit: backend/docker/cleanup orphan-state-dir detection. + +PRD 0018 chunk 4. The orphan-state-dir rule has three categories: + - LIVE: a compose project with the matching name is up → keep + - PRESERVED: state dir carries `.preserve` → keep (resume target) + - ORPHAN: neither → reap + +These are the cases the test exercises. The compose-project + +container/network enumeration is left to the integration tests +because it requires a real docker daemon.""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from claude_bottle import supervise +from claude_bottle.backend.docker import bottle_state +from claude_bottle.backend.docker.cleanup import _list_orphan_state_dirs + + +class _FakeHomeMixin: + def _setup_fake_home(self) -> None: + self._tmp = tempfile.TemporaryDirectory(prefix="docker-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) -> None: + self._restore() + self._tmp.cleanup() + + +class TestOrphanStateDirs(_FakeHomeMixin, unittest.TestCase): + def setUp(self) -> None: + self._setup_fake_home() + + def tearDown(self) -> None: + self._teardown_fake_home() + + def test_no_state_root_returns_empty(self): + self.assertEqual([], _list_orphan_state_dirs(set())) + + def test_state_dir_with_no_live_no_preserve_is_orphan(self): + # Just touch the dir; no metadata, no preserve marker — the + # exact orphan shape. + bottle_state.write_per_bottle_dockerfile("solo-aaa", "FROM x\n") + self.assertEqual( + ["solo-aaa"], + _list_orphan_state_dirs(set()), + ) + + def test_live_project_skips_dir(self): + # Live project means the bottle is currently running under + # compose — never reap. + bottle_state.write_per_bottle_dockerfile("live-bbb", "FROM x\n") + self.assertEqual( + [], + _list_orphan_state_dirs({"claude-bottle-live-bbb"}), + ) + + def test_preserve_marker_skips_dir(self): + # Preserve marker = capability-block or crash auto-preserve; + # the user explicitly wanted this dir kept for `resume`. + bottle_state.write_per_bottle_dockerfile("kept-ccc", "FROM x\n") + bottle_state.mark_preserved("kept-ccc") + self.assertEqual( + [], + _list_orphan_state_dirs(set()), + ) + + def test_preserve_overrides_no_live_project(self): + # Even without a live project, a preserve marker keeps it. + bottle_state.write_per_bottle_dockerfile("kept-ddd", "FROM x\n") + bottle_state.mark_preserved("kept-ddd") + self.assertEqual([], _list_orphan_state_dirs(set())) + + def test_mixed_set_categorized_correctly(self): + bottle_state.write_per_bottle_dockerfile("orphan-eee", "FROM x\n") + bottle_state.write_per_bottle_dockerfile("live-fff", "FROM y\n") + bottle_state.write_per_bottle_dockerfile("kept-ggg", "FROM z\n") + bottle_state.mark_preserved("kept-ggg") + + result = _list_orphan_state_dirs({"claude-bottle-live-fff"}) + self.assertEqual(["orphan-eee"], result) + + def test_sorted_output(self): + for name in ("zzz-1", "aaa-1", "mmm-1"): + bottle_state.write_per_bottle_dockerfile(name, "FROM x\n") + self.assertEqual( + ["aaa-1", "mmm-1", "zzz-1"], + _list_orphan_state_dirs(set()), + ) + + +if __name__ == "__main__": + unittest.main()