diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 06de0cc87..8c0f33b2a 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -1840,6 +1840,27 @@ def _scope_serena_languages(*, verbose: bool = False) -> None: click.echo(f" Serena: could not scope languages ({e})") +def _serena_project_skip_reason(root: Path) -> str | None: + """Why Serena's per-project setup must not run for *root* (None = proceed). + + ``$HOME`` is never a project: scanning it walks every unrelated tree + (Downloads, VM images, network mounts) and would write ``project.yml`` into + Serena's own ``~/.serena`` config directory. A linked git worktree (its + top-level ``.git`` is a file, not a directory) is an ephemeral checkout that + would pay for its own index at a path that soon disappears. + """ + try: + resolved = root.resolve() + home = Path.home().resolve() + except OSError: + return None + if resolved == home: + return "$HOME is not a project" + if (resolved / ".git").is_file(): + return "linked git worktree" + return None + + def _index_serena_project(*, verbose: bool = False) -> None: """Warm Serena's symbol cache for the current project (non-fatal). @@ -1947,6 +1968,11 @@ def _setup_serena_mcp( # ``serena project index`` respects the scope. Each step is best-effort and # non-fatal — none of them block the wrap. _inject_serena_instructions(_serena_instruction_file(registrar), verbose=verbose) + skip_reason = _serena_project_skip_reason(Path.cwd()) + if skip_reason is not None: + if verbose: + click.echo(f" Serena: skipping language scope + pre-index ({skip_reason})") + return _scope_serena_languages(verbose=verbose) _index_serena_project(verbose=verbose) diff --git a/tests/test_cli/test_wrap_serena_boost.py b/tests/test_cli/test_wrap_serena_boost.py index 49dc7fe09..c5ca1d9c7 100644 --- a/tests/test_cli/test_wrap_serena_boost.py +++ b/tests/test_cli/test_wrap_serena_boost.py @@ -258,3 +258,34 @@ def test_preindex_generic_error_is_non_fatal(monkeypatch: pytest.MonkeyPatch) -> monkeypatch.setattr(wrap_cli, "run", Mock(side_effect=RuntimeError("boom"))) # Must not propagate. wrap_cli._index_serena_project(verbose=True) + + +# --------------------------------------------------------------------------- +# _serena_project_skip_reason — keep per-project setup off non-project roots +# --------------------------------------------------------------------------- + + +def test_skip_reason_none_for_ordinary_project(tmp_path: Path) -> None: + assert wrap_cli._serena_project_skip_reason(tmp_path) is None + + +def test_skip_reason_none_for_normal_checkout(tmp_path: Path) -> None: + (tmp_path / ".git").mkdir() # real checkout: .git is a directory + + assert wrap_cli._serena_project_skip_reason(tmp_path) is None + + +def test_skip_reason_flags_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + + assert wrap_cli._serena_project_skip_reason(tmp_path) == "$HOME is not a project" + + +def test_skip_reason_flags_linked_worktree(tmp_path: Path) -> None: + (tmp_path / ".git").write_text("gitdir: /repo/.git/worktrees/wt\n") + + assert wrap_cli._serena_project_skip_reason(tmp_path) == "linked git worktree" + + +def test_skip_reason_survives_unresolvable_root(tmp_path: Path) -> None: + assert wrap_cli._serena_project_skip_reason(tmp_path / "gone") is None