Fix find_project_root hijacking git worktrees nested under a Serena project (#1550)

* Fix find_project_root hijacking git worktrees nested under a Serena project

find_project_root searched all ancestor levels for .serena/project.yml
before ever looking for .git. A git worktree whose own .git is a pointer
file, nested under a directory that is an explicit Serena project, was
resolved to the ancestor project instead of the worktree. With CLI agents
that launch via --project-from-cwd (Claude Code, Codex, Gemini), this
silently bound the server to the wrong working tree: reads returned stale
symbols and edits landed in the parent repo.

Walk up in a single pass so the nearest project boundary wins, whether it
is a .serena/project.yml or a .git (dir or worktree/submodule pointer
file). Same-level behavior is unchanged. Adds a regression test.

* Document worktree project-root fix in changelog and usage docs
This commit is contained in:
Tomi P. Hakala authored and GitHub committed 2026-06-07 18:05:01 +02:00
1 parent 577931c8cf
commit b5b9cc4ffc
5 files changed
+54 -9

No files matched your search

+7
View File
@@ -2,6 +2,13 @@
Status of the `main` branch. Changes prior to the next official version change will appear here. Status of the `main` branch. Changes prior to the next official version change will appear here.
* General:
- Fix `--project-from-cwd` hijacking git worktrees nested under a Serena project. `find_project_root`
now walks up in a single pass so the nearest project boundary wins (either a `.serena/project.yml`
or a `.git`, including worktree/submodule pointer files), instead of preferring an ancestor's
`.serena/project.yml` over a closer `.git`. This previously bound CLI agents (Claude Code, Codex,
Gemini) launched from inside a worktree to the parent repo, causing stale reads and misdirected edits.
* Language Servers: * Language Servers:
- `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`.
The dict is forwarded to vtsls via `initializationOptions`, `workspace/didChangeConfiguration`, The dict is forwarded to vtsls via `initializationOptions`, `workspace/didChangeConfiguration`,
+3 -1
View File
@@ -92,7 +92,9 @@ Some useful options include:
* `--project <path|name>`: specify the project to work on by name or path. * `--project <path|name>`: specify the project to work on by name or path.
* `--project-from-cwd`: auto-detect the project from current working directory * `--project-from-cwd`: auto-detect the project from current working directory
(looking for a directory containing `.serena/project.yml` or `.git` in parent directories and activating the containing directory as the project root, if any). (walking up the parent directories and activating the nearest one that contains either `.serena/project.yml`
or `.git`, if any). The nearest boundary wins, so a git worktree nested under another Serena project resolves
to the worktree itself rather than the ancestor project.
This option is intended for CLI-based agents like Claude Code, Gemini and Codex, which are typically started from within the project directory This option is intended for CLI-based agents like Claude Code, Gemini and Codex, which are typically started from within the project directory
and which do not change directories during their operation. and which do not change directories during their operation.
* `--language-backend JetBrains`: use the Serena JetBrains Plugin as the language backend (overriding the default backend configured in the central configuration) * `--language-backend JetBrains`: use the Serena JetBrains Plugin as the language backend (overriding the default backend configured in the central configuration)
+2
View File
@@ -14,3 +14,5 @@ having persisted the plan in a memory or dedicated file.
[git-worktree](https://git-scm.com/docs/git-worktree) can be an excellent way to parallelize your work. More on this in [Anthropic: Run parallel Claude Code sessions with Git worktrees](https://docs.claude.com/en/docs/claude-code/common-workflows#run-parallel-claude-code-sessions-with-git-worktrees). [git-worktree](https://git-scm.com/docs/git-worktree) can be an excellent way to parallelize your work. More on this in [Anthropic: Run parallel Claude Code sessions with Git worktrees](https://docs.claude.com/en/docs/claude-code/common-workflows#run-parallel-claude-code-sessions-with-git-worktrees).
Be sure to add the `.serena` folder to version control, such that your project-specific settings and memories are available across worktrees. Be sure to add the `.serena` folder to version control, such that your project-specific settings and memories are available across worktrees.
When you launch a CLI agent from inside a worktree using `--project-from-cwd`, Serena activates the worktree itself, even if the worktree lives under another Serena project (for example `<repo>/.claude/worktrees/<name>`, where Claude Code creates them natively). The nearest project boundary wins: the worktree's own `.git` pointer file takes precedence over an ancestor's `.serena/project.yml`, so file operations always resolve against the correct working tree.
+15 -8
View File
@@ -65,7 +65,16 @@ For details on mode configuration, see
def find_project_root(root: str | Path | None = None) -> str | None: def find_project_root(root: str | Path | None = None) -> str | None:
"""Find project root by walking up from CWD. """Find project root by walking up from CWD.
Checks for .serena/project.yml first (explicit Serena project), then .git (git root). Returns the nearest ancestor that is either an explicit Serena project
(contains .serena/project.yml) or a git root (contains .git, which may be a
directory or, for git worktrees and submodules, a pointer file). The nearest
such directory wins; a .serena/project.yml at the same level takes priority
over .git only because they resolve to the same directory.
Walking up with a single pass (rather than searching all levels for
.serena/project.yml first and only then for .git) ensures a git worktree
nested under another Serena project resolves to the worktree itself, instead
of being hijacked by the ancestor project's .serena/project.yml.
:param root: If provided, constrains the search to this directory and below :param root: If provided, constrains the search to this directory and below
(acts as a virtual filesystem root). Search stops at this boundary. (acts as a virtual filesystem root). Search stops at this boundary.
@@ -82,14 +91,12 @@ def find_project_root(root: str | Path | None = None) -> str | None:
if boundary is not None and parent == boundary: if boundary is not None and parent == boundary:
return return
# First pass: look for .serena # Single pass: the nearest project boundary wins, whether it is an explicit
# Serena project (.serena/project.yml) or a git root (.git, a dir or a worktree
# pointer file). This keeps a nested git worktree from being hijacked by an
# ancestor's .serena/project.yml.
for directory in ancestors(): for directory in ancestors():
if (directory / ".serena" / "project.yml").is_file(): if (directory / ".serena" / "project.yml").is_file() or (directory / ".git").exists():
return str(directory)
# Second pass: look for .git
for directory in ancestors():
if (directory / ".git").exists(): # .git can be file (worktree) or dir
return str(directory) return str(directory)
return None return None
+27
View File
@@ -328,6 +328,33 @@ class TestFindProjectRoot:
finally: finally:
os.chdir(original_cwd) os.chdir(original_cwd)
def test_git_worktree_not_hijacked_by_ancestor_serena(self, temp_project_dir):
"""A git worktree nested under a Serena project must resolve to the worktree.
Regression test: when a git worktree (whose .git is a pointer *file*) lives
below a directory that is an explicit Serena project (.serena/project.yml),
the worktree's own .git boundary must win over the ancestor's project marker.
The old two-pass search returned the ancestor Serena project, causing reads
and edits to land in the wrong working tree.
"""
# Ancestor directory is an explicit Serena project.
serena_dir = os.path.join(temp_project_dir, ".serena")
os.makedirs(serena_dir)
Path(os.path.join(serena_dir, "project.yml")).touch()
# Nested git worktree: .git is a gitdir pointer file, as created by `git worktree add`.
worktree = os.path.join(temp_project_dir, "nested", "worktree")
os.makedirs(worktree)
Path(os.path.join(worktree, ".git")).write_text("gitdir: /repo/.git/worktrees/wt\n")
original_cwd = os.getcwd()
try:
os.chdir(worktree)
result = find_project_root(root=temp_project_dir)
assert result is not None
assert os.path.samefile(result, worktree)
finally:
os.chdir(original_cwd)
class TestProjectFromCwdMutualExclusivity: class TestProjectFromCwdMutualExclusivity:
"""Tests for --project-from-cwd mutual exclusivity.""" """Tests for --project-from-cwd mutual exclusivity."""