Repository navigation
Nested git repos never reindex on branch switch: watcher only tracks session_project, index_status reports false freshness #1429
Description
Summary
In a multi-repo monorepo workspace (parent git root + many nested independent git checkouts), background auto-watch / reindex only tracks the MCP session project (cwd root). Branch switches and HEAD moves in already-indexed nested repositories are ignored.
Worse: index_status / list_projects surface live branch / head_sha from disk git, while the knowledge graph remains on the previously indexed tree. Agents therefore treat a stale graph as fresh and answer structural questions incorrectly with no error signal.
This is a high-severity reliability issue for agent workflows: silent wrong answers, not a hard failure.
Environment
| Item | Value |
|---|---|
| Version | codebase-memory-mcp 0.9.0 (darwin-arm64 release binary) |
| OS | macOS (arm64) |
| Config | auto_index=true, auto_watch=true, auto_index_limit=50000 |
| Client | Agent MCP session with cwd = monorepo parent |
| Cache | default ~/.cache/codebase-memory-mcp |
Setup shape (common agent monorepo)
/path/to/workspace/ ← git root; MCP session_root
group/nested-service/ ← nested independent git repo (already indexed)
other-nested-repos/ ← also indexed as separate projects
- Session project name: derived from parent path (e.g.
Users-...-workspace) - Nested project name: e.g.
nested-service(indexed earlier viaindex_repository)
The nested repo has mutually exclusive branch trees (different modules/files per branch). Example: branch A has module service-variant-a/, branch B has service-variant-b/ instead.
Reproduction
- Start MCP from the parent monorepo root. Confirm autoindex/watcher only registers the session project:
level=info msg=autoindex.start project=<session-project> path=/path/to/workspace
level=info msg=watcher.watch project=<session-project> path=/path/to/workspace
level=info msg=watcher.baseline project=<session-project> strategy=git files=yes
No watcher.watch line appears for the nested project.
- Ensure nested repo is indexed and on branch A:
codebase-memory-mcp cli index_status --project nested-service
# nodes ~N, graph contains symbols under service-variant-a/- Clean switch nested repo to a very different branch B:
cd group/nested-service
git status --porcelain # empty
git switch branch-b
# service-variant-a gone from disk; service-variant-b present- Wait well beyond poll interval (minutes). Re-check:
codebase-memory-mcp cli index_status --project nested-serviceActual
| Surface | After branch switch |
|---|---|
| Live files on disk | Branch B tree |
index_status.git.branch / head_sha |
Branch B (live) |
Graph nodes / edges |
Unchanged from branch A |
search_graph for branch-A-only symbols |
Still hits (e.g. paths under service-variant-a/) |
search_graph for branch-B paths |
Missing / empty |
search_code (disk grep) |
Correctly reflects branch B |
stderr watcher.reindex for nested project |
Never |
Expected
Either:
- Nested indexed projects under (or beside) the session are watched and reindexed when their
HEADmoves; or - Query surfaces refuse / flag staleness when graph snapshot ≠ current
HEAD; and index_statusexposes an indexed HEAD (orstale: true), not only live git metadata that always looks current.
Control: manual reindex works
codebase-memory-mcp cli index_repository \
--repo-path /path/to/workspace/group/nested-service \
--name nested-service \
--mode fast- Branch B → fewer/different nodes;
service-variant-b/*present, variant A gone fromfile_hashes - Switch back + reindex → graph matches branch A again
So the pipeline can rebuild correctly; automatic path never targets the nested project.
Note: omitting --name derived a second project name from absolute path, leaving the original project stale. Separate naming footgun, but not the core bug.
Root cause (source read on main / v0.9.0-era tree)
1. Watcher registration is session-scoped only
register_watcher_if_enabled only watches srv->session_project / srv->session_root:
/* Register the session project with the background watcher for ongoing
* change detection — unless auto_watch is disabled. */
static void register_watcher_if_enabled(cbm_mcp_server_t *srv) {
if (!srv->watcher || srv->session_project[0] == '\0' || srv->session_root[0] == '\0') {
return;
}
...
cbm_watcher_watch(srv->watcher, srv->session_project, srv->session_root);
}Already-indexed nested projects are never registered unless the MCP session cwd is that nested root.
2. Watcher can detect checkout when registered
watcher.c tracks git rev-parse HEAD and treats HEAD movement (commit / checkout / pull) as a change. Nested repos never reach this path.
3. Parent git HEAD does not move when a nested repo switches branches
Even continuous reindex of the session/parent project does not rebuild the nested project's DB. Nested .git trees are separate indexes.
4. False freshness compounds the failure
index_status / project list git fields read live git state (also discussed in #1213). After a nested branch switch, agents see matching branch/sha and assume the graph is current — while search_graph still returns symbols deleted on disk.
Related open issues (distinct but compounding):
- Watcher re-index leaves MCP server serving a stale cached store (POSIX, silent) #1191 / MCP session serves a stale graph indefinitely after the background watcher re-indexes #1296 — watcher reindex may leave MCP serving a stale cached store (POSIX)
- Branch.head_sha stale after re-indexing an existing project (freezes at creation-time HEAD) #1213 — Branch / head_sha freshness signals unreliable
- Feature request: worktree overlay indexing for branch-aware agent workflows #573 — branch/worktree overlay (longer-term product direction)
This report is specifically: unwatched nested projects + live git metadata on stale graphs.
Impact
- Agent monorepos with many nested git checkouts are a common CBM install shape.
- Silent structural wrong answers on branch-divergent code (different modules / layouts per branch).
- Worse than “index missing”: tools return confident, high-quality looking wrong results.
- No reliable client-side freshness check today without external bookkeeping.
Severity: high for agent reliability (silent incorrect graph).
Suggested fixes (shortest path first)
- Watch all indexed projects (or all projects whose
root_pathis under session root / configured roots), not onlysession_project. - Persist
indexed_head_sha(and branch) at successful index time; expose inindex_statuswithstale = (indexed_head_sha != live_head_sha). - On
stale, either auto-queue reindex or return an explicit staleness flag on graph tools so agents do not trust results blindly. - Keep Watcher re-index leaves MCP server serving a stale cached store (POSIX, silent) #1191/MCP session serves a stale graph indefinitely after the background watcher re-indexes #1296 store invalidation so successful reindex is visible to long-lived MCP sessions.
- Docs: state clearly that
auto_watchonly covers the session project today, and nested multi-repo setups need per-repo reindex or separate sessions until (1) lands.
Workaround (current)
After every git switch / checkout in a nested indexed repo:
codebase-memory-mcp cli index_repository \
--repo-path <nested-repo> \
--name <existing-project-name> \
--mode fastOr keep one clone per long-lived branch as separate project roots (avoids in-place branch switches).
Offer
Happy to refine the repro into a small fixture (parent repo + nested repo with two branches of divergent file trees) if useful for a regression test.
Activity
bosschen0429 commentedon Aug 27, 2026
I reproduced the same false-freshness outcome on Windows with
codebase-memory-mcp 0.10.8, but without requiring a nested repository.
Observed sequence:
a later feature branch.
reindex.
index_status.git.head_shareflected the live checkout, whilesearch_graphcontinued returning symbols absent from that checkout.The project database last-write timestamp also predated the current checkout
state, so this was not only a stale in-process reader: the persisted graph
itself had not been rebuilt.
Source inspection appears to explain the lifecycle gap:
index_statusresolves Git context live fromroot_path.last_headis process-local.init_baseline()adopts the current HEAD without comparing it against apersisted indexed HEAD.
distinguished from a fresh graph after re-registration.
This means the graph can remain stale indefinitely after the new HEAD is
adopted as the baseline; waiting for another poll is insufficient.
Persisting
indexed_head_shaat successful publication, comparing it duringwatcher initialization, and exposing indexed-vs-live Git state (or
stale)would cover both this lifecycle case and the false-freshness surface described
here.
zhangbiyao1129 commentedon Sep 19, 2026
Reproducing from macOS (Apple Silicon) on 0.11.0 with two additions that may help
shape the fix: a per-file freshness oracle already exists and works, and an
mtime-only change latches
metadata_changedirrecoverably.1. False freshness reproduces without a branch switch or a nested repo
A project simply left un-reindexed while commits accumulate behaves identically.
Users-kanyun-it-services-its-ms-attendance(~/it-services/its-ms-attendance):indexed_at2026-08-06T08:01:34Z2026-09-18 17:13:35 +0800index_status.git.head_shaHEADexactlystatusreadyparse_partial/skippedBoth surfaced fields look healthy; the graph is six weeks behind. Verified by content,
not inference — of 12 functions added in that window, all 12 are absent, and the
file carrying most of them has zero nodes:
No snapshot exists to compare against:
projectsholds onlyname/indexed_at/root_path, andstore_metais empty. This is consistent with @bosschen0429'slifecycle analysis —
indexed_head_shais genuinely absent from the store.2.
check_index_coveragealready carries the per-file staleness signalThis is the
stalesignal point 3 asks for, per path, and it works. Clean A/B on acurrent-format project, nothing touched but mtime:
So the oracle exists and is reachable today. Surfacing it at project level would
reuse machinery that is already there rather than adding a second notion of staleness.
3. An mtime-only change latches
metadata_changedpermanentlymetadata_changedcannot be cleared by re-indexing when the content is unchanged —neither the default mode nor
--mode fullrefreshes the storedmtime_ns:A content change does clear it (
recanddiskconverge,metadata_matchreturns), because the incremental path compares hashes, correctly concludes the file
changed, and then writes fresh metadata. An mtime-only change is judged "unchanged"
and skipped entirely — including the metadata write.
Consequence: any tool that rewrites mtimes without changing content pins the project
in
metadata_changedindefinitely. Agit checkoutin CI, agit stash/restore, aneditor that re-saves identical bytes, or a tar/rsync restore all do this. I confirmed
recovery requires a real content edit; re-indexing alone never does it.
4.
get_code_snippetserves shifted source under this state (observed on 0.11.0)I could not test PR #1788's
source_driftpath directly —stringsfinds nosource_driftsymbol in the 0.11.0 binary, so that fix is not in this release. On0.11.0 the behaviour is the pre-#1788 one, and it is worth restating because it is
silent:
Inserting two lines above a function (indexed
start_line: 46; the function is now atline 48):
The reported range is unchanged, the slice is shifted, and the first line is the tail
of the preceding JSDoc block — presented as the function source. No error, no flag.
Suggestions
Points 1–4 in the issue are the right shape. Three additions:
stalewhen any recordedfile's metadata no longer matches, using the same reader
check_index_coveragealready uses, instead of a new signal that could disagree with it.
mtime_nson the incremental path even when the content hash matches.This removes the one-way door in §3 with a one-line metadata write.
§3 is independent of git entirely. A fix framed only as "watcher should track
nested HEADs" would leave both in place.
Happy to retest against a candidate build.
DeusData commentedon Sep 19, 2026
Thank you for the v0.11.0 evidence. The watcher still gates work on project registration, so the earlier dirty-to-clean fix does not settle this nested-project report. The stale generation, persistent metadata mismatch and shifted snippet need separate checks. This remains open; the earlier watcher fix is not being treated as proof that these cases are resolved.
DeusData commentedon Sep 26, 2026
Thank you, @xnng, @bosschen0429 and @zhangbiyao1129! Between you, you separated the lifecycle cause from the reporting surface. There are now two linked tracks:
index_statuswhether a project is actually being watched.Neither of them makes nested repositories watched: the watcher is still registered only for the session project. That part remains open here.
njuptlzf commentedon Sep 29, 2026
Confirming this on 0.11.0 / Windows, in a host that serves many session workspaces (the MCP client's workspace here is a non-git parent directory containing several independent git checkouts), and adding the mechanism plus a concrete ask.
Mechanism (measured from
~/.cache/codebase-memory-mcp/logs/cbm-daemon.log): the watch is registered by the client that creates the session-managed daemon, keyed to that client's cwd; clients that merely join an already-active daemon register nothing.daemon.start580 /daemon.lifetime_end579 (reason=runtime_exited) — the daemon, and its watch, live only as long as the creating client.daemon.startis followed by exactly onewatcher.watchnaming the creator's cwd:…-work(the session workspace) 598 times,…-dsh-codebase-memory118 times, and the nested repo I was actually editing (…-ruankao-ai) 0 times — despite manycli index_repository --repo-path …/ruankao-airuns (a transient CLI is not the creator, so it acquires no watch).cwd = <nested repo>while a daemon was already held by the host's client:watcher.watch/baseline/changed/unwatch= 0 / 0 / 0 / 0 for both. Joining does not add a watch, even for a fully interactive client.watcher.baseline … strategy=noneandchanged=0throughout — no polling at all, matching the source comment.Quantifying the silent-freshness half (same repo): after the index had drifted, I sampled 59 symbols and 20 (34%) returned a line range that does not contain the named symbol — e.g.
adminGateclaimed27-34while the file has it at47;registerRoutesclaimed36-382vs actual56.index_statusreportsreadythroughout, so an agent reads a neighbour function under the correct name, exactly as you describe.Ask: an explicit registration surface — e.g.
daemon watch <path>, an MCPwatch_projecttool, or a config list of watch roots — so a host serving many workspaces can register the projects it cares about. Worth noting the per-project-MCP-server workaround does not help either: with one shared daemon, the extra clients join it and register nothing.Cross-referencing #1660 (session-scoped watch); the data above refines its wording: it is not "an interactive client connects", but specifically the daemon-creating client that registers.