diff --git a/CHANGELOG.md b/CHANGELOG.md index 75c9309c..c89f63de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ 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: - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. The dict is forwarded to vtsls via `initializationOptions`, `workspace/didChangeConfiguration`, diff --git a/docs/02-usage/020_running.md b/docs/02-usage/020_running.md index 468b1d32..84de5490 100644 --- a/docs/02-usage/020_running.md +++ b/docs/02-usage/020_running.md @@ -92,7 +92,9 @@ Some useful options include: * `--project `: specify the project to work on by name or path. * `--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 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) diff --git a/docs/02-usage/999_additional-usage.md b/docs/02-usage/999_additional-usage.md index d4da7ce6..0fef1f8a 100644 --- a/docs/02-usage/999_additional-usage.md +++ b/docs/02-usage/999_additional-usage.md @@ -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). 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 `/.claude/worktrees/`, 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. diff --git a/src/serena/cli.py b/src/serena/cli.py index 71d7da38..3b9ba581 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -65,7 +65,16 @@ For details on mode configuration, see def find_project_root(root: str | Path | None = None) -> str | None: """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 (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: 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(): - if (directory / ".serena" / "project.yml").is_file(): - return str(directory) - - # Second pass: look for .git - for directory in ancestors(): - if (directory / ".git").exists(): # .git can be file (worktree) or dir + if (directory / ".serena" / "project.yml").is_file() or (directory / ".git").exists(): return str(directory) return None diff --git a/test/serena/test_cli_project_commands.py b/test/serena/test_cli_project_commands.py index be90a799..ff6fd7ec 100644 --- a/test/serena/test_cli_project_commands.py +++ b/test/serena/test_cli_project_commands.py @@ -328,6 +328,33 @@ class TestFindProjectRoot: finally: 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: """Tests for --project-from-cwd mutual exclusivity."""