Files
hermes-agent/hermes_cli/data_cleanup.py
ethernet bbec973514 refactor(pm): pm owns the dependency-environment layout and interpreter paths
hermes_cli.runtime_paths (venv generations, selection, activation) moves to
pm.environments, and gains venv_bin_dir / venv_python / project_python. Every
in-tree caller asks pm for an interpreter now; pm no longer reaches back into
hermes_cli for its own environment layout (pm.packages, pm.extras, pm.ensure,
pm.paths imported hermes_cli.runtime_paths). The three open-coded
"Scripts/python.exe or bin/python" ladders in pm collapse onto venv_python.

hermes_constants.venv_python_path / venv_bin_dir and hermes_cli.runtime_paths
stay as frozen-updater-surface shims only (tests/compat/old_updater_surface.json).

To keep the boot path light, pm/__init__ resolves its facade lazily (PEP 562)
and pm.registry loads the built-in package definitions on first read instead of
at import: `import hermes_bootstrap` now loads pm + pm.environments only (25ms,
was 37ms with the eager facade dragging in the downloader). The stripped-payload
fixtures that ship only pre-import files keep working for the same reason.

Also restores two frozen-surface re-exports the F401 sweep dropped
(banner._github_compare_behind, cua_backend.resolve_cua_driver_cmd).
2026-09-18 20:02:36 -04:00

104 lines
4.0 KiB
Python

"""Data-only deletion separates user state from the runtime that must survive."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import stat
from pm.filesystem import is_junction
@dataclass(frozen=True)
class DataRemovalPlan:
home: Path
remove: tuple[Path, ...]
keep: tuple[Path, ...]
def plan_data_removal(home: Path, project: Path, userdata: Path | None = None) -> DataRemovalPlan:
from hermes_constants import get_default_hermes_root
from pm.environments import base_venv, installs_root, store_root
from hermes_cli.steward import is_bundled_payload
from tools.checkpoint_pruning import store_lock_path
home = home.resolve()
if home == Path(home.anchor) or home == Path.home().resolve():
raise ValueError(f"refusing to erase an entire filesystem or user home: {home}")
machine = get_default_hermes_root(home=home).resolve()
protected = {
project.resolve(), base_venv(project).resolve(), store_root(project).resolve(),
installs_root().resolve(), machine / "tools", machine / "bin", machine / "cache",
machine / "spawn-ledger.json", home / "gateway.lock", home / ".backup.lock",
home / "runtime" / "active_sessions.lock", home / "cron" / ".tick.lock",
store_lock_path(home / "checkpoints"),
}
if home == machine:
protected.add(machine / "profiles")
if is_bundled_payload(project):
from hermes_cli.bundled_app import NotBundledApp, resolve_bundle_layout
try:
protected.add(resolve_bundle_layout(project).app_root)
except NotBundledApp as exc:
raise ValueError(str(exc)) from exc
# The canonical source installation may coexist with the invoking package.
canonical = home / "hermes-agent"
if (canonical / "hermes_cli" / "__init__.py").is_file():
protected.add(canonical)
remove: list[Path] = []
keep: set[Path] = set()
def visit(path: Path) -> None:
try:
mode = path.lstat().st_mode
except FileNotFoundError:
return
resolved = path.resolve()
if any(path == root or resolved == root or path.is_relative_to(root) for root in protected):
keep.add(path)
elif path.is_symlink() or is_junction(path):
remove.append(path) # Remove the directory entry, never its target.
elif any(root.is_relative_to(path) for root in protected):
if stat.S_ISDIR(mode):
for child in sorted(path.iterdir()):
visit(child)
else:
keep.add(path)
else:
remove.append(path)
if home.is_dir():
for child in sorted(home.iterdir()):
visit(child)
if home != machine:
userdata = None # Desktop preferences belong to the app, not a named profile.
if userdata is not None and not userdata.resolve().is_relative_to(home):
if userdata.resolve() == Path.home().resolve() or home.is_relative_to(userdata.resolve()):
raise ValueError(f"desktop data directory must not contain the Hermes or user home: {userdata}")
visit(userdata.parent.resolve() / userdata.name)
return DataRemovalPlan(home, tuple(remove), tuple(sorted(keep)))
def remove_data(plan: DataRemovalPlan) -> tuple[list[Path], list[tuple[Path, str]]]:
import shutil
removed: list[Path] = []
failed: list[tuple[Path, str]] = []
for path in plan.remove:
try:
if path.parent.resolve() != path.parent:
raise OSError("parent directory changed after confirmation; refusing to follow a link")
mode = path.lstat().st_mode
if path.is_symlink() or not stat.S_ISDIR(mode):
path.unlink()
elif is_junction(path):
path.rmdir()
else:
shutil.rmtree(path)
removed.append(path)
except FileNotFoundError:
continue
except OSError as exc:
failed.append((path, str(exc)))
return removed, failed