diff --git a/plugins/disk-cleanup/disk_cleanup.py b/plugins/disk-cleanup/disk_cleanup.py index cef2698316..5cdb294cd2 100755 --- a/plugins/disk-cleanup/disk_cleanup.py +++ b/plugins/disk-cleanup/disk_cleanup.py @@ -10,7 +10,7 @@ Rules: - test files → delete immediately at task end (age >= 0) - temp files → delete after 7 days - cron-output → delete after 14 days - - empty dirs → always delete (under HERMES_HOME) + - empty dirs → delete when safe under HERMES_HOME (excluding checkpoint shadow-repo dirs and git internals) - research → keep 10 newest, prompt for older (deep only) - chrome-profile→ prompt after 14 days (deep only) - >500 MB files → prompt always (deep only) @@ -145,6 +145,29 @@ ALLOWED_CATEGORIES = { } +def _is_protected_empty_dir(dirpath: Path, hermes_home: Path) -> bool: + """Return True when an empty-dir cleanup candidate must be preserved. + + This protects git internals for both Hermes checkpoint shadow repos + (stored under ``checkpoints//...``) and ordinary repos living under + ``HERMES_HOME`` (stored under ``.git/...``). + """ + try: + rel_parts = dirpath.relative_to(hermes_home).parts + except ValueError: + return False + + if ".git" in rel_parts and rel_parts[-1] != ".git": + return True + + if len(rel_parts) >= 2 and rel_parts[0] == "checkpoints": + repo_root = hermes_home / rel_parts[0] / rel_parts[1] + if any((repo_root / marker).exists() for marker in ("HEAD", "HERMES_WORKDIR", "config")): + return True + + return False + + def fmt_size(n: float) -> str: for unit in ("B", "KB", "MB", "GB", "TB"): if n < 1024: @@ -309,6 +332,8 @@ def quick() -> Dict[str, Any]: rel_parts = dirpath.relative_to(hermes_home).parts except ValueError: continue + if _is_protected_empty_dir(dirpath, hermes_home): + continue # Skip the well-known top-level state dirs themselves. if len(rel_parts) == 1 and rel_parts[0] in _PROTECTED_TOP_LEVEL: continue diff --git a/tests/plugins/test_disk_cleanup_plugin.py b/tests/plugins/test_disk_cleanup_plugin.py index e1463bced7..bbaace74a8 100644 --- a/tests/plugins/test_disk_cleanup_plugin.py +++ b/tests/plugins/test_disk_cleanup_plugin.py @@ -195,6 +195,28 @@ class TestTrackForgetQuick: for d in ("logs", "memories", "sessions", "cron", "cache"): assert (_isolate_env / d).exists(), f"{d}/ should be preserved" + def test_quick_preserves_git_internal_dirs_and_removes_normal_empty_dirs(self, _isolate_env): + dg = _load_lib() + checkpoint_heads = _isolate_env / "checkpoints" / "demo" / "refs" / "heads" + checkpoint_info = _isolate_env / "checkpoints" / "demo" / "objects" / "info" + repo_heads = _isolate_env / "scratch" / "repo" / ".git" / "refs" / "heads" + repo_info = _isolate_env / "scratch" / "repo" / ".git" / "objects" / "info" + plain_empty = _isolate_env / "scratch" / "empty-dir" + + for p in (checkpoint_heads, checkpoint_info, repo_heads, repo_info): + p.mkdir(parents=True, exist_ok=True) + (_isolate_env / "checkpoints" / "demo" / "HERMES_WORKDIR").write_text("/tmp/demo\n") + plain_empty.mkdir(parents=True) + + summary = dg.quick() + + assert checkpoint_heads.exists() + assert checkpoint_info.exists() + assert repo_heads.exists() + assert repo_info.exists() + assert not plain_empty.exists() + assert summary["empty_dirs"] == 1 + class TestStatus: def test_empty_status(self, _isolate_env):