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 <PROJECT_NAME|PROJECT_PATH>' 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
This commit is contained in:
ChiFungHillmanChan authored and Dr. Dominik Jain committed 2026-09-16 02:25:20 +02:00
1 parent 16322651bb
commit 1bbe535461
4 files changed
+162

No files matched your search

+3
View File
@@ -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
+21
View File
@@ -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",
+15
View File
@@ -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
+123
View File
@@ -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()}