codebase-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,169 @@
1
+ """
2
+ File system watcher: monitors the project directory and triggers incremental
3
+ re-index when source files change.
4
+
5
+ Uses watchdog for cross-platform file watching (Windows ReadDirectoryChangesW,
6
+ Linux inotify, macOS FSEvents). Debounces rapid changes with a 2-second window.
7
+
8
+ Optional dependency — gracefully fails with a clear message if watchdog is not installed.
9
+ """
10
+
11
+ from __future__ import annotations
12
+ import logging
13
+ import queue
14
+ import threading
15
+ import time
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from .config import Config
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ # Global watcher instance (one per process)
24
+ _active_watcher: "CodebaseWatcher | None" = None
25
+
26
+
27
+ class WatcherNotAvailable(RuntimeError):
28
+ pass
29
+
30
+
31
+ class CodebaseWatcher:
32
+ """
33
+ Background file watcher with debounced incremental re-index.
34
+
35
+ Architecture:
36
+ Main thread: FastMCP / CLI (calls start/stop)
37
+ Observer thread: watchdog OS-level event loop
38
+ Debounce thread: waits 2s after last event, then calls run_index
39
+ """
40
+
41
+ def __init__(self, project_root: str, db_path: str, config: "Config"):
42
+ try:
43
+ from watchdog.observers import Observer # type: ignore
44
+ except ImportError:
45
+ raise WatcherNotAvailable(
46
+ "watchdog is not installed. Install it with: pip install 'codebase-mcp[watch]'"
47
+ )
48
+
49
+ self._root = project_root
50
+ self._db_path = db_path
51
+ self._config = config
52
+ self._queue: queue.Queue[str] = queue.Queue()
53
+ self._observer = Observer()
54
+ self._running = False
55
+ self._debounce_thread: threading.Thread | None = None
56
+ self._stats = {"events": 0, "reindexes": 0, "last_reindex": None}
57
+
58
+ def start(self) -> None:
59
+ from watchdog.events import FileSystemEventHandler, FileSystemEvent # type: ignore
60
+
61
+ config = self._config
62
+
63
+ class _ChangeHandler(FileSystemEventHandler):
64
+ def on_any_event(self, event: FileSystemEvent):
65
+ if event.is_directory:
66
+ return
67
+ src = getattr(event, "src_path", "")
68
+ if not src:
69
+ return
70
+ # Only queue files we actually index
71
+ import os
72
+ ext = os.path.splitext(src)[1]
73
+ basename = os.path.basename(src)
74
+ lang = config.language_for(ext) or config.language_for_basename(basename)
75
+ if lang:
76
+ self.queue.put(src)
77
+
78
+ handler = _ChangeHandler()
79
+ handler.queue = self._queue
80
+
81
+ if not __import__("os").path.isdir(self._root):
82
+ log.warning("Watcher: project root does not exist: %s", self._root)
83
+ return
84
+
85
+ self._observer.schedule(handler, self._root, recursive=True)
86
+ self._observer.start()
87
+
88
+ self._running = True
89
+ self._debounce_thread = threading.Thread(
90
+ target=self._debounce_loop, name="codebase-mcp-watcher", daemon=True
91
+ )
92
+ self._debounce_thread.start()
93
+ log.info("File watcher started for %s", self._root)
94
+
95
+ def stop(self) -> None:
96
+ self._running = False
97
+ self._observer.stop()
98
+ self._observer.join(timeout=5)
99
+ log.info("File watcher stopped")
100
+
101
+ @property
102
+ def is_running(self) -> bool:
103
+ return self._running
104
+
105
+ @property
106
+ def stats(self) -> dict:
107
+ return dict(self._stats)
108
+
109
+ def _debounce_loop(self) -> None:
110
+ from .indexer import run_index
111
+
112
+ while self._running:
113
+ # Block until the first change arrives (poll every 5s to check running)
114
+ try:
115
+ self._queue.get(timeout=5.0)
116
+ self._stats["events"] += 1
117
+ except queue.Empty:
118
+ continue
119
+
120
+ # Drain for 2 seconds to batch rapid changes (e.g., git checkout)
121
+ deadline = time.monotonic() + 2.0
122
+ while time.monotonic() < deadline:
123
+ try:
124
+ self._queue.get_nowait()
125
+ self._stats["events"] += 1
126
+ except queue.Empty:
127
+ time.sleep(0.1)
128
+
129
+ # Drain any remaining
130
+ while not self._queue.empty():
131
+ try:
132
+ self._queue.get_nowait()
133
+ except queue.Empty:
134
+ break
135
+
136
+ # Run incremental index
137
+ log.debug("Watcher: triggering incremental re-index")
138
+ try:
139
+ result = run_index(self._db_path, self._root, self._config, full_reindex=False)
140
+ self._stats["reindexes"] += 1
141
+ self._stats["last_reindex"] = __import__("datetime").datetime.utcnow().isoformat() + "Z"
142
+ log.info(
143
+ "Auto-reindex: %d files changed, %d symbols updated (%.0fms)",
144
+ result.files_changed, result.symbols_added, result.duration_ms
145
+ )
146
+ except Exception as e:
147
+ log.error("Auto-reindex failed: %s", e)
148
+
149
+
150
+ # ─── Global watcher management ───────────────────────────────────────────────
151
+
152
+ def start_watcher(project_root: str, db_path: str, config: "Config") -> CodebaseWatcher:
153
+ global _active_watcher
154
+ if _active_watcher and _active_watcher.is_running:
155
+ _active_watcher.stop()
156
+ _active_watcher = CodebaseWatcher(project_root, db_path, config)
157
+ _active_watcher.start()
158
+ return _active_watcher
159
+
160
+
161
+ def stop_watcher() -> None:
162
+ global _active_watcher
163
+ if _active_watcher:
164
+ _active_watcher.stop()
165
+ _active_watcher = None
166
+
167
+
168
+ def get_watcher() -> "CodebaseWatcher | None":
169
+ return _active_watcher