Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 43 additions & 20 deletions plugins/disk-cleanup/disk_cleanup.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,17 @@ def save_tracked(tracked: List[Dict[str, Any]]) -> None:
"chrome-profile", "cron-output", "other",
}

_EMPTY_DIR_PROTECTED_TOP_LEVEL = frozenset({
"logs", "memories", "sessions", "cron", "cronjobs",
"cache", "skills", "plugins", "disk-cleanup", "optional-skills",
"hermes-agent", "backups", "profiles", ".worktrees",
})

_EMPTY_DIR_SWEEP_PRUNE_DIRS = frozenset({
".git", "node_modules", "venv", ".venv",
"site-packages", "__pycache__",
})


# Paths under $HERMES_HOME that must NEVER be deleted by quick(),
# regardless of what the stored category says. This is a defense-in-depth
Expand Down Expand Up @@ -348,36 +359,48 @@ def quick() -> Dict[str, Any]:
else:
new_tracked.append(item)

# Remove empty dirs under HERMES_HOME (but leave HERMES_HOME itself and
# a short list of well-known top-level state dirs alone — a fresh install
# has these empty, and deleting them would surprise the user).
# Remove empty dirs under HERMES_HOME, but never recurse into known
# durable state trees. Some installs place the Hermes checkout, venv,
# and desktop build under HERMES_HOME; a full rglob over that tree can
# stall the gateway event loop for minutes.
hermes_home = get_hermes_home()
_PROTECTED_TOP_LEVEL = {
"logs", "memories", "sessions", "cron", "cronjobs",
"cache", "skills", "plugins", "disk-cleanup", "optional-skills",
"hermes-agent", "backups", "profiles", ".worktrees",
}
empty_removed = 0
sweep_stack: List[Tuple[Path, bool]] = []
try:
for dirpath in sorted(hermes_home.rglob("*"), reverse=True):
if not dirpath.is_dir() or dirpath == hermes_home:
continue
try:
rel_parts = dirpath.relative_to(hermes_home).parts
except ValueError:
continue
# Skip the well-known top-level state dirs themselves.
if len(rel_parts) == 1 and rel_parts[0] in _PROTECTED_TOP_LEVEL:
continue
for top in hermes_home.iterdir():
if (
top.is_dir()
and not top.is_symlink()
and top.name not in _EMPTY_DIR_PROTECTED_TOP_LEVEL
and top.name not in _EMPTY_DIR_SWEEP_PRUNE_DIRS
):
sweep_stack.append((top, False))
except OSError:
sweep_stack = []

while sweep_stack:
dirpath, visited = sweep_stack.pop()
if visited:
try:
if not any(dirpath.iterdir()):
dirpath.rmdir()
empty_removed += 1
_log(f"DELETED: {dirpath} (empty dir)")
except OSError:
pass
except OSError:
pass
continue

sweep_stack.append((dirpath, True))
try:
for child in dirpath.iterdir():
if (
child.is_dir()
and not child.is_symlink()
and child.name not in _EMPTY_DIR_SWEEP_PRUNE_DIRS
):
sweep_stack.append((child, False))
except OSError:
pass

save_tracked(new_tracked)
_log(
Expand Down
31 changes: 31 additions & 0 deletions tests/plugins/test_disk_cleanup_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,37 @@ def test_quick_preserves_protected_top_level_dirs(self, _isolate_env):
for d in ("logs", "memories", "sessions", "cron", "cache"):
assert (_isolate_env / d).exists(), f"{d}/ should be preserved"

def test_quick_does_not_descend_into_protected_top_level_dirs(
self, _isolate_env, monkeypatch
):
dg = _load_lib()
protected_empty = (
_isolate_env / "hermes-agent" / "node_modules" / "pkg" / "empty"
)
protected_empty.mkdir(parents=True)

original_iterdir = Path.iterdir

def guarded_iterdir(path):
if path == _isolate_env / "hermes-agent":
raise AssertionError("quick() descended into protected hermes-agent/")
return original_iterdir(path)

monkeypatch.setattr(Path, "iterdir", guarded_iterdir)

dg.quick()

assert protected_empty.exists()

def test_quick_removes_empty_dirs_in_managed_subtrees(self, _isolate_env):
dg = _load_lib()
managed_empty = _isolate_env / "scratch" / "nested" / "empty"
managed_empty.mkdir(parents=True)

dg.quick()

assert not (_isolate_env / "scratch").exists()


class TestStatus:
def test_empty_status(self, _isolate_env):
Expand Down
Loading