mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 06:17:58 +00:00
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:
1 parent
16322651bb
commit
1bbe535461
4 files changed
+162
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
Reference in new issue
Block a user