From 1bbe53546124c00e4238972f1599a91fbf8c6539 Mon Sep 17 00:00:00 2001 From: ChiFungHillmanChan Date: Tue, 15 Sep 2026 18:46:09 +0100 Subject: [PATCH] feat(cli): add 'project remove' to unregister a project The project registry in serena_config.yml can be added to from the CLI ('project create', 'project index') and from the MCP side ('activate_project'), but it could only be removed from on the MCP side, via the 'remove_project' tool. From a terminal, hand-editing the file was the only way to drop an entry. 'serena project remove ' takes the same argument type as 'project index', so a project can be addressed by name or by path. Only the registry entry is removed; the project's own files, including its project configuration, are left on disk. Adds SerenaConfig.remove_registered_project, which mirrors add_registered_project and removes the given entry rather than the first entry whose name matches. Resolving a path to an entry and then deleting by name would remove the wrong project when two registered directories carry the same project_name. Refs #2029 --- CHANGELOG.md | 3 + src/serena/cli.py | 21 +++++ src/serena/config/serena_config.py | 15 +++ test/serena/test_cli_project_remove.py | 123 +++++++++++++++++++++++++ 4 files changed, 162 insertions(+) create mode 100644 test/serena/test_cli_project_remove.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 56e3b8c4..a202ce84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,9 @@ Status of the `main` branch. Changes prior to the next official version change w exited 0 even when `FindReferencingSymbolsTool` had raised, because that failure was logged as a warning while the verdict checked `FindSymbolTool` only. A reference-search failure now fails the check; a symbol with no references is still a pass + - Add `project remove`, which unregisters a project from the project list in `serena_config.yml`, + addressed either by name or by path. Only the registry entry is removed; the project's own files, + including its project configuration, are left untouched (#2029) * Tools: - Fix: the file-editing tools saved the edited file with `open(path, "w")`, which truncates it diff --git a/src/serena/cli.py b/src/serena/cli.py index ab41b61a..282ec0ca 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -766,6 +766,27 @@ class ProjectCommands(AutoRegisteringGroup): except ValueError as e: raise click.ClickException(str(e)) + @staticmethod + @click.command( + "remove", + help="Remove a project from Serena's project registry. " + "The project's own files, including its project configuration, are left untouched.", + context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, + ) + @click.argument("project", type=PROJECT_TYPE) + def remove(project: str) -> None: + serena_config = SerenaConfig.from_config_file() + registered_project_names = serena_config.project_names + try: + registered_project = serena_config.get_registered_project(project) + except ValueError as e: + # raised when the name is ambiguous; the message names the candidate locations + raise click.ClickException(str(e)) + if registered_project is None: + raise click.ClickException(f"No registered project found for '{project}'; registered project names: {registered_project_names}") + serena_config.remove_registered_project(registered_project) + click.echo(f"Removed project '{registered_project.project_name}' ({registered_project.project_root}) from the project registry.") + @staticmethod @click.command( "index", diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index bc975d73..6e0ace54 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -1247,6 +1247,21 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): self.projects.append(registered_project) self._persist_projects() + def remove_registered_project(self, registered_project: RegisteredProject) -> None: + """ + Removes the given registered project, persisting the updated project list. + Only the registry entry is removed; the project's own files, including its project + configuration file, are left untouched. + + Unlike :meth:`remove_project`, which resolves the project by name, this removes the + given entry itself and is therefore unambiguous when several registered projects + share a name. + + :param registered_project: the project to remove, which must be an element of :attr:`projects` + """ + self.projects.remove(registered_project) + self._persist_projects() + def add_project_from_path(self, project_root: Path | str, asynchronous_autogen: bool = False) -> "Project": """ Adds a new project to the Serena configuration from a given path, auto-generating the project diff --git a/test/serena/test_cli_project_remove.py b/test/serena/test_cli_project_remove.py new file mode 100644 index 00000000..a0e64066 --- /dev/null +++ b/test/serena/test_cli_project_remove.py @@ -0,0 +1,123 @@ +"""Tests for the CLI's ``project remove`` command.""" + +import shutil +import tempfile +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from serena.cli import ProjectCommands +from serena.config.serena_config import SerenaConfig +from serena.constants import SERENA_MANAGED_DIR_NAME + + +class TestProjectRemove: + """ + Drives ``serena project remove`` against a temporary Serena configuration file, so the + user's real project registry is never touched. + """ + + @pytest.fixture(autouse=True) + def setup(self, monkeypatch): + self.test_dir = Path(tempfile.mkdtemp()) + self.master_config_path = self.test_dir / "serena_config.yml" + monkeypatch.setattr( + SerenaConfig, + "_determine_config_file_path", + classmethod(lambda cls: str(self.master_config_path)), + ) + self.runner = CliRunner() + + def teardown_method(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _make_project_dir(self, dir_name: str, project_name: str | None = None) -> Path: + project_dir = self.test_dir / dir_name + (project_dir / SERENA_MANAGED_DIR_NAME).mkdir(parents=True) + (project_dir / SERENA_MANAGED_DIR_NAME / "project.yml").write_text( + f'project_name: "{project_name or dir_name}"\nlanguages: ["python"]\n' + ) + return project_dir + + def _write_master_config(self, project_paths: list[Path]) -> None: + self.master_config_path.write_text("projects:\n" + "".join(f" - {p}\n" for p in project_paths)) + + def _registered_roots(self) -> set[Path]: + config = SerenaConfig.from_config_file(generate_if_missing=False) + return {Path(project.project_root) for project in config.projects} + + def test_remove_by_name_unregisters_only_that_project(self): + keep = self._make_project_dir("keep") + drop = self._make_project_dir("drop") + self._write_master_config([keep, drop]) + + result = self.runner.invoke(ProjectCommands.remove, ["drop"]) + + assert result.exit_code == 0, f"Command failed: {result.output}" + assert self._registered_roots() == {keep.resolve()} + + def test_remove_by_path_unregisters_only_that_project(self): + keep = self._make_project_dir("keep") + drop = self._make_project_dir("drop") + self._write_master_config([keep, drop]) + + result = self.runner.invoke(ProjectCommands.remove, [str(drop)]) + + assert result.exit_code == 0, f"Command failed: {result.output}" + assert self._registered_roots() == {keep.resolve()} + + def test_remove_names_the_project_it_removed(self): + drop = self._make_project_dir("drop") + self._write_master_config([drop]) + + result = self.runner.invoke(ProjectCommands.remove, [str(drop)]) + + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "drop" in result.output + assert str(drop.resolve()) in result.output + + def test_remove_keeps_the_project_configuration_file_on_disk(self): + """Unregistering must not delete the project's own files; re-registering it must remain possible.""" + drop = self._make_project_dir("drop") + self._write_master_config([drop]) + + result = self.runner.invoke(ProjectCommands.remove, [str(drop)]) + + assert result.exit_code == 0, f"Command failed: {result.output}" + assert (drop / SERENA_MANAGED_DIR_NAME / "project.yml").is_file() + + def test_remove_unknown_project_fails_without_changing_the_registry(self): + keep = self._make_project_dir("keep") + self._write_master_config([keep]) + + result = self.runner.invoke(ProjectCommands.remove, ["no_such_project"]) + + assert result.exit_code != 0 + assert "no_such_project" in result.output + assert self._registered_roots() == {keep.resolve()} + + def test_remove_by_path_picks_the_entry_at_that_path_when_names_collide(self): + """Two directories may carry the same ``project_name``; a path must remove the entry at that path.""" + first = self._make_project_dir("first_dir", project_name="twin") + second = self._make_project_dir("second_dir", project_name="twin") + self._write_master_config([first, second]) + + result = self.runner.invoke(ProjectCommands.remove, [str(second)]) + + assert result.exit_code == 0, f"Command failed: {result.output}" + assert self._registered_roots() == {first.resolve()} + + def test_remove_by_ambiguous_name_fails_with_a_message_rather_than_a_traceback(self): + first = self._make_project_dir("first_dir", project_name="twin") + second = self._make_project_dir("second_dir", project_name="twin") + self._write_master_config([first, second]) + + result = self.runner.invoke(ProjectCommands.remove, ["twin"]) + + assert result.exit_code != 0 + assert result.exception is None or isinstance(result.exception, SystemExit), ( + f"Expected a handled CLI error, got: {result.exception!r}" + ) + assert "twin" in result.output + assert self._registered_roots() == {first.resolve(), second.resolve()}