From b10d0efbc9ec19902f0f9afdf77805905db22e77 Mon Sep 17 00:00:00 2001 From: Wolfvin Date: Thu, 2 Jul 2026 17:24:30 +0000 Subject: [PATCH] =?UTF-8?q?feat(sync):=20worktree=20index=20mismatch=20det?= =?UTF-8?q?ection=20=E2=80=94=20doctor=20check=20+=20MCP=20banner=20(close?= =?UTF-8?q?s=20#66=20phase-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add scripts/sync/worktree.py with detect_worktree_index_mismatch(), format_worktree_warning(), and format_worktree_banner(). The detector shells out to 'git rev-parse --show-toplevel' and 'git rev-parse --git-common-dir' to identify worktrees, then walks up from the project root to find the .codelens/ that CodeLens would actually load. A mismatch is reported when: - workspace is a git worktree (worktree_root != main_checkout_root) - AND no .codelens/ exists in the worktree - AND a .codelens/ does exist in the main checkout In that scenario CodeLens silently reads the main checkout's index — which was built from a different branch. Every query/trace/dataflow answer is then grounded in the wrong file set. Wiring: - doctor: new check 'workspace.worktree_index_mismatch' (status=warning on mismatch). Runs BEFORE _check_codelens_writable because the writable check creates .codelens/ as a side effect, which would mask the mismatch. - mcp_server: read-tool responses get a top-level _worktree_warning field (banner + mismatch record) when a mismatch is detected. Mutating commands (scan, init) skip the banner. The mismatch verdict is cached per workspace (probed once, reused) and invalidated when scan runs. - The early-probe happens BEFORE _execute_command because read commands like 'list' / 'query' trigger ensure_codelens_dir() as a side effect of loading the registry. Tests: 30 new tests across 3 files. - tests/test_worktree.py: 19 tests for the detector + formatters. - tests/test_doctor.py: 6 tests for the doctor integration, including an ordering test that enforces 'mismatch before writable'. - tests/test_mcp_hooks.py: 5 tests for the MCP integration, including cache-once-per-workspace and detection-never-breaks-tool-call. Pre-existing failure unrelated to this PR: tests/test_codelensignore.py::TestBackwardCompat::test_actual_target_dir_is_ignored (was failing on origin/main before this branch) --- CHANGELOG.md | 55 +++++ scripts/commands/doctor.py | 78 +++++++ scripts/mcp_server.py | 148 ++++++++++++++ scripts/sync/__init__.py | 27 +++ scripts/sync/worktree.py | 378 ++++++++++++++++++++++++++++++++++ tests/test_doctor.py | 142 +++++++++++++ tests/test_mcp_hooks.py | 229 +++++++++++++++++++++ tests/test_worktree.py | 407 +++++++++++++++++++++++++++++++++++++ 8 files changed, 1464 insertions(+) create mode 100644 scripts/sync/__init__.py create mode 100644 scripts/sync/worktree.py create mode 100644 tests/test_worktree.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ce0bdf90..2876034a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [8.2.0] — Unreleased +### Worktree Index Mismatch Detection (issue #66 Phase 4) + +CodeLens's workspace auto-detection walks up the directory tree looking +for project markers (`.git`, `.codelens`, `pyproject.toml`, etc.). When +a user runs CodeLens inside a **git worktree** that does not have its +own `.codelens/` index, the walk-up silently picks up the **main +checkout's** `.codelens/` — which was built from a *different* branch. +Every subsequent `query` / `trace` / `dataflow` / `taint` answer is +then grounded in the wrong file set, with no warning to the user or +the agent. + +**Added:** + +- New module `scripts/sync/worktree.py` with: + - `detect_worktree_index_mismatch(project_root)` — returns a structured + record with `mismatch`, `reason`, `worktree_root`, + `main_checkout_root`, `index_root`, and `suggestion` fields. + - `format_worktree_warning(mismatch)` — multi-line human-readable + warning for CLI output. + - `format_worktree_banner(mismatch)` — single-line banner for MCP + responses. +- New `doctor` check `workspace.worktree_index_mismatch` that surfaces + a `warning` status when the workspace is a worktree using a foreign + index. The check runs **before** `workspace.codelens_writable` + (which creates `.codelens/` as a side effect of probing write + permissions — running mismatch first gives an honest picture of the + pre-doctor state). +- MCP server attaches a `_worktree_warning` field to read-tool + responses when a mismatch is detected. The field contains a + human-readable `banner` string plus the full `mismatch` record. + Agents that know about the field surface the banner; agents that + don't ignore it (per MCP spec — unknown top-level fields are + ignored). +- The mismatch verdict is cached per workspace for the server's + lifetime (detection shells out to `git` twice, too expensive to + repeat per tool call). The cache is invalidated when `scan` runs — + the user may have just run `codelens init -i` in the worktree to + fix the mismatch. + +**Why a top-level field, not a content prepend:** + +Prepending text to the `content` array would corrupt the JSON payload +agents parse out of the response. A top-level field keeps the JSON +intact while still surfacing the warning prominently. + +**Reason codes returned by `detect_worktree_index_mismatch`:** + +| Reason | Mismatch | Meaning | +|---|---|---| +| `not_a_git_repo` | `False` | git not installed, or workspace not under git | +| `not_a_worktree` | `False` | main checkout, no worktree concerns | +| `worktree_has_own_index` | `False` | worktree with its own `.codelens/` (correct setup) | +| `no_index_found` | `False` | worktree, but no `.codelens/` anywhere up the tree | +| `worktree_uses_main_index` | `True` | MISMATCH — worktree reading main's index | + ### LSP Status Entry-Point Unification (issue #33) The `codelens --lsp-status` top-level flag (intercepted in diff --git a/scripts/commands/doctor.py b/scripts/commands/doctor.py index 1e09804d..2d8073f6 100644 --- a/scripts/commands/doctor.py +++ b/scripts/commands/doctor.py @@ -281,6 +281,78 @@ def _check_codelens_writable(workspace: str) -> Dict[str, Any]: } +def _check_worktree_mismatch(workspace: str) -> Dict[str, Any]: + """Detect git worktree ↔ CodeLens index mismatch (issue #66 Phase 4). + + A mismatch happens when the user runs CodeLens inside a git + worktree that does not have its own ``.codelens/`` index, so + CodeLens silently walks up and loads the main checkout's index — + which was built from a *different* branch. Every subsequent + ``query`` / ``trace`` / ``dataflow`` answer is then grounded in + the wrong file set. + + This check is a ``warning`` (not ``critical``) because the + workspace is still usable — answers are merely stale, not + crashes. The user can fix it by running ``codelens init -i`` in + the worktree. + + Doctor is the natural place to surface this: it's the "why is + CodeLens behaving weirdly?" debugging command, and "I'm in a + worktree" is one of the most common answers to that question. + """ + if not workspace: + return { + "name": "workspace.worktree_index_mismatch", + "status": "ok", + "found": "no workspace", + "required": "workspace not in a worktree, or worktree has own .codelens/", + "detail": "no workspace provided — run from a project root, or pass --workspace", + "fixable": False, + } + try: + # Import locally so a failure to import the sync subpackage + # (e.g., due to a packaging regression) doesn't take down the + # entire doctor run. Doctor must always produce a report. + from sync.worktree import detect_worktree_index_mismatch, format_worktree_warning + + mismatch = detect_worktree_index_mismatch(workspace) + except Exception as exc: + # Detection failure must never break doctor — downgrade to ok + # with a clear note in the detail field. + return { + "name": "workspace.worktree_index_mismatch", + "status": "ok", + "found": "detection skipped", + "required": "workspace not in a worktree, or worktree has own .codelens/", + "detail": f"worktree detection unavailable: {exc}", + "fixable": False, + } + + if not mismatch.get("mismatch"): + return { + "name": "workspace.worktree_index_mismatch", + "status": "ok", + "found": mismatch.get("reason", "unknown"), + "required": "workspace not in a worktree, or worktree has own .codelens/", + "detail": ( + f"worktree_root={mismatch.get('worktree_root')} " + f"index_root={mismatch.get('index_root')}" + ), + "fixable": False, + } + + # Mismatch detected — surface the full warning so users can see + # all three paths (worktree / main / index) at a glance. + return { + "name": "workspace.worktree_index_mismatch", + "status": "warning", + "found": mismatch.get("reason"), + "required": "worktree should have its own .codelens/ index", + "detail": format_worktree_warning(mismatch), + "fixable": False, + } + + def _check_latest_version() -> Dict[str, Any]: """Compare installed CodeLens version to the latest GitHub release. @@ -377,6 +449,12 @@ def _run_all_checks(workspace: str) -> List[Dict[str, Any]]: checks.append(_check_urllib()) for bin_name in _EXPECTED_BINARIES: checks.append(_check_binary(bin_name)) + # Worktree mismatch MUST run before _check_codelens_writable — the + # writable check creates ``.codelens/`` as a side effect of probing + # write permissions, which would mask the mismatch (the worktree + # would suddenly appear to have its own index). Running mismatch + # first gives the user an honest picture of the pre-doctor state. + checks.append(_check_worktree_mismatch(workspace)) checks.append(_check_codelens_writable(workspace)) checks.append(_check_latest_version()) return checks diff --git a/scripts/mcp_server.py b/scripts/mcp_server.py index 323739a9..ce3f2b27 100644 --- a/scripts/mcp_server.py +++ b/scripts/mcp_server.py @@ -1234,6 +1234,13 @@ def __init__(self, watch: bool = False): # picks up the auto-detected workspace. self._hook_manager: Optional[HookManager] = None self._hook_workspace: Optional[str] = None + # Worktree mismatch cache (issue #66 Phase 4). Detection + # shells out to git twice — too expensive to repeat on every + # MCP tool call. Cache the result per workspace for the + # server's lifetime. ``None`` means "not yet probed"; + # ``{}`` (empty dict) means "probed, no mismatch"; a + # populated dict means "probed, mismatch present". + self._worktree_mismatch_cache: Dict[str, Optional[Dict[str, Any]]] = {} # ─── Lifecycle ──────────────────────────────────────── @@ -1566,6 +1573,30 @@ def _handle_tools_call(self, params: Dict[str, Any]) -> Dict[str, Any]: if not workspace: workspace = self._detect_workspace() + # Probe worktree mismatch EARLY — before any command execution. + # Why: read commands like ``list`` / ``query`` trigger + # ``ensure_codelens_dir(workspace)`` as a side effect of + # loading the registry, which creates ``.codelens/`` in the + # worktree. If we probed after execution, the worktree would + # appear to have its own index and the mismatch would never + # fire. Probing here caches the *pre-execution* state so the + # banner reflects what the user actually configured, not what + # we just created for them. (issue #66 Phase 4) + # + # Wrapped in try/except so a detection bug never breaks a + # user's tool call — the banner is a nice-to-have, not a + # critical path. The ``_get_worktree_mismatch`` method itself + # also catches exceptions, but we double-wrap here so even a + # bug in the caching logic doesn't escape. + if cmd_name not in ("scan", "init"): + try: + self._get_worktree_mismatch(workspace) + except Exception as exc: + print( + f"[CodeLens MCP] worktree early-probe failed: {exc}", + file=sys.stderr, + ) + # Check cache for non-mutating commands cache_key = f"cmd:{workspace}:{cmd_name}:{json.dumps(arguments, sort_keys=True, ensure_ascii=False)}" if cmd_name not in ("scan", "init"): @@ -1583,6 +1614,12 @@ def _handle_tools_call(self, params: Dict[str, Any]) -> Dict[str, Any]: # last tool call (issue #47). Even cached responses must # drain the queue so the agent doesn't miss warnings. self._attach_pending_hooks(response) + # Attach worktree-mismatch banner (issue #66 Phase 4) on + # read tools. Cached responses get the same banner as fresh + # ones — the warning is a property of the workspace, not + # the call. Mutating commands (scan, init) skip the banner + # because they're the user's fix path. + self._attach_worktree_banner(response, workspace) return response # Execute the command @@ -1597,6 +1634,13 @@ def _handle_tools_call(self, params: Dict[str, Any]) -> Dict[str, Any]: # If scan, also cache the registry in memory if cmd_name == "scan" and workspace: self._load_registry_to_memory(workspace) + # A scan is the moment the index becomes fresh — drop + # any cached worktree-mismatch verdict so the next read + # tool re-probes against the new state. (If the user + # just ran ``codelens init -i`` in the worktree, the + # mismatch is now resolved and the banner should + # disappear.) + self._worktree_mismatch_cache.pop(os.path.abspath(workspace), None) response = { "content": [{ @@ -1615,6 +1659,11 @@ def _handle_tools_call(self, params: Dict[str, Any]) -> Dict[str, Any]: # response. (The hook we just scheduled will surface on the # next call — that's by design: hooks are non-blocking.) self._attach_pending_hooks(response) + # Attach worktree-mismatch banner on read tools (issue + # #66 Phase 4). Skip mutating commands (scan/init) — they're + # the user's remediation path, not analysis calls. + if cmd_name not in ("scan", "init"): + self._attach_worktree_banner(response, workspace) return response @@ -1636,6 +1685,11 @@ def _handle_tools_call(self, params: Dict[str, Any]) -> Dict[str, Any]: # agent doesn't lose them — issue #47 spec calls for hook # results to be surfaced via "next tool response". self._attach_pending_hooks(response) + # And still attach the worktree banner — if the user is in + # a misconfigured worktree, that context is more useful + # than the error itself. The error is almost certainly + # caused by the wrong index being loaded. + self._attach_worktree_banner(response, workspace) return response # ─── Hook integration (issue #47) ───────────────────── @@ -1713,6 +1767,100 @@ def _attach_pending_hooks(self, response: Dict[str, Any]) -> None: except Exception: pass + def _get_worktree_mismatch(self, workspace: str) -> Optional[Dict[str, Any]]: + """Return cached worktree mismatch record for ``workspace``. + + Probes :func:`sync.worktree.detect_worktree_index_mismatch` + exactly once per workspace per server lifetime — detection + shells out to ``git`` twice, which is too expensive to repeat + on every MCP tool call. The result is cached in + ``self._worktree_mismatch_cache`` keyed by absolute workspace + path. + + Returns ``None`` when there is no mismatch, when git is not + available, or when the workspace is not under git control — + callers treat ``None`` as "no banner to show". + + Returns a populated dict (with ``mismatch=True`` and the full + mismatch record) when the workspace is a worktree using a + foreign index. Callers attach this to the tool response so + agents can surface the warning. + + Args: + workspace: Absolute path to the workspace root. Empty + strings return ``None`` without probing. + + Why this lives on the server, not on each command: + --------------------------------------------------- + The mismatch is a property of *where the server is running*, + not of *which command is being called*. Caching it at the + server level means a single git probe per workspace, shared + across all read tools. + """ + if not workspace: + return None + ws_key = os.path.abspath(workspace) + cached = self._worktree_mismatch_cache.get(ws_key, None) + # Note: ``None`` (default from .get) means "not yet probed". + # A probed "no mismatch" is stored as an empty dict ``{}`` to + # distinguish it from "never probed". This is documented at + # the cache field declaration. + if cached is None: + try: + from sync.worktree import detect_worktree_index_mismatch + + mismatch = detect_worktree_index_mismatch(ws_key) + if mismatch and mismatch.get("mismatch"): + self._worktree_mismatch_cache[ws_key] = mismatch + else: + # Probed, no mismatch — store sentinel to skip + # future git probes for this workspace. + self._worktree_mismatch_cache[ws_key] = {} + except Exception as exc: + # Detection failure must never break a tool call. + # Log to stderr (mirrors HookManager pattern) and + # cache the "no mismatch" sentinel so we don't retry + # on every subsequent call. + print( + f"[CodeLens MCP] worktree mismatch detection failed: {exc}", + file=sys.stderr, + ) + self._worktree_mismatch_cache[ws_key] = {} + result = self._worktree_mismatch_cache.get(ws_key) + # Return the populated dict only if it has ``mismatch=True``. + if result and result.get("mismatch"): + return result + return None + + def _attach_worktree_banner(self, response: Dict[str, Any], workspace: str) -> None: + """Attach a worktree-mismatch banner to ``response`` if needed. + + Adds a ``_worktree_warning`` field to the response payload. + The field is a dict with the full mismatch record plus a + human-readable ``banner`` string. Agents that know about the + field surface the banner; agents that don't ignore it (per + MCP spec — unknown top-level fields are ignored). + + Why a separate field rather than prepending to ``content``: + Prepending text to the ``content`` array would corrupt the + JSON payload that agents parse out of the second content + item. A top-level field keeps the JSON response intact while + still surfacing the warning prominently. + """ + try: + mismatch = self._get_worktree_mismatch(workspace) + if not mismatch: + return + from sync.worktree import format_worktree_banner + + response["_worktree_warning"] = { + "banner": format_worktree_banner(mismatch), + "mismatch": mismatch, + } + except Exception: + # Banner attachment must never break a tool response. + pass + def _send_hook_notification(self, notification: Dict[str, Any]) -> None: """Push a hook notification to the agent via stdout JSON-RPC. diff --git a/scripts/sync/__init__.py b/scripts/sync/__init__.py new file mode 100644 index 00000000..f05fe77a --- /dev/null +++ b/scripts/sync/__init__.py @@ -0,0 +1,27 @@ +"""CodeLens sync subpackage — workspace ↔ index reconciliation helpers. + +This package contains modules that detect and repair drift between the +on-disk working tree and the persisted CodeLens index under +``.codelens/``. Drift can happen for several reasons: + +* The user switched git branches without re-scanning. +* The user is working inside a git worktree whose ``.codelens/`` was + never created, so CodeLens silently walks up and loads the main + checkout's index — which indexes a *different* branch. +* Files were edited while no MCP server was running (Phase 2 of + issue #66 — connect-time catch-up, future module). + +Modules +------- +``worktree`` — git worktree ↔ index mismatch detection (issue #66 Phase 4). + +Why a subpackage? +----------------- +The engines under ``scripts/*_engine.py`` analyse code. The modules +under ``scripts/sync/`` analyse *state*: they answer "is the index +still a faithful reflection of the working tree?" Without that +guarantee, every downstream analysis is potentially reading stale +data, which is worse than no data at all. +""" + +__all__ = ["worktree"] diff --git a/scripts/sync/worktree.py b/scripts/sync/worktree.py new file mode 100644 index 00000000..914b0010 --- /dev/null +++ b/scripts/sync/worktree.py @@ -0,0 +1,378 @@ +# @WHO: scripts/sync/worktree.py +# @WHAT: Detect git worktree ↔ CodeLens index mismatch (issue #66 Phase 4). +# @PART: sync +# @ENTRY: detect_worktree_index_mismatch() +"""Git worktree ↔ CodeLens index mismatch detection. + +Why this module exists +---------------------- +When a user runs CodeLens inside a git worktree that does not have its +own ``.codelens/`` directory, CodeLens's workspace auto-detection +walks up the directory tree and silently picks up the *main* +checkout's ``.codelens/`` index. That index was built from a different +branch — every subsequent ``query`` / ``trace`` / ``dataflow`` / +``taint`` answer is then grounded in the wrong file set, with no +warning to the user or the agent. + +This module answers one question: + + "Is the CodeLens index we are about to read actually indexing the + working tree we are standing in?" + +If not, it returns a structured mismatch record that callers can +surface as a warning (``codelens doctor``) or as a banner field on +MCP tool responses (``mcp_server._handle_tools_call``). + +Design constraints +------------------ +* **No external deps** — uses ``subprocess`` to call ``git``. Mirrors + the pattern in ``scripts/git_aware.py``. +* **Python 3.8+** compatible. +* **Git is OPTIONAL** — every public function returns a benign "no + mismatch" result when git is missing or the workspace is not under + git control. Never raises. +* **Subprocess-budget aware** — ``detect_worktree_index_mismatch`` + shells out at most twice per call (``show-toplevel`` and + ``git-common-dir``). Callers that need to call this on every MCP + tool response should cache the result per workspace — see + ``MCPServer._worktree_mismatch_cache``. + +Public API +---------- +* :func:`detect_worktree_index_mismatch` — the main entry point. +* :func:`format_worktree_warning` — human-readable warning for + ``codelens doctor`` text output. +* :func:`format_worktree_banner` — one-line banner for MCP responses. +""" + +from __future__ import annotations + +import os +import subprocess +from typing import Any, Dict, Optional + +from utils import logger + +# Exit-code threshold for "git not installed" vs "git ran but failed". +# ``git --version`` returns 0 on success. ``FileNotFoundError`` means +# git is not installed at all. +_GIT_NOT_INSTALLED_HINT = "git binary not found on PATH" + + +def _run_git(workspace: str, args: list) -> Optional[str]: + """Run ``git`` inside ``workspace`` and return stripped stdout. + + Returns ``None`` if git is unavailable, the workspace is not a git + repository, or the command exits non-zero. Never raises — callers + treat ``None`` as "git could not answer" and fall back. + + Mirrors :func:`git_aware._run_git` deliberately rather than + importing it, because (a) that function is private (underscore + prefixed) and (b) keeping the sync subpackage self-contained means + it can be vendored into other tooling without dragging + ``git_aware`` along. + + Args: + workspace: Absolute path to the directory to run git in. + args: Arguments to pass to ``git`` (e.g. ``["rev-parse", "HEAD"]``). + + Returns: + Stripped stdout on success, ``None`` on any failure. + """ + try: + result = subprocess.run( + ["git"] + args, + cwd=workspace, + capture_output=True, + text=True, + timeout=5, # Same budget as git_aware — generous for slow disks, + # short enough to not hang an MCP tool call. + check=False, # We inspect returncode ourselves. + ) + except FileNotFoundError: + # git not installed — log once at debug so we don't spam. + logger.debug(_GIT_NOT_INSTALLED_HINT) + return None + except (subprocess.SubprocessError, OSError) as exc: + logger.debug(f"worktree: git invocation failed: {exc}") + return None + + if result.returncode != 0: + # Most common: "not a git repository" when run outside a repo. + # stderr has the detail; log at debug so users with + # CODELENS_DEBUG=1 can see why detection bailed. + logger.debug( + f"worktree: git {' '.join(args)} exit={result.returncode} " + f"stderr={result.stderr.strip()[:200]}" + ) + return None + return result.stdout.strip() or None + + +def _resolve_common_dir(workspace: str, raw: str) -> Optional[str]: + """Resolve a ``--git-common-dir`` return value to an absolute path. + + ``git rev-parse --git-common-dir`` may return a path relative to + ``workspace`` (e.g. ``.git`` or ``../../.git``) on some git + versions when ``workspace`` is not the worktree top-level. We need + an absolute path so the parent-dir computation in + :func:`detect_worktree_index_mismatch` works regardless of cwd. + """ + if not raw: + return None + if os.path.isabs(raw): + return os.path.normpath(raw) + # Resolve relative to the directory git was run in. + return os.path.normpath(os.path.join(workspace, raw)) + + +def _find_index_root(start_dir: str, max_depth: int = 10) -> Optional[str]: + """Walk up from ``start_dir`` looking for a ``.codelens/`` directory. + + This mirrors the walk-up in :func:`codelens._detect_workspace` so + we detect the *same* index that CodeLens would actually load. If + we walked a different number of levels, we'd risk reporting a + mismatch against an index that isn't even being used. + + Args: + start_dir: Directory to start walking from (usually the workspace). + max_depth: Maximum number of parent dirs to walk up. Default 10 + matches :func:`codelens._detect_workspace`. + + Returns: + Absolute path to the directory containing ``.codelens/``, or + ``None`` if no ``.codelens/`` was found within ``max_depth`` + levels. + """ + current = os.path.abspath(start_dir) + depth = 0 + while depth <= max_depth: + if os.path.isdir(os.path.join(current, ".codelens")): + return current + parent = os.path.dirname(current) + if parent == current: + # Reached filesystem root. + break + current = parent + depth += 1 + return None + + +def detect_worktree_index_mismatch(project_root: str) -> Dict[str, Any]: + """Detect whether ``project_root`` is a worktree using a foreign index. + + This is the main entry point. It answers: + + * Is ``project_root`` inside a git worktree (i.e., not the main + checkout)? + * If so, does the worktree have its own ``.codelens/`` index, or + is CodeLens going to silently walk up and load the main + checkout's index — which was built from a *different* branch? + + Args: + project_root: Absolute path to the directory CodeLens is + operating on. Usually the resolved workspace. + + Returns: + A dict with the following shape (always present, never raises): + + ``mismatch`` (bool) + ``True`` if CodeLens is about to read an index that does + not belong to the current worktree. Callers should surface + a warning. + + ``reason`` (str) + Machine-readable reason code: + + * ``"not_a_git_repo"`` — git not installed or + ``project_root`` is not inside a git repository. + * ``"not_a_worktree"`` — main checkout, no worktree + concerns. + * ``"worktree_has_own_index"`` — worktree, but its own + ``.codelens/`` exists. No drift. + * ``"no_index_found"`` — worktree, and no ``.codelens/`` + anywhere up the tree. No drift (nothing to be wrong). + * ``"worktree_uses_main_index"`` — MISMATCH. Worktree is + going to read the main checkout's index. + + ``worktree_root`` (str | None) + Absolute path to the worktree's top level (the result of + ``git rev-parse --show-toplevel``). ``None`` if not in a + git repo. + + ``main_checkout_root`` (str | None) + Absolute path to the main checkout's root. ``None`` if not + in a git repo or if git could not resolve the common dir. + + ``index_root`` (str | None) + Absolute path to the directory that holds the + ``.codelens/`` CodeLens will actually load. ``None`` if no + ``.codelens/`` exists anywhere up the tree. + + ``suggestion`` (str | None) + One-line remediation hint shown to the user. ``None`` when + there is no mismatch. + + The function is intentionally side-effect free — it does not write + anything, does not mutate the registry, and does not auto-run + ``codelens init``. The caller decides what to do with the result. + """ + # Defensive: ensure project_root is a string and exists, but do + # NOT raise — return a benign "no mismatch" so callers can keep + # operating. This matches the spirit of git_aware: detection + # failure must never break the actual work. + if not project_root or not isinstance(project_root, str): + return _no_mismatch("not_a_git_repo") + if not os.path.isdir(project_root): + return _no_mismatch("not_a_git_repo") + + # ─── Step 1: git worktree root ──────────────────────────────── + toplevel_raw = _run_git(project_root, ["rev-parse", "--show-toplevel"]) + if toplevel_raw is None: + return _no_mismatch("not_a_git_repo") + worktree_root = os.path.abspath(toplevel_raw) + + # ─── Step 2: git common dir (the main repo's .git) ─────────── + common_raw = _run_git(project_root, ["rev-parse", "--git-common-dir"]) + if common_raw is None: + # Some very old git versions don't know --git-common-dir. We + # can't reliably detect a worktree without it — bail to "no + # mismatch" rather than risk a false positive. + return _no_mismatch("not_a_git_repo") + + common_dir = _resolve_common_dir(project_root, common_raw) + if not common_dir: + return _no_mismatch("not_a_git_repo") + + # The common dir is always /.git (or + # /.git for bare-style worktrees). The main + # checkout root is the parent. + main_checkout_root = os.path.dirname(common_dir) + + # If the worktree root equals the main checkout root, we're in the + # main checkout — no worktree concerns. But we still know the + # worktree_root and main_checkout_root (they're the same path), + # so populate them for callers that want to display the resolved + # paths regardless of mismatch state. + if os.path.abspath(worktree_root) == os.path.abspath(main_checkout_root): + # Walk up to find the index (mirrors codelens._detect_workspace). + index_root = _find_index_root(project_root) + return { + "mismatch": False, + "reason": "not_a_worktree" if index_root else "no_index_found", + "worktree_root": worktree_root, + "main_checkout_root": main_checkout_root, + "index_root": index_root, + "suggestion": None, + } + + # ─── Step 3: where does .codelens/ actually live? ──────────── + # Walk up from the *project_root* (which may be a subdirectory of + # the worktree root, not the worktree root itself) so we find the + # same index that codelens._detect_workspace would find. + index_root = _find_index_root(project_root) + + if index_root is None: + # No .codelens anywhere — not a mismatch per se, just an + # uninitialised workspace. The user will get a "run codelens + # init" prompt elsewhere; we don't double up. + return { + "mismatch": False, + "reason": "no_index_found", + "worktree_root": worktree_root, + "main_checkout_root": main_checkout_root, + "index_root": None, + "suggestion": None, + } + + # If the index lives inside the worktree root, the worktree has + # its own index — that's the correct setup. + if os.path.abspath(index_root) == os.path.abspath(worktree_root): + return { + "mismatch": False, + "reason": "worktree_has_own_index", + "worktree_root": worktree_root, + "main_checkout_root": main_checkout_root, + "index_root": index_root, + "suggestion": None, + } + + # MISMATCH: the index we're about to read is in the main checkout + # (or some other ancestor), not in this worktree. Reading it will + # return symbols from a different branch. + return { + "mismatch": True, + "reason": "worktree_uses_main_index", + "worktree_root": worktree_root, + "main_checkout_root": main_checkout_root, + "index_root": index_root, + "suggestion": ( + f"Run 'codelens init -i {worktree_root}' to build a " + f"worktree-local index, or switch to the main checkout at " + f"{main_checkout_root}." + ), + } + + +def _no_mismatch(reason: str) -> Dict[str, Any]: + """Return a benign mismatch dict with the given reason code.""" + return { + "mismatch": False, + "reason": reason, + "worktree_root": None, + "main_checkout_root": None, + "index_root": None, + "suggestion": None, + } + + +def format_worktree_warning(mismatch: Dict[str, Any]) -> str: + """Format a mismatch record as a multi-line human-readable warning. + + Used by ``codelens doctor`` text output. Returns an empty string + when there is no mismatch — doctor calls this unconditionally and + only prints the result if non-empty. + + Args: + mismatch: A dict returned by :func:`detect_worktree_index_mismatch`. + + Returns: + Multi-line warning string, or ``""`` if no mismatch. + """ + if not mismatch or not mismatch.get("mismatch"): + return "" + parts = [ + "WORKTREE INDEX MISMATCH", + f" worktree: {mismatch.get('worktree_root')}", + f" main checkout: {mismatch.get('main_checkout_root')}", + f" index loaded: {mismatch.get('index_root')}", + " problem: CodeLens is reading the main checkout's index,", + " which was built from a different branch.", + ] + suggestion = mismatch.get("suggestion") + if suggestion: + parts.append(f" fix: {suggestion}") + return "\n".join(parts) + + +def format_worktree_banner(mismatch: Dict[str, Any]) -> str: + """Format a mismatch record as a single-line banner for MCP responses. + + Returns an empty string when there is no mismatch, so callers can + unconditionally prepend the banner without producing empty noise + on every response. + + Args: + mismatch: A dict returned by :func:`detect_worktree_index_mismatch`. + + Returns: + One-line banner string, or ``""`` if no mismatch. + """ + if not mismatch or not mismatch.get("mismatch"): + return "" + return ( + f"⚠️ WORKTREE INDEX MISMATCH: workspace is in a git worktree at " + f"{mismatch.get('worktree_root')} but CodeLens is reading the index " + f"at {mismatch.get('index_root')} (main checkout, different branch). " + f"{mismatch.get('suggestion') or ''}" + ).strip() diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 49b4524d..6a5b46a7 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -425,3 +425,145 @@ def test_outdated_install_returns_warning(self): result = doctor_module._check_latest_version() assert result["status"] == "warning" assert "latest" in result["detail"].lower() + + +# ─── Worktree mismatch check (issue #66 Phase 4) ─────────────── + + +def _git_available() -> bool: + """Return True if the ``git`` binary is installed.""" + try: + result = subprocess.run( + ["git", "--version"], + capture_output=True, text=True, timeout=5, check=False, + ) + return result.returncode == 0 + except (FileNotFoundError, subprocess.SubprocessError, OSError): + return False + + +pytestmark_worktree = pytest.mark.skipif( + not _git_available(), + reason="git not available — worktree doctor tests require the git binary", +) + + +@pytestmark_worktree +class TestWorktreeMismatchCheck: + """The worktree-mismatch check must detect drift without breaking doctor. + + Covers three scenarios: + + * Main checkout — no mismatch, ``status="ok"``. + * Worktree with its own ``.codelens/`` — no mismatch, ``status="ok"``. + * Worktree using the main checkout's index — MISMATCH, + ``status="warning"`` with a multi-line detail message. + + Plus an integration test that verifies the check appears in + ``_run_all_checks`` output and runs *before* the writable check + (the writable check creates ``.codelens/`` as a side effect, so + ordering matters — see comment in ``_run_all_checks``). + """ + + def _make_repo(self, td: str) -> None: + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=td, check=True) + subprocess.run(["git", "config", "user.name", "Test"], cwd=td, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=td, check=True + ) + with open(os.path.join(td, "README.md"), "w") as f: + f.write("init\n") + subprocess.run(["git", "add", "."], cwd=td, check=True) + subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=td, check=True) + + def test_main_checkout_returns_ok(self, tmp_path): + """A main checkout with its own ``.codelens/`` reports no mismatch.""" + self._make_repo(str(tmp_path)) + os.makedirs(os.path.join(tmp_path, ".codelens")) + result = doctor_module._check_worktree_mismatch(str(tmp_path)) + assert result["name"] == "workspace.worktree_index_mismatch" + assert result["status"] == "ok" + assert result["fixable"] is False + + def test_worktree_with_own_codelens_returns_ok(self, tmp_path): + """A worktree that has its own ``.codelens/`` reports no mismatch.""" + self._make_repo(str(tmp_path)) + wt = tmp_path / "wt-feature" + subprocess.run( + ["git", "worktree", "add", "-q", str(wt), "-b", "feature"], + cwd=str(tmp_path), check=True, + ) + os.makedirs(wt / ".codelens") + result = doctor_module._check_worktree_mismatch(str(wt)) + assert result["status"] == "ok" + assert result["found"] == "worktree_has_own_index" + + def test_worktree_using_main_index_returns_warning(self, tmp_path): + """A worktree without ``.codelens/`` (main has it) → warning.""" + self._make_repo(str(tmp_path)) + os.makedirs(tmp_path / ".codelens") # main has the index + wt = tmp_path / "wt-feature" + subprocess.run( + ["git", "worktree", "add", "-q", str(wt), "-b", "feature"], + cwd=str(tmp_path), check=True, + ) + # worktree does NOT have .codelens — would walk up to main's. + result = doctor_module._check_worktree_mismatch(str(wt)) + assert result["status"] == "warning" + assert result["found"] == "worktree_uses_main_index" + assert "WORKTREE INDEX MISMATCH" in result["detail"] + assert str(wt) in result["detail"] + assert str(tmp_path) in result["detail"] + assert "codelens init" in result["detail"] + + def test_no_workspace_returns_ok(self): + """An empty workspace returns ok (doctor is callable without a workspace).""" + result = doctor_module._check_worktree_mismatch("") + assert result["status"] == "ok" + assert result["name"] == "workspace.worktree_index_mismatch" + + def test_check_appears_in_run_all_checks(self, tmp_path): + """``_run_all_checks`` includes the worktree check.""" + self._make_repo(str(tmp_path)) + os.makedirs(tmp_path / ".codelens") + checks = doctor_module._run_all_checks(str(tmp_path)) + names = [c["name"] for c in checks] + assert "workspace.worktree_index_mismatch" in names + + def test_check_runs_before_writable_check(self, tmp_path): + """Worktree check runs BEFORE the writable check. + + The writable check creates ``.codelens/`` as a side effect of + probing write permissions. If the worktree check ran after, the + worktree would appear to have its own index (the just-created + empty dir) and the mismatch would never fire. + + This test enforces the ordering comment in ``_run_all_checks``. + """ + self._make_repo(str(tmp_path)) + # Main has .codelens, worktree won't — so mismatch should be + # detected IF the check runs before writable creates .codelens + # in the worktree. + os.makedirs(tmp_path / ".codelens") + wt = tmp_path / "wt-feature" + subprocess.run( + ["git", "worktree", "add", "-q", str(wt), "-b", "feature"], + cwd=str(tmp_path), check=True, + ) + checks = doctor_module._run_all_checks(str(wt)) + wt_check = next( + c for c in checks if c["name"] == "workspace.worktree_index_mismatch" + ) + writable_check = next( + c for c in checks if c["name"] == "workspace.codelens_writable" + ) + wt_idx = checks.index(wt_check) + writable_idx = checks.index(writable_check) + assert wt_idx < writable_idx, ( + "worktree check must run BEFORE codelens_writable check — " + "the writable check creates .codelens/ as a side effect, " + "which would mask the mismatch" + ) + # And the mismatch IS detected (proves the ordering matters). + assert wt_check["status"] == "warning" + assert wt_check["found"] == "worktree_uses_main_index" diff --git a/tests/test_mcp_hooks.py b/tests/test_mcp_hooks.py index 70cfcd25..99096fa9 100644 --- a/tests/test_mcp_hooks.py +++ b/tests/test_mcp_hooks.py @@ -27,6 +27,7 @@ import json import os +import subprocess import sys import tempfile import threading @@ -814,3 +815,231 @@ def drain_pending(self): assert "_hooks" not in response server._shutdown() + + +# ─── Worktree mismatch banner integration (issue #66 Phase 4) ──── + + +def _git_available() -> bool: + """Return True if the ``git`` binary is installed.""" + try: + result = subprocess.run( + ["git", "--version"], + capture_output=True, text=True, timeout=5, check=False, + ) + return result.returncode == 0 + except (FileNotFoundError, subprocess.SubprocessError, OSError): + return False + + +pytestmark_wt = pytest.mark.skipif( + not _git_available(), + reason="git not available — worktree MCP tests require the git binary", +) + + +def _make_repo(td): + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=td, check=True) + subprocess.run(["git", "config", "user.name", "Test"], cwd=td, check=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=td, check=True) + with open(os.path.join(td, "README.md"), "w") as f: + f.write("init\n") + subprocess.run(["git", "add", "."], cwd=td, check=True) + subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=td, check=True) + + +def _write_empty_registry(td): + """Drop a minimal registry so 'list' command can load it without crashing.""" + os.makedirs(os.path.join(td, ".codelens"), exist_ok=True) + with open(os.path.join(td, ".codelens", "backend.json"), "w") as f: + json.dump({"symbols": [], "metadata": {"version": "1"}}, f) + with open(os.path.join(td, ".codelens", "frontend.json"), "w") as f: + json.dump({"classes": [], "metadata": {"version": "1"}}, f) + + +@pytestmark_wt +class TestWorktreeBannerAttachment: + """The MCP server must attach a ``_worktree_warning`` field on read-tool + responses when the workspace is a worktree using a foreign index. + + Contract (issue #66 Phase 4 acceptance criteria): + + * ``_worktree_warning`` is absent when there is no mismatch. + * ``_worktree_warning`` is present (with banner + mismatch dict) when + the workspace is a worktree using the main checkout's index. + * The mismatch is probed ONCE per workspace (cached) — second tool + call reuses the cached verdict without re-shelling out to git. + * Mutating commands (``scan``, ``init``) skip the banner. + * The cache is invalidated when ``scan`` runs (the user may have + just run ``codelens init -i`` in the worktree to fix the mismatch). + """ + + def test_read_tool_attaches_banner_on_mismatch(self, tmp_path): + """``codelens_list`` from a mismatched worktree → banner attached.""" + _make_repo(str(tmp_path)) + _write_empty_registry(str(tmp_path)) # main has .codelens + wt = tmp_path / "wt-feature" + subprocess.run( + ["git", "worktree", "add", "-q", str(wt), "-b", "feature"], + cwd=str(tmp_path), check=True, + ) + # worktree does NOT have its own .codelens + + from mcp_server import MCPServer + server = MCPServer() + try: + response = server._handle_tools_call({ + "name": "codelens_list", + "arguments": {"workspace": str(wt)}, + }) + assert response.get("isError") is False + assert "_worktree_warning" in response + warning = response["_worktree_warning"] + assert "banner" in warning + assert "mismatch" in warning + assert "WORKTREE INDEX MISMATCH" in warning["banner"] + assert warning["mismatch"]["mismatch"] is True + assert warning["mismatch"]["reason"] == "worktree_uses_main_index" + finally: + server._shutdown() + + def test_read_tool_no_banner_on_main_checkout(self, tmp_path): + """``codelens_list`` from main checkout → no banner.""" + _make_repo(str(tmp_path)) + _write_empty_registry(str(tmp_path)) + + from mcp_server import MCPServer + server = MCPServer() + try: + response = server._handle_tools_call({ + "name": "codelens_list", + "arguments": {"workspace": str(tmp_path)}, + }) + assert response.get("isError") is False + assert "_worktree_warning" not in response + finally: + server._shutdown() + + def test_mismatch_is_cached_per_workspace(self, tmp_path): + """Second tool call reuses the cached verdict — no re-probe of git. + + The MCP server calls ``_get_worktree_mismatch`` twice per tool + call: once early (before command execution, to cache the + pre-execution state) and once inside ``_attach_worktree_banner`` + (to read the cached verdict). Both calls hit the cache on the + second tool call — neither re-shells out to git. + + We verify this by spying on the underlying + ``detect_worktree_index_mismatch`` function (which does the + actual git probe) — it should be called exactly once across + two tool calls. + """ + _make_repo(str(tmp_path)) + _write_empty_registry(str(tmp_path)) + wt = tmp_path / "wt-feature" + subprocess.run( + ["git", "worktree", "add", "-q", str(wt), "-b", "feature"], + cwd=str(tmp_path), check=True, + ) + + from mcp_server import MCPServer + import sync.worktree as wt_module + server = MCPServer() + try: + # Spy on the actual git-probing function. + call_count = {"n": 0} + orig_detect = wt_module.detect_worktree_index_mismatch + + def counting_detect(project_root): + call_count["n"] += 1 + return orig_detect(project_root) + + wt_module.detect_worktree_index_mismatch = counting_detect + try: + # First call — probes git once, caches result. + server._handle_tools_call({ + "name": "codelens_list", + "arguments": {"workspace": str(wt)}, + }) + assert call_count["n"] == 1, ( + f"first call should probe git exactly once, got {call_count['n']}" + ) + + # Second call — should hit cache, NOT re-probe git. + server._handle_tools_call({ + "name": "codelens_list", + "arguments": {"workspace": str(wt)}, + }) + assert call_count["n"] == 1, ( + f"second call should reuse cache without re-probing git, " + f"got {call_count['n']} probes total" + ) + finally: + wt_module.detect_worktree_index_mismatch = orig_detect + finally: + server._shutdown() + + def test_scan_invalidates_mismatch_cache(self, tmp_path): + """``codelens_scan`` drops the cached verdict so the next read re-probes. + + Rationale: a scan is the user's signal that the index has changed + state. If they just ran ``codelens init -i`` in the worktree, the + mismatch is resolved and the banner should disappear on the next + read tool call. + """ + _make_repo(str(tmp_path)) + _write_empty_registry(str(tmp_path)) + wt = tmp_path / "wt-feature" + subprocess.run( + ["git", "worktree", "add", "-q", str(wt), "-b", "feature"], + cwd=str(tmp_path), check=True, + ) + + from mcp_server import MCPServer + server = MCPServer() + try: + # First call — populates cache with mismatch verdict. + server._handle_tools_call({ + "name": "codelens_list", + "arguments": {"workspace": str(wt)}, + }) + wt_key = os.path.abspath(str(wt)) + assert wt_key in server._worktree_mismatch_cache + + # Simulate a scan call — should drop the cached entry. + # We don't actually run scan (it requires tree-sitter etc); + # we just call the cache-invalidation logic directly to + # test the contract. + server._worktree_mismatch_cache.pop(wt_key, None) + assert wt_key not in server._worktree_mismatch_cache + finally: + server._shutdown() + + def test_mismatch_detection_never_breaks_tool_call(self, tmp_path): + """Even if mismatch detection raises, the tool response is intact. + + The ``_attach_worktree_banner`` method wraps everything in + try/except so a detection bug never breaks a user's query. + This test verifies that contract by monkey-patching + ``_get_worktree_mismatch`` to raise. + """ + _make_repo(str(tmp_path)) + _write_empty_registry(str(tmp_path)) + + from mcp_server import MCPServer + server = MCPServer() + try: + def raising_get(workspace): + raise RuntimeError("simulated detection bug") + server._get_worktree_mismatch = raising_get + + response = server._handle_tools_call({ + "name": "codelens_list", + "arguments": {"workspace": str(tmp_path)}, + }) + # Tool response is intact — no crash, no error field. + assert response.get("isError") is False + # And no _worktree_warning field (detection failed silently). + assert "_worktree_warning" not in response + finally: + server._shutdown() diff --git a/tests/test_worktree.py b/tests/test_worktree.py new file mode 100644 index 00000000..105d6c2e --- /dev/null +++ b/tests/test_worktree.py @@ -0,0 +1,407 @@ +"""Tests for ``scripts/sync/worktree.py`` — git worktree ↔ index mismatch (issue #66 Phase 4). + +Verifies: + +1. ``detect_worktree_index_mismatch`` correctly distinguishes: + - not-a-git-repo → ``reason="not_a_git_repo"``, ``mismatch=False`` + - main checkout → ``reason="not_a_worktree"``, ``mismatch=False`` + - worktree with own ``.codelens/`` → ``reason="worktree_has_own_index"``, ``mismatch=False`` + - worktree without ``.codelens/``, main has it → ``reason="worktree_uses_main_index"``, ``mismatch=True`` + - worktree without ``.codelens/`` anywhere → ``reason="no_index_found"``, ``mismatch=False`` +2. ``format_worktree_warning`` returns multi-line warning when mismatch, empty string otherwise. +3. ``format_worktree_banner`` returns single-line banner when mismatch, empty string otherwise. +4. Detection is robust to: + - missing git binary (skipped, not failed) + - non-existent workspace path + - workspace that is a file, not a directory + - empty/None workspace +5. The mismatch record includes all documented fields: ``mismatch``, ``reason``, + ``worktree_root``, ``main_checkout_root``, ``index_root``, ``suggestion``. + +All git operations use a temp directory + ``git init`` / ``git worktree add`` +so tests don't depend on the CodeLens repo's git state. If git isn't +installed, every test is skipped with ``pytest.skip('git not available')`` +— mirroring the pattern in ``tests/test_git_aware.py``. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tempfile + +import pytest + +# Add scripts directory to path (matches other test files). +SCRIPT_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts" +) +if SCRIPT_DIR not in sys.path: + sys.path.insert(0, SCRIPT_DIR) + +from sync.worktree import ( # noqa: E402 + detect_worktree_index_mismatch, + format_worktree_banner, + format_worktree_warning, +) + + +# ─── Skip-if-git-missing guard ────────────────────────────────────── + + +def _git_available() -> bool: + """Return True if the ``git`` binary is installed and supports worktrees.""" + try: + result = subprocess.run( + ["git", "--version"], + capture_output=True, text=True, timeout=5, check=False, + ) + if result.returncode != 0: + return False + # ``git worktree add`` was stabilised in git 2.5 (2015). Any + # git from the last decade supports it. We don't pin a minimum + # version — if ``git --version`` works, worktrees work. + return True + except (FileNotFoundError, subprocess.SubprocessError, OSError): + return False + + +pytestmark = pytest.mark.skipif( + not _git_available(), + reason="git not available — worktree tests require the git binary", +) + + +# ─── Fixtures ─────────────────────────────────────────────────────── + + +def _make_git_repo(td: str) -> None: + """Initialise a git repo in ``td`` with one commit. + + Sets local ``user.name`` / ``user.email`` so commits work without + relying on the host's global git config. + """ + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=td, check=True) + subprocess.run(["git", "config", "user.name", "Test"], cwd=td, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=td, check=True + ) + with open(os.path.join(td, "README.md"), "w") as f: + f.write("initial commit\n") + subprocess.run(["git", "add", "."], cwd=td, check=True) + subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=td, check=True) + + +def _make_worktree(main_repo: str, wt_name: str = "wt-feature") -> str: + """Create a git worktree of ``main_repo`` and return its path. + + The worktree is created on a new branch ``feature`` so it's + clearly distinct from the main branch — this matches the + real-world scenario where a user works on a feature branch in a + worktree. + """ + wt_path = os.path.join(main_repo, wt_name) + subprocess.run( + ["git", "worktree", "add", "-q", wt_path, "-b", "feature"], + cwd=main_repo, + check=True, + ) + return wt_path + + +def _make_codelens_dir(parent: str) -> str: + """Create a ``.codelens/`` directory inside ``parent`` and return its path.""" + codelens_dir = os.path.join(parent, ".codelens") + os.makedirs(codelens_dir, exist_ok=True) + return codelens_dir + + +# ─── Test: not a git repo ────────────────────────────────────────── + + +def test_not_a_git_repo_returns_no_mismatch(): + """A directory that's not under git control reports ``not_a_git_repo``.""" + with tempfile.TemporaryDirectory(prefix="codelens_wt_test_") as td: + result = detect_worktree_index_mismatch(td) + assert result["mismatch"] is False + assert result["reason"] == "not_a_git_repo" + assert result["worktree_root"] is None + assert result["main_checkout_root"] is None + assert result["index_root"] is None + assert result["suggestion"] is None + + +def test_nonexistent_path_returns_no_mismatch(): + """A path that doesn't exist reports ``not_a_git_repo`` (benign fallback).""" + result = detect_worktree_index_mismatch("/nonexistent/path/that/does/not/exist") + assert result["mismatch"] is False + assert result["reason"] == "not_a_git_repo" + + +def test_empty_string_returns_no_mismatch(): + """An empty workspace string reports ``not_a_git_repo``.""" + result = detect_worktree_index_mismatch("") + assert result["mismatch"] is False + assert result["reason"] == "not_a_git_repo" + + +def test_none_path_returns_no_mismatch(): + """A None workspace reports ``not_a_git_repo`` without raising.""" + result = detect_worktree_index_mismatch(None) # type: ignore[arg-type] + assert result["mismatch"] is False + assert result["reason"] == "not_a_git_repo" + + +def test_file_path_returns_no_mismatch(): + """A path pointing to a file (not a dir) reports ``not_a_git_repo``.""" + with tempfile.NamedTemporaryFile(prefix="codelens_wt_test_") as tf: + result = detect_worktree_index_mismatch(tf.name) + assert result["mismatch"] is False + assert result["reason"] == "not_a_git_repo" + + +# ─── Test: main checkout (not a worktree) ────────────────────────── + + +def test_main_checkout_with_codelens_no_mismatch(): + """A main checkout with its own ``.codelens/`` reports ``not_a_worktree``.""" + with tempfile.TemporaryDirectory(prefix="codelens_wt_test_") as td: + _make_git_repo(td) + _make_codelens_dir(td) + result = detect_worktree_index_mismatch(td) + assert result["mismatch"] is False + assert result["reason"] == "not_a_worktree" + assert result["worktree_root"] is not None + assert result["main_checkout_root"] is not None + # For a main checkout, worktree_root == main_checkout_root. + assert os.path.abspath(result["worktree_root"]) == os.path.abspath( + result["main_checkout_root"] + ) + assert result["index_root"] is not None + assert os.path.abspath(result["index_root"]) == os.path.abspath(td) + + +def test_main_checkout_without_codelens_no_mismatch(): + """A main checkout without ``.codelens/`` still reports ``not_a_worktree``. + + The absence of an index is not a worktree-specific problem — it's + just an uninitialised workspace, handled elsewhere. + """ + with tempfile.TemporaryDirectory(prefix="codelens_wt_test_") as td: + _make_git_repo(td) + # No .codelens/ created. + result = detect_worktree_index_mismatch(td) + assert result["mismatch"] is False + # The walk-up finds no .codelens anywhere, so the reason is + # ``no_index_found`` rather than ``not_a_worktree``. Both + # indicate no mismatch — the specific reason is informational. + assert result["reason"] in ("not_a_worktree", "no_index_found") + + +# ─── Test: worktree with its own index ───────────────────────────── + + +def test_worktree_with_own_codelens_no_mismatch(): + """A worktree that has its own ``.codelens/`` reports no mismatch. + + This is the correct setup: each worktree maintains its own index + because each worktree checks out a different branch. + """ + with tempfile.TemporaryDirectory(prefix="codelens_wt_test_") as td: + _make_git_repo(td) + wt_path = _make_worktree(td) + _make_codelens_dir(wt_path) # worktree has own index + result = detect_worktree_index_mismatch(wt_path) + assert result["mismatch"] is False + assert result["reason"] == "worktree_has_own_index" + assert os.path.abspath(result["worktree_root"]) == os.path.abspath(wt_path) + assert os.path.abspath(result["main_checkout_root"]) == os.path.abspath(td) + assert os.path.abspath(result["index_root"]) == os.path.abspath(wt_path) + assert result["suggestion"] is None + + +# ─── Test: worktree without own index, main has it (MISMATCH) ────── + + +def test_worktree_uses_main_index_mismatch_detected(): + """A worktree without ``.codelens/`` but main has it → MISMATCH. + + This is the core scenario the module exists to detect: CodeLens + will silently walk up and load the main checkout's index, which + was built from a different branch. + """ + with tempfile.TemporaryDirectory(prefix="codelens_wt_test_") as td: + _make_git_repo(td) + _make_codelens_dir(td) # main has .codelens + wt_path = _make_worktree(td) + # worktree does NOT have .codelens + + result = detect_worktree_index_mismatch(wt_path) + assert result["mismatch"] is True + assert result["reason"] == "worktree_uses_main_index" + assert os.path.abspath(result["worktree_root"]) == os.path.abspath(wt_path) + assert os.path.abspath(result["main_checkout_root"]) == os.path.abspath(td) + assert os.path.abspath(result["index_root"]) == os.path.abspath(td) + + suggestion = result["suggestion"] + assert suggestion is not None + assert "codelens init" in suggestion + assert wt_path in suggestion + + +def test_worktree_mismatch_from_subdirectory(): + """Mismatch is detected even when called from a subdir of the worktree. + + CodeLens's workspace auto-detection walks up from cwd, so the + workspace may resolve to the worktree root even when the user is + in a subdirectory. The mismatch detection must work from any + depth inside the worktree. + """ + with tempfile.TemporaryDirectory(prefix="codelens_wt_test_") as td: + _make_git_repo(td) + _make_codelens_dir(td) + wt_path = _make_worktree(td) + # Create a subdirectory inside the worktree + subdir = os.path.join(wt_path, "src", "deep", "nested") + os.makedirs(subdir) + + result = detect_worktree_index_mismatch(subdir) + assert result["mismatch"] is True + assert result["reason"] == "worktree_uses_main_index" + assert os.path.abspath(result["worktree_root"]) == os.path.abspath(wt_path) + + +# ─── Test: worktree without .codelens anywhere ───────────────────── + + +def test_worktree_no_codelens_anywhere_no_mismatch(): + """A worktree with no ``.codelens/`` anywhere up the tree. + + Nothing is being loaded, so there's nothing to be wrong about. + The reason is ``no_index_found`` — informational, not a warning. + """ + with tempfile.TemporaryDirectory(prefix="codelens_wt_test_") as td: + _make_git_repo(td) + wt_path = _make_worktree(td) + # No .codelens anywhere. + + result = detect_worktree_index_mismatch(wt_path) + assert result["mismatch"] is False + assert result["reason"] == "no_index_found" + assert result["worktree_root"] is not None + assert result["main_checkout_root"] is not None + assert result["index_root"] is None + + +# ─── Test: formatters ────────────────────────────────────────────── + + +def test_format_worktree_warning_empty_when_no_mismatch(): + """``format_worktree_warning`` returns ``""`` when no mismatch.""" + with tempfile.TemporaryDirectory(prefix="codelens_wt_test_") as td: + result = detect_worktree_index_mismatch(td) + assert format_worktree_warning(result) == "" + + +def test_format_worktree_warning_multiline_when_mismatch(): + """``format_worktree_warning`` returns multi-line warning when mismatch.""" + with tempfile.TemporaryDirectory(prefix="codelens_wt_test_") as td: + _make_git_repo(td) + _make_codelens_dir(td) + wt_path = _make_worktree(td) + result = detect_worktree_index_mismatch(wt_path) + assert result["mismatch"] is True + + warning = format_worktree_warning(result) + assert warning != "" + assert "WORKTREE INDEX MISMATCH" in warning + assert wt_path in warning + assert td in warning + assert "codelens init" in warning + # Multi-line — has at least 4 lines (header, worktree, main, index, problem, fix). + assert warning.count("\n") >= 4 + + +def test_format_worktree_banner_empty_when_no_mismatch(): + """``format_worktree_banner`` returns ``""`` when no mismatch.""" + with tempfile.TemporaryDirectory(prefix="codelens_wt_test_") as td: + result = detect_worktree_index_mismatch(td) + assert format_worktree_banner(result) == "" + + +def test_format_worktree_banner_single_line_when_mismatch(): + """``format_worktree_banner`` returns single-line banner when mismatch.""" + with tempfile.TemporaryDirectory(prefix="codelens_wt_test_") as td: + _make_git_repo(td) + _make_codelens_dir(td) + wt_path = _make_worktree(td) + result = detect_worktree_index_mismatch(wt_path) + assert result["mismatch"] is True + + banner = format_worktree_banner(result) + assert banner != "" + assert "WORKTREE INDEX MISMATCH" in banner + assert wt_path in banner + # Single line — no newlines. + assert "\n" not in banner + + +def test_format_worktree_warning_handles_empty_dict(): + """``format_worktree_warning`` is robust to an empty dict input.""" + assert format_worktree_warning({}) == "" + assert format_worktree_warning(None) == "" # type: ignore[arg-type] + + +def test_format_worktree_banner_handles_empty_dict(): + """``format_worktree_banner`` is robust to an empty dict input.""" + assert format_worktree_banner({}) == "" + assert format_worktree_banner(None) == "" # type: ignore[arg-type] + + +# ─── Test: return-shape contract ─────────────────────────────────── + + +def test_result_dict_has_all_documented_fields(): + """Every result dict must have all 6 documented fields. + + Callers (doctor, mcp_server) access these fields unconditionally + via ``.get()`` — but the contract is that they're always present. + Missing fields would be a silent API regression. + """ + with tempfile.TemporaryDirectory(prefix="codelens_wt_test_") as td: + _make_git_repo(td) + result = detect_worktree_index_mismatch(td) + for field in ( + "mismatch", + "reason", + "worktree_root", + "main_checkout_root", + "index_root", + "suggestion", + ): + assert field in result, f"missing field: {field}" + + +def test_mismatch_is_bool_not_truthy_value(): + """``mismatch`` must be a real bool, not a truthy value. + + Doctor and MCP server do ``if mismatch.get("mismatch"):`` — that + works with truthy values, but the contract says bool. Using bool + makes the JSON serialization deterministic (``true`` / ``false``, + not ``1`` / ``0`` or ``"yes"`` / ``"no"``). + """ + with tempfile.TemporaryDirectory(prefix="codelens_wt_test_") as td: + _make_git_repo(td) + _make_codelens_dir(td) + wt_path = _make_worktree(td) + result = detect_worktree_index_mismatch(wt_path) + assert isinstance(result["mismatch"], bool) + assert result["mismatch"] is True + + with tempfile.TemporaryDirectory(prefix="codelens_wt_test_") as td: + _make_git_repo(td) + result = detect_worktree_index_mismatch(td) + assert isinstance(result["mismatch"], bool) + assert result["mismatch"] is False