From 4160732c7d2fc2bc37695e8262494e49477dd8f3 Mon Sep 17 00:00:00 2001 From: Andrew Newell Date: Tue, 23 Jun 2026 15:11:52 -0400 Subject: [PATCH] Fix dangling registered project poisoning project lookup/activation A registered project whose root directory was deleted (e.g. a removed git worktree) would raise FileNotFoundError from RegisteredProject.matches_root_path (via Path.samefile). Because get_registered_project iterates matches_root_path over every registered project, one dangling entry aborted activation of all other, valid projects until an MCP server restart. matches_root_path now treats a non-existent directory as a non-match instead of letting the filesystem error propagate. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 ++ src/serena/config/serena_config.py | 9 +++-- test/serena/config/test_serena_config.py | 43 ++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74728305..48c723af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Status of the `main` branch. Changes prior to the next official version change w - Improve quoting/escaping of arguments in shell executions on Windows (via `oslex` dependency) - Add tool parameter alias support, adding `name_path` as an alias for `name_path_pattern` in `find_symbol` tools - Make tool call errors surface explicitly as errors at the MCP protocol level + - Fix: a registered project whose root directory was deleted while Serena was already running could break + `activate_project`/project lookup, raising `FileNotFoundError` in `RegisteredProject.matches_root_path` * Language Servers: - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 8f2c3522..aaf17fd4 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -705,9 +705,14 @@ class RegisteredProject(ToStringMixin): Check if the given path matches the project root path. :param path: the path to check - :return: True if the path matches the project root, False otherwise + :return: True if the path matches the project root, False otherwise (including the case + where this project's root directory no longer exists, e.g. a removed git worktree) """ - return self.project_root.samefile(Path(path).resolve()) + try: + return self.project_root.samefile(Path(path).resolve()) + except OSError: + # typically raised if the path does not exist (e.g., a removed git worktree) + return False def get_project_instance(self, serena_config: "SerenaConfig") -> "Project": """ diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index 2b0a4d52..4848e8a1 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -569,6 +569,49 @@ class TestSerenaConfigFromConfigFileRobustness: ) +class TestGetRegisteredProjectWithDanglingProject: + """A registered project whose root directory was deleted (e.g. a removed git + worktree) must not break lookup/activation of other, valid projects. + + Reproduces the bug where ``get_registered_project`` iterates over every + registered project and ``RegisteredProject.matches_root_path`` raises + ``FileNotFoundError`` for the dangling project, aborting the whole lookup. + """ + + def setup_method(self): + self.test_dir = Path(tempfile.mkdtemp()) + + def teardown_method(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _make_project_dir(self, name: str) -> Path: + project_dir = self.test_dir / name + (project_dir / SERENA_MANAGED_DIR_NAME).mkdir(parents=True) + (project_dir / SERENA_MANAGED_DIR_NAME / "project.yml").write_text(f'project_name: "{name}"\nlanguages: ["python"]\n') + return project_dir + + def test_dangling_project_does_not_break_lookup_of_valid_project(self): + config = create_default_serena_config() + dangling_dir = self._make_project_dir("dangling_project") + valid_dir = self._make_project_dir("valid_project") + + # Register the dangling project FIRST so the path-matching loop hits the + # deleted directory before reaching the valid project. + config.projects = [ + RegisteredProject.from_project_root(dangling_dir, serena_config=config), + RegisteredProject.from_project_root(valid_dir, serena_config=config), + ] + + # Delete the dangling project's directory out from under Serena. + shutil.rmtree(dangling_dir) + + # Looking up the valid project by path must succeed, not raise + # FileNotFoundError on the deleted dangling project's path. + result = config.get_registered_project(str(valid_dir)) + assert result is not None + assert result.project_name == "valid_project" + + class TestMemoriesManagerCustomPath: """Tests for MemoriesManager with a custom serena data folder."""