feat(config): add system-global ignored_paths support

- Introduce ignored_paths to SerenaConfig for global ignore patterns
- Merge global and project ignored_paths additively in Project logic
- Update CLI and config loading to thread global_ignored_paths correctly
- Add tests for global ignored_paths, additive merge, glob patterns, and compatibility
- Update config templates and docstrings for new global ignored_paths option
This commit is contained in:
takeokunn committed 2026-02-07 19:40:23 +09:00
1 parent fafebdc205
commit 43dca464d6
6 files changed
+328 -21

No files matched your search

+7 -4
View File
@@ -567,7 +567,7 @@ class ProjectCommands(AutoRegisteringGroup):
serena_config = SerenaConfig.from_config_file()
registered_project = serena_config.get_registered_project(str(project_root))
if registered_project is None:
registered_project = RegisteredProject(str(project_root), generated_conf)
registered_project = RegisteredProject(str(project_root), generated_conf, global_ignored_paths=serena_config.ignored_paths)
serena_config.add_registered_project(registered_project)
return registered_project
@@ -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), global_ignored_paths=serena_config.ignored_paths)
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), global_ignored_paths=serena_config.ignored_paths)
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, global_ignored_paths=serena_config.ignored_paths)
# Create log file with timestamp
timestamp = datetime_tag()
+32 -7
View File
@@ -397,16 +397,25 @@ 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,
global_ignored_paths: Sequence[str] = (),
) -> 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)
:param global_ignored_paths: global ignored paths from SerenaConfig, merged additively with project-level patterns
"""
self.project_root = Path(project_root).resolve()
self.project_config = project_config
self._project_instance = project_instance
self._global_ignored_paths = global_ignored_paths
def _tostring_exclude_private(self) -> bool:
return True
@@ -416,19 +425,21 @@ class RegisteredProject(ToStringMixin):
return self.project_config.project_name
@classmethod
def from_project_instance(cls, project_instance: "Project") -> "RegisteredProject":
def from_project_instance(cls, project_instance: "Project", global_ignored_paths: Sequence[str] = ()) -> "RegisteredProject":
return RegisteredProject(
project_root=project_instance.project_root,
project_config=project_instance.project_config,
project_instance=project_instance,
global_ignored_paths=global_ignored_paths,
)
@classmethod
def from_project_root(cls, project_root: str | Path) -> "RegisteredProject":
def from_project_root(cls, project_root: str | Path, global_ignored_paths: Sequence[str] = ()) -> "RegisteredProject":
project_config = ProjectConfig.load(project_root)
return RegisteredProject(
project_root=str(project_root),
project_config=project_config,
global_ignored_paths=global_ignored_paths,
)
def matches_root_path(self, path: str | Path) -> bool:
@@ -448,7 +459,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,
global_ignored_paths=self._global_ignored_paths,
)
return self._project_instance
@@ -493,6 +508,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")
@@ -610,6 +629,7 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix
project = RegisteredProject(
project_root=str(path),
project_config=project_config,
global_ignored_paths=instance.ignored_paths,
)
instance.projects.append(project)
@@ -700,7 +720,7 @@ class SerenaConfig(ToolInclusionDefinition, ModeSelectionDefinition, ToStringMix
if autoregister:
config_path = ProjectConfig.path_to_project_yml(project_root_or_name)
if os.path.isfile(config_path):
registered_project = RegisteredProject.from_project_root(project_root_or_name)
registered_project = RegisteredProject.from_project_root(project_root_or_name, global_ignored_paths=self.ignored_paths)
self.add_registered_project(registered_project)
return registered_project
# nothing found
@@ -745,8 +765,13 @@ 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)
self.add_registered_project(RegisteredProject.from_project_instance(new_project))
new_project = Project(
project_root=str(project_root),
project_config=project_config,
is_newly_created=True,
global_ignored_paths=self.ignored_paths,
)
self.add_registered_project(RegisteredProject.from_project_instance(new_project, global_ignored_paths=self.ignored_paths))
return new_project
+25 -8
View File
@@ -2,6 +2,7 @@ import json
import logging
import os
import threading
from collections.abc import Sequence
from pathlib import Path
from typing import Any
@@ -56,7 +57,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,
global_ignored_paths: Sequence[str] = (),
):
self.project_root = project_root
self.project_config = project_config
self.memories_manager = MemoriesManager(project_root)
@@ -72,6 +79,7 @@ class Project(ToStringMixin):
f.write(f"/{SolidLanguageServer.CACHE_FOLDER_NAME}\n")
# prepare ignore spec asynchronously, ensuring immediate project activation.
self._global_ignored_paths = global_ignored_paths
self.__ignored_patterns: list[str]
self.__ignore_spec: pathspec.PathSpec
self._ignore_spec_available = threading.Event()
@@ -80,11 +88,15 @@ 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
ignored_patterns = list(self._global_ignored_paths) + list(self.project_config.ignored_paths)
if len(self._global_ignored_paths) > 0:
log.info(f"Using {len(self._global_ignored_paths)} ignored paths from the global configuration.")
log.debug(f"Global ignored paths: {list(self._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 +127,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,
autogenerate: bool = True,
global_ignored_paths: Sequence[str] = (),
) -> "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, global_ignored_paths=global_ignored_paths)
def save_config(self) -> None:
"""
+3 -2
View File
@@ -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
@@ -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
@@ -0,0 +1,256 @@
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,
)
return Project(
project_root=str(project_root),
project_config=config,
global_ignored_paths=global_ignored_paths or [],
)
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,
)
registered = RegisteredProject(
project_root=str(self.project_path),
project_config=config,
global_ignored_paths=["node_modules"],
)
project = registered.get_project_instance()
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()
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'
)
registered = RegisteredProject.from_project_root(
str(self.project_path),
global_ignored_paths=["node_modules"],
)
project = registered.get_project_instance()
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,
)
project = Project(
project_root=str(self.project_path),
project_config=config,
global_ignored_paths=["node_modules"],
)
registered = RegisteredProject.from_project_instance(project, global_ignored_paths=["node_modules"])
# The registered project already has a project_instance, so get_project_instance() returns it directly
retrieved = registered.get_project_instance()
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,
)
project = Project(
project_root=str(self.project_path),
project_config=config,
global_ignored_paths=["node_modules"],
)
# 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"]