fix(wrap): skip Serena project setup outside real project roots (#2574)

## Problem

`headroom wrap` runs two per-project Serena steps against the cwd:
`_scope_serena_languages()` (detect languages, pin them into
`.serena/project.yml`) and `_index_serena_project()` (`serena project
index`, to warm the symbol cache). Both assume the cwd *is* a project.

Launched from `$HOME` — an ordinary way to start an agent — that
assumption breaks badly:

- the language scan `os.walk`s the entire home directory: `Downloads/`,
VM images, backup trees, network mounts;
- the pre-index then runs `serena project index` over the same tree and
sits there until its full 300s timeout;
- so the agent appears to **hang for minutes on every launch**, with no
output after the Serena MCP registration line and nothing to suggest
indexing is what's blocking;
- and the scan writes `project.yml` into `~/.serena`, which is Serena's
own config directory rather than a project's `.serena/`.

A linked git worktree hits the same code from the other side: it's an
ephemeral checkout, so it pays for a full cold index at a path that soon
disappears — once per worktree, which adds up under any fan-out
workflow.

## Fix

Add `_serena_project_skip_reason(root)` and gate both steps on it:

- `root == $HOME` → `"$HOME is not a project"`
- top-level `.git` is a **file** rather than a directory → `"linked git
worktree"`
- otherwise `None`, and behavior is exactly as before

The reason is echoed under `--verbose`. Nothing else changes: Serena MCP
is still registered, instructions are still injected, and in the skipped
cases Serena still indexes lazily on demand — so no capability is lost,
only the wasted upfront scan.

## Testing

Five unit tests in `tests/test_cli/test_wrap_serena_boost.py` covering
an ordinary directory, a normal checkout (`.git` dir), `$HOME`, a linked
worktree (`.git` file), and a non-existent root. Full file: 22 passed.
`ruff format --check` and `ruff check` clean.

Verified manually on the reported case: `claude` launched from `$HOME`
now starts immediately instead of stalling on the index.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eyal Mizrachi 2026-07-26 13:31:00 -04:00 committed by GitHub
parent aebe19539f
commit 0994ea04c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 57 additions and 0 deletions

View file

@ -1840,6 +1840,27 @@ def _scope_serena_languages(*, verbose: bool = False) -> None:
click.echo(f" Serena: could not scope languages ({e})") 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: def _index_serena_project(*, verbose: bool = False) -> None:
"""Warm Serena's symbol cache for the current project (non-fatal). """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 # ``serena project index`` respects the scope. Each step is best-effort and
# non-fatal — none of them block the wrap. # non-fatal — none of them block the wrap.
_inject_serena_instructions(_serena_instruction_file(registrar), verbose=verbose) _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) _scope_serena_languages(verbose=verbose)
_index_serena_project(verbose=verbose) _index_serena_project(verbose=verbose)

View file

@ -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"))) monkeypatch.setattr(wrap_cli, "run", Mock(side_effect=RuntimeError("boom")))
# Must not propagate. # Must not propagate.
wrap_cli._index_serena_project(verbose=True) 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