diff --git a/CHANGELOG.md b/CHANGELOG.md index 29582122..891649d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,7 +72,10 @@ Status of the `main` branch. Changes prior to the next official version change w - Tray manager: Add NixOS-support for AppIndicator-based trays (e.g., most Wayland-trays) to the package in flake.nix. - Fix: Wait for the subprocess that opens the browser window, preventing zombie processes #1488 -Dependencies: +* Memories: + - Make memory iteration follow symbolic links + +* Dependencies: - Add dependency `oslex` # v1.5.3 (2026-05-26) diff --git a/src/serena/memories/memory_manager.py b/src/serena/memories/memory_manager.py index 76c24447..60e5e7d8 100644 --- a/src/serena/memories/memory_manager.py +++ b/src/serena/memories/memory_manager.py @@ -2,7 +2,8 @@ import logging import os import re import shutil -from collections.abc import Sequence +import sys +from collections.abc import Iterator, Sequence from pathlib import Path from typing import Literal @@ -233,11 +234,27 @@ class MemoryManager: def get_full_list(self) -> list[str]: return sorted(self.memories + self.read_only_memories) + @staticmethod + def _iter_memory_files(search_dir: Path) -> Iterator[Path]: + """Yields every ``*.md`` file under ``search_dir``, descending into symlinked directories. + + Directory symlinks must be followed so that memories shared via symlink are discovered + (e.g. a monorepo whose ``.serena/memories`` folder symlinks each submodule's memory + directory, making them addressable as ``/``). ``Path.rglob`` only + follows symlinked directories from Python 3.13 onwards, via ``recurse_symlinks``; on + 3.11/3.12 it silently skips them, so we fall back to ``os.walk(followlinks=True)`` there. + ``rglob`` is preferred when available as it is faster than ``os.walk``. + """ + for root, _dirs, files in os.walk(search_dir, followlinks=True): + for filename in files: + if filename.endswith(".md"): + yield Path(root) / filename + def _list_memories(self, search_dir: Path, base_dir: Path, prefix: str = "") -> MemoriesList: result = self.MemoriesList() if not search_dir.exists(): return result - for md_file in search_dir.rglob("*.md"): + for md_file in self._iter_memory_files(search_dir): rel = str(md_file.relative_to(base_dir).with_suffix("")).replace(os.sep, "/") memory_name = prefix + rel if self._is_ignored_memory(memory_name): diff --git a/test/serena/test_memories_manager.py b/test/serena/test_memories_manager.py index 2ba70196..79be6832 100644 --- a/test/serena/test_memories_manager.py +++ b/test/serena/test_memories_manager.py @@ -269,6 +269,40 @@ def _write(manager: MemoryManager, name: str, content: str) -> None: manager.save_memory(name, content, is_tool_context=False) +class TestListMemoriesFollowsSymlinks: + """Regression: memories reachable only through a directory symlink (e.g. a monorepo whose + ``.serena/memories`` symlinks each submodule's memory folder, making them addressable as + ``/``) must be listed and readable. ``Path.rglob`` does not descend into + symlinked directories before Python 3.13, which silently hid them from ``list_memories``. + """ + + def test_lists_and_reads_memories_behind_directory_symlink(self, tmp_path) -> None: + project_dir = tmp_path / "project" / ".serena" + manager = MemoryManager(serena_data_folder=project_dir) + _write(manager, "own_memory", "# local") + + # a sibling submodule with its own (possibly nested) memories + submodule_memories = tmp_path / "submodule" / ".serena" / "memories" + (submodule_memories / "nested").mkdir(parents=True) + (submodule_memories / "sub_a.md").write_text("# a", encoding="utf-8") + (submodule_memories / "nested" / "sub_b.md").write_text("# b", encoding="utf-8") + + # symlink the submodule's memory dir into the project's memories folder + link = project_dir / "memories" / "submodule" + try: + link.symlink_to(submodule_memories, target_is_directory=True) + except (OSError, NotImplementedError) as e: + pytest.skip(f"cannot create directory symlinks on this platform/permissions: {e}") + + listed = manager.list_memories().get_full_list() + assert "own_memory" in listed + assert "submodule/sub_a" in listed + assert "submodule/nested/sub_b" in listed + # the listed names must round-trip through the read path (which resolves the symlink) + assert manager.load_memory("submodule/sub_a") == "# a" + assert manager.load_memory("submodule/nested/sub_b") == "# b" + + class TestValidateReferentialIntegrity: def test_clean_report_when_all_references_resolve(self, fs_manager: MemoryManager) -> None: _write(fs_manager, "auth", "# auth notes")