diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 4ba5c473..5ff9b8c4 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -958,6 +958,8 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): the path to the configuration file to which updates of the configuration shall be saved; if None, the configuration is not saved to disk """ + _projects_at_load: set[str] = field(default_factory=set, repr=False) + """Project roots present when this configuration instance was last persisted.""" # *** static members *** @@ -1096,6 +1098,8 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): ) instance.projects.append(project) + instance._projects_at_load = {str(project.project_root) for project in instance.projects} + # determine language backend language_backend = get_dataclass_default(SerenaConfig, "language_backend") if "language_backend" in loaded_commented_yaml: @@ -1295,22 +1299,38 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): self._persist_projects() def _persist_projects(self) -> None: - """ - Persists the list of registered projects, merging it with the list currently found on disk - (parallel agent instances may have added or removed projects in the meantime). + """Persist this instance's project-list changes without undoing concurrent edits. + + The disk copy is reloaded because multiple agent processes may update the global + configuration concurrently. The instance baseline lets us distinguish this instance's + removals and additions from changes made by another process: unchanged baseline projects + follow the current disk copy, while removed baseline projects are filtered out and newly + added instance projects are appended. """ if self.config_file_path is None: return + persisted = SerenaConfig.from_config_file() + current_projects_by_path = {str(project.project_root): project for project in self.projects} + current_paths = set(current_projects_by_path) + removed_paths = self._projects_at_load - current_paths + added_paths = current_paths - self._projects_at_load + combined_projects = [] handled_project_paths = set() - for p in persisted.projects + self.projects: - str_path = str(p.project_root) - if str_path not in handled_project_paths: - combined_projects.append(p) - handled_project_paths.add(str_path) + for project in persisted.projects: + project_path = str(project.project_root) + if project_path not in removed_paths: + combined_projects.append(project) + handled_project_paths.add(project_path) + for project_path in added_paths: + if project_path not in handled_project_paths: + combined_projects.append(current_projects_by_path[project_path]) + handled_project_paths.add(project_path) + persisted.projects = combined_projects persisted._save() + self._projects_at_load = current_paths def _save(self) -> None: """ diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index cc04ab45..529667f7 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -604,6 +604,48 @@ class TestSerenaConfigLoadSave: assert len(resulting_config.projects) == 4 + def test_remove_project_persists_across_reload(self): + """Removing a project must update the on-disk project registry.""" + p1 = self._make_project_dir("project1", 'project_name: "project1"\nlanguages: ["python"]\n') + p2 = self._make_project_dir("project2", 'project_name: "project2"\nlanguages: ["python"]\n') + self._write_master_config([p1, p2]) + + config = SerenaConfig.from_config_file(generate_if_missing=False) + config.remove_project("project2") + + reloaded = SerenaConfig.from_config_file(generate_if_missing=False) + assert [project.project_config.project_name for project in reloaded.projects] == ["project1"] + + def test_remove_project_preserves_concurrent_addition(self): + """A removal must not discard a project added by another config instance.""" + p1 = self._make_project_dir("project1", 'project_name: "project1"\nlanguages: ["python"]\n') + p2 = self._make_project_dir("project2", 'project_name: "project2"\nlanguages: ["python"]\n') + p3 = self._make_project_dir("project3", 'project_name: "project3"\nlanguages: ["python"]\n') + self._write_master_config([p1, p2]) + + removing_config = SerenaConfig.from_config_file(generate_if_missing=False) + adding_config = SerenaConfig.from_config_file(generate_if_missing=False) + adding_config.add_project_from_path(p3) + removing_config.remove_project("project2") + + reloaded = SerenaConfig.from_config_file(generate_if_missing=False) + assert {project.project_config.project_name for project in reloaded.projects} == {"project1", "project3"} + + def test_add_project_preserves_concurrent_removal(self): + """An addition must not resurrect a project removed by another config instance.""" + p1 = self._make_project_dir("project1", 'project_name: "project1"\nlanguages: ["python"]\n') + p2 = self._make_project_dir("project2", 'project_name: "project2"\nlanguages: ["python"]\n') + p3 = self._make_project_dir("project3", 'project_name: "project3"\nlanguages: ["python"]\n') + self._write_master_config([p1, p2]) + + removing_config = SerenaConfig.from_config_file(generate_if_missing=False) + adding_config = SerenaConfig.from_config_file(generate_if_missing=False) + removing_config.remove_project("project2") + adding_config.add_project_from_path(p3) + + reloaded = SerenaConfig.from_config_file(generate_if_missing=False) + assert {project.project_config.project_name for project in reloaded.projects} == {"project1", "project3"} + class TestGetRegisteredProjectWithDanglingProject: """A registered project whose root directory was deleted (e.g. a removed git