diff --git a/src/serena/cli.py b/src/serena/cli.py index 73aeb6c9..33595e70 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -637,7 +637,7 @@ class ProjectCommands(AutoRegisteringGroup): lvl = logging.getLevelNamesMapping()[log_level.upper()] logging.configure(level=lvl) serena_config = SerenaConfig.from_config_file() - proj = registered_project.get_project_instance() + proj = registered_project.get_project_instance(serena_config=serena_config) click.echo(f"Indexing symbols in {proj} …") ls_mgr = proj.create_language_server_manager( log_level=lvl, ls_timeout=timeout, ls_specific_settings=serena_config.ls_specific_settings @@ -690,7 +690,8 @@ class ProjectCommands(AutoRegisteringGroup): :param path: The path to check. :param project: The path to the project directory, defaults to the current working directory. """ - proj = Project.load(os.path.abspath(project)) + serena_config = SerenaConfig.from_config_file() + proj = Project.load(os.path.abspath(project), serena_config=serena_config) if os.path.isabs(path): path = os.path.relpath(path, start=proj.project_root) is_ignored = proj.is_ignored_path(path) @@ -712,7 +713,8 @@ class ProjectCommands(AutoRegisteringGroup): :param project: path to the project directory, defaults to the current working directory. :param verbose: if set, prints detailed information about the indexed symbols. """ - proj = Project.load(os.path.abspath(project)) + serena_config = SerenaConfig.from_config_file() + proj = Project.load(os.path.abspath(project), serena_config=serena_config) if os.path.isabs(file): file = os.path.relpath(file, start=proj.project_root) if proj.is_ignored_path(file, ignore_non_source_files=True): @@ -749,7 +751,8 @@ class ProjectCommands(AutoRegisteringGroup): # NOTE: completely written by Claude Code, only functionality was reviewed, not implementation logging.configure(level=logging.INFO) project_path = os.path.abspath(project) - proj = Project.load(project_path) + serena_config = SerenaConfig.from_config_file() + proj = Project.load(project_path, serena_config=serena_config) # Create log file with timestamp timestamp = datetime_tag() diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 5f6b3fe6..6599a591 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -397,12 +397,18 @@ class ProjectConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMi class RegisteredProject(ToStringMixin): - def __init__(self, project_root: str, project_config: "ProjectConfig", project_instance: Optional["Project"] = None) -> None: + def __init__( + self, + project_root: str, + project_config: "ProjectConfig", + project_instance: Optional["Project"] = None, + ) -> None: """ Represents a registered project in the Serena configuration. :param project_root: the root directory of the project :param project_config: the configuration of the project + :param project_instance: an existing project instance (if already loaded) """ self.project_root = Path(project_root).resolve() self.project_config = project_config @@ -440,7 +446,7 @@ class RegisteredProject(ToStringMixin): """ return self.project_root.samefile(Path(path).resolve()) - def get_project_instance(self) -> "Project": + def get_project_instance(self, serena_config: "SerenaConfig | None") -> "Project": """ Returns the project instance for this registered project, loading it if necessary. """ @@ -448,7 +454,11 @@ class RegisteredProject(ToStringMixin): from ..project import Project with LogTime(f"Loading project instance for {self}", logger=log): - self._project_instance = Project(project_root=str(self.project_root), project_config=self.project_config) + self._project_instance = Project( + project_root=str(self.project_root), + project_config=self.project_config, + serena_config=serena_config, + ) return self._project_instance @@ -493,6 +503,10 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix ls_specific_settings: dict = field(default_factory=dict) """Advanced configuration option allowing to configure language server implementation specific options, see SolidLSPSettings for more info.""" + ignored_paths: list[str] = field(default_factory=list) + """List of paths to ignore across all projects. Same syntax as gitignore, so you can use * and **. + These patterns are merged additively with each project's own ignored_paths.""" + # settings with overridden defaults default_modes: Sequence[str] | None = ("interactive", "editing") @@ -724,7 +738,7 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix if registered_project is None: return None else: - return registered_project.get_project_instance() + return registered_project.get_project_instance(serena_config=self) def add_registered_project(self, registered_project: RegisteredProject) -> None: """ @@ -758,7 +772,12 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix project_config = ProjectConfig.load(project_root, autogenerate=True) - new_project = Project(project_root=str(project_root), project_config=project_config, is_newly_created=True) + new_project = Project( + project_root=str(project_root), + project_config=project_config, + is_newly_created=True, + serena_config=self, + ) self.add_registered_project(RegisteredProject.from_project_instance(new_project)) return new_project diff --git a/src/serena/project.py b/src/serena/project.py index ea3674ad..c176dddb 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -3,7 +3,7 @@ import logging import os import threading from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any import pathspec from sensai.util.logging import LogTime @@ -18,6 +18,9 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import FileUtils +if TYPE_CHECKING: + from serena.config.serena_config import SerenaConfig + log = logging.getLogger(__name__) @@ -56,7 +59,13 @@ class MemoriesManager: class Project(ToStringMixin): - def __init__(self, project_root: str, project_config: ProjectConfig, is_newly_created: bool = False): + def __init__( + self, + project_root: str, + project_config: ProjectConfig, + is_newly_created: bool = False, + serena_config: "SerenaConfig | None" = None, + ): self.project_root = project_root self.project_config = project_config self.memories_manager = MemoriesManager(project_root) @@ -72,6 +81,7 @@ class Project(ToStringMixin): f.write(f"/{SolidLanguageServer.CACHE_FOLDER_NAME}\n") # prepare ignore spec asynchronously, ensuring immediate project activation. + self._serena_config = serena_config self.__ignored_patterns: list[str] self.__ignore_spec: pathspec.PathSpec self._ignore_spec_available = threading.Event() @@ -80,11 +90,16 @@ class Project(ToStringMixin): def _gather_ignorespec(self) -> None: with LogTime(f"Gathering ignore spec for project {self.project_config.project_name}", logger=log): - # gather ignored paths from the project configuration and gitignore files - ignored_patterns = list(self.project_config.ignored_paths) - if len(ignored_patterns) > 0: - log.info(f"Using {len(ignored_patterns)} ignored paths from the explicit project configuration.") - log.debug(f"Ignored paths: {ignored_patterns}") + # gather ignored paths from the global configuration, project configuration, and gitignore files + global_ignored_paths = self._serena_config.ignored_paths if self._serena_config else [] + ignored_patterns = list(global_ignored_paths) + list(self.project_config.ignored_paths) + if len(global_ignored_paths) > 0: + log.info(f"Using {len(global_ignored_paths)} ignored paths from the global configuration.") + log.debug(f"Global ignored paths: {list(global_ignored_paths)}") + if len(self.project_config.ignored_paths) > 0: + log.info(f"Using {len(self.project_config.ignored_paths)} ignored paths from the project configuration.") + log.debug(f"Project ignored paths: {self.project_config.ignored_paths}") + log.debug(f"Combined ignored patterns: {ignored_patterns}") if self.project_config.ignore_all_files_in_gitignore: gitignore_parser = GitignoreParser(self.project_root) for spec in gitignore_parser.get_ignore_specs(): @@ -115,12 +130,17 @@ class Project(ToStringMixin): return self.project_config.project_name @classmethod - def load(cls, project_root: str | Path, autogenerate: bool = True) -> "Project": + def load( + cls, + project_root: str | Path, + serena_config: "SerenaConfig | None", + autogenerate: bool = True, + ) -> "Project": project_root = Path(project_root).resolve() if not project_root.exists(): raise FileNotFoundError(f"Project root not found: {project_root}") project_config = ProjectConfig.load(project_root, autogenerate=autogenerate) - return Project(project_root=str(project_root), project_config=project_config) + return Project(project_root=str(project_root), project_config=project_config, serena_config=serena_config) def save_config(self) -> None: """ diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index 1ce23a99..8276989d 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -34,8 +34,9 @@ encoding: "utf-8" # whether to use project's .gitignore files to ignore files ignore_all_files_in_gitignore: true -# list of additional paths to ignore in all projects -# same syntax as gitignore, so you can use * and ** +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. ignored_paths: [] # whether the project is in read-only mode diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index 7a660e64..ac11088e 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -63,6 +63,11 @@ trace_lsp_communication: False # No documentation on options means no options are available. ls_specific_settings: {} +# list of paths to ignore across all projects. +# Same syntax as gitignore, so you can use * and **. +# These patterns are merged additively with each project's own ignored_paths. +ignored_paths: [] + # timeout, in seconds, after which tool executions are terminated tool_timeout: 240 diff --git a/test/conftest.py b/test/conftest.py index 822006f2..85d38a05 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -116,7 +116,7 @@ def start_default_ls_context(language: Language) -> Iterator[SolidLanguageServer def _create_default_project(language: Language) -> Project: repo_path = str(get_repo_path(language)) - return Project.load(repo_path) + return Project.load(repo_path, serena_config=None) @pytest.fixture(scope="session") diff --git a/test/serena/config/test_global_ignored_paths.py b/test/serena/config/test_global_ignored_paths.py new file mode 100644 index 00000000..23481373 --- /dev/null +++ b/test/serena/config/test_global_ignored_paths.py @@ -0,0 +1,261 @@ +import os +import shutil +import tempfile +from pathlib import Path + +from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig +from serena.project import Project +from solidlsp.ls_config import Language + + +def _create_test_project( + project_root: Path, + project_ignored_paths: list[str] | None = None, + global_ignored_paths: list[str] | None = None, +) -> Project: + """Helper to create a Project with the given ignored paths configuration.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=project_ignored_paths or [], + ignore_all_files_in_gitignore=False, + ) + serena_config: SerenaConfig | None = None + if global_ignored_paths: + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=global_ignored_paths) + return Project( + project_root=str(project_root), + project_config=config, + serena_config=serena_config, + ) + + +class TestGlobalIgnoredPaths: + """Tests for system-global ignored_paths feature.""" + + def setup_method(self) -> None: + self.test_dir = tempfile.mkdtemp() + self.project_path = Path(self.test_dir) + # Create some test files and directories + (self.project_path / "main.py").write_text("print('hello')") + os.makedirs(self.project_path / "node_modules" / "pkg", exist_ok=True) + (self.project_path / "node_modules" / "pkg" / "index.js").write_text("module.exports = {}") + os.makedirs(self.project_path / "build", exist_ok=True) + (self.project_path / "build" / "output.js").write_text("compiled") + os.makedirs(self.project_path / "src", exist_ok=True) + (self.project_path / "src" / "app.py").write_text("def app(): pass") + (self.project_path / "debug.log").write_text("log data") + + def teardown_method(self) -> None: + shutil.rmtree(self.test_dir) + + def test_global_ignored_paths_are_applied(self) -> None: + """Global ignored_paths from SerenaConfig are respected by Project.is_ignored_path().""" + project = _create_test_project( + self.project_path, + global_ignored_paths=["node_modules"], + ) + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + assert not project.is_ignored_path(str(self.project_path / "src" / "app.py")) + + def test_additive_merge_of_global_and_project_patterns(self) -> None: + """Global + project patterns are merged additively (both applied).""" + project = _create_test_project( + self.project_path, + project_ignored_paths=["build"], + global_ignored_paths=["node_modules"], + ) + # Global pattern should be applied + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + # Project pattern should also be applied + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + # Non-ignored files should not be affected + assert not project.is_ignored_path(str(self.project_path / "src" / "app.py")) + + def test_empty_global_ignored_paths_has_no_effect(self) -> None: + """Empty global ignored_paths (default) has no effect on existing behavior.""" + project = _create_test_project( + self.project_path, + project_ignored_paths=["build"], + global_ignored_paths=[], + ) + # Project pattern still works + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + # Non-ignored files still accessible + assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + + def test_default_global_ignored_paths_backward_compatible(self) -> None: + """Project created without global_ignored_paths parameter works as before.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=["build"], + ignore_all_files_in_gitignore=False, + ) + project = Project( + project_root=str(self.project_path), + project_config=config, + ) + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + + def test_duplicate_patterns_across_global_and_project(self) -> None: + """Duplicate patterns across global and project do not cause errors.""" + project = _create_test_project( + self.project_path, + project_ignored_paths=["node_modules", "build"], + global_ignored_paths=["node_modules", "build"], + ) + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + assert not project.is_ignored_path(str(self.project_path / "src" / "app.py")) + + def test_glob_patterns_in_global_ignored_paths(self) -> None: + """Global ignored_paths support gitignore-style glob patterns.""" + project = _create_test_project( + self.project_path, + global_ignored_paths=["*.log"], + ) + assert project.is_ignored_path(str(self.project_path / "debug.log")) + assert not project.is_ignored_path(str(self.project_path / "main.py")) + + +class TestRegisteredProjectGlobalIgnoredPaths: + """RegisteredProject.get_project_instance() correctly passes global patterns to Project.""" + + def setup_method(self) -> None: + self.test_dir = tempfile.mkdtemp() + self.project_path = Path(self.test_dir).resolve() + (self.project_path / "main.py").write_text("print('hello')") + os.makedirs(self.project_path / "node_modules", exist_ok=True) + (self.project_path / "node_modules" / "pkg.js").write_text("module") + + def teardown_method(self) -> None: + shutil.rmtree(self.test_dir) + + def test_get_project_instance_passes_global_ignored_paths(self) -> None: + """RegisteredProject.get_project_instance() passes global_ignored_paths to Project.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=[], + ignore_all_files_in_gitignore=False, + ) + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) + registered = RegisteredProject( + project_root=str(self.project_path), + project_config=config, + ) + project = registered.get_project_instance(serena_config=serena_config) + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + + def test_get_project_instance_without_global_ignored_paths(self) -> None: + """RegisteredProject without global_ignored_paths defaults to empty.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=[], + ignore_all_files_in_gitignore=False, + ) + registered = RegisteredProject( + project_root=str(self.project_path), + project_config=config, + ) + project = registered.get_project_instance(serena_config=None) + assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + + def test_from_project_root_passes_global_ignored_paths(self) -> None: + """RegisteredProject.from_project_root() threads global_ignored_paths to Project.""" + # Create a minimal project.yml so from_project_root can load config + serena_dir = self.project_path / ".serena" + serena_dir.mkdir(exist_ok=True) + (serena_dir / "project.yml").write_text( + 'project_name: "test_project"\nlanguages: ["python"]\nignored_paths: []\nignore_all_files_in_gitignore: false\n' + ) + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) + registered = RegisteredProject.from_project_root( + str(self.project_path), + ) + project = registered.get_project_instance(serena_config=serena_config) + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + + def test_from_project_instance_passes_global_ignored_paths(self) -> None: + """RegisteredProject.from_project_instance() threads global_ignored_paths to Project.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=[], + ignore_all_files_in_gitignore=False, + ) + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) + project = Project( + project_root=str(self.project_path), + project_config=config, + serena_config=serena_config, + ) + registered = RegisteredProject.from_project_instance(project) + # The registered project already has a project_instance, so get_project_instance() returns it directly + retrieved = registered.get_project_instance(serena_config=serena_config) + assert retrieved.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + + +class TestGlobalIgnoredPathsWithGitignore: + """Global ignored_paths combined with ignore_all_files_in_gitignore produces correct three-way merge.""" + + def setup_method(self) -> None: + self.test_dir = tempfile.mkdtemp() + self.project_path = Path(self.test_dir).resolve() + # Create test files + (self.project_path / "main.py").write_text("print('hello')") + os.makedirs(self.project_path / "node_modules", exist_ok=True) + (self.project_path / "node_modules" / "pkg.js").write_text("module") + os.makedirs(self.project_path / "dist", exist_ok=True) + (self.project_path / "dist" / "bundle.js").write_text("bundled") + os.makedirs(self.project_path / "build", exist_ok=True) + (self.project_path / "build" / "output.js").write_text("compiled") + # Create .gitignore that ignores dist/ + (self.project_path / ".gitignore").write_text("dist/\n") + + def teardown_method(self) -> None: + shutil.rmtree(self.test_dir) + + def test_three_way_merge_global_project_and_gitignore(self) -> None: + """Global patterns, project patterns, and .gitignore patterns are all applied together.""" + config = ProjectConfig( + project_name="test_project", + languages=[Language.PYTHON], + ignored_paths=["build"], + ignore_all_files_in_gitignore=True, + ) + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) + project = Project( + project_root=str(self.project_path), + project_config=config, + serena_config=serena_config, + ) + # Global pattern: node_modules + assert project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) + # Project pattern: build + assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) + # Gitignore pattern: dist/ + assert project.is_ignored_path(str(self.project_path / "dist" / "bundle.js")) + # Non-ignored file + assert not project.is_ignored_path(str(self.project_path / "main.py")) + + +class TestSerenaConfigIgnoredPaths: + """Config loading with ignored_paths in serena_config.yml works correctly.""" + + def test_serena_config_default_ignored_paths(self) -> None: + """SerenaConfig defaults to empty ignored_paths.""" + config = SerenaConfig(gui_log_window=False, web_dashboard=False) + assert config.ignored_paths == [] + + def test_serena_config_with_ignored_paths(self) -> None: + """SerenaConfig can be created with explicit ignored_paths.""" + config = SerenaConfig( + gui_log_window=False, + web_dashboard=False, + ignored_paths=["node_modules", "*.log", "build"], + ) + assert config.ignored_paths == ["node_modules", "*.log", "build"]