Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions scripts/commands/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,78 @@
}


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",

Check failure on line 305 in scripts/commands/doctor.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "workspace.worktree_index_mismatch" 4 times.

See more on https://sonarcloud.io/project/issues?id=Wolfvin_CodeLens&issues=AZ8j3Yw23-Z9uIIJjeix&open=AZ8j3Yw23-Z9uIIJjeix&pullRequest=154
"status": "ok",
"found": "no workspace",
"required": "workspace not in a worktree, or worktree has own .codelens/",

Check failure on line 308 in scripts/commands/doctor.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "workspace not in a worktree, or worktree has own .codelens/" 3 times.

See more on https://sonarcloud.io/project/issues?id=Wolfvin_CodeLens&issues=AZ8j3Yw23-Z9uIIJjeiw&open=AZ8j3Yw23-Z9uIIJjeiw&pullRequest=154
"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.

Expand Down Expand Up @@ -377,6 +449,12 @@
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
Expand Down
148 changes: 148 additions & 0 deletions scripts/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────

Expand Down Expand Up @@ -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"):
Expand All @@ -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
Expand All @@ -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": [{
Expand All @@ -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

Expand All @@ -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) ─────────────────────
Expand Down Expand Up @@ -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.

Expand Down
27 changes: 27 additions & 0 deletions scripts/sync/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading
Loading