diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index bf5d79b0b8..794400fff6 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -71,6 +71,29 @@ _CLONE_ALL_STRIP = [ "processes.json", ] + +def _clone_all_copytree_ignore(source_dir: Path): + """Ignore ``profiles/`` at the root of *source_dir* only. + + ``~/.hermes`` contains ``profiles//`` for sibling named profiles. + ``shutil.copytree`` would otherwise duplicate that entire tree inside the + new profile (recursive ``.../profiles/.../profiles/...``). Export already + excludes ``profiles`` via ``_DEFAULT_EXPORT_EXCLUDE_ROOT`` — match that + behavior for ``--clone-all``. + """ + source_resolved = source_dir.resolve() + + def _ignore(directory: str, names: List[str]) -> List[str]: + try: + if Path(directory).resolve() == source_resolved: + return [n for n in names if n == "profiles"] + except (OSError, ValueError): + pass + return [] + + return _ignore + + # Directories/files to exclude when exporting the default (~/.hermes) profile. # The default profile contains infrastructure (repo checkout, worktrees, DBs, # caches, binaries) that named profiles don't have. We exclude those so the @@ -424,8 +447,12 @@ def create_profile( ) if clone_all and source_dir: - # Full copy of source profile - shutil.copytree(source_dir, profile_dir) + # Full copy of source profile (exclude sibling ~/.hermes/profiles/) + shutil.copytree( + source_dir, + profile_dir, + ignore=_clone_all_copytree_ignore(source_dir), + ) # Strip runtime files for stale in _CLONE_ALL_STRIP: (profile_dir / stale).unlink(missing_ok=True) diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index b8876a764c..a285dca545 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -171,6 +171,23 @@ class TestCreateProfile: assert not (profile_dir / "gateway_state.json").exists() assert not (profile_dir / "processes.json").exists() + def test_clone_all_excludes_sibling_profiles_tree(self, profile_env): + """--clone-all from default ~/.hermes must not copy profiles/* (nested explosion).""" + tmp_path = profile_env + default_home = tmp_path / ".hermes" + profiles_root = default_home / "profiles" + profiles_root.mkdir(exist_ok=True) + (profiles_root / "other").mkdir(parents=True, exist_ok=True) + (profiles_root / "other" / "marker.txt").write_text("sibling data") + + (default_home / "memories").mkdir(exist_ok=True) + (default_home / "memories" / "note.md").write_text("remember this") + + profile_dir = create_profile("coder", clone_all=True, no_alias=True) + + assert (profile_dir / "memories" / "note.md").read_text() == "remember this" + assert not (profile_dir / "profiles").exists() + def test_clone_config_missing_files_skipped(self, profile_env): """Clone config gracefully skips files that don't exist in source.""" profile_dir = create_profile("coder", clone_config=True, no_alias=True)