mcp-vector-search 0.15.7__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.

Potentially problematic release.


This version of mcp-vector-search might be problematic. Click here for more details.

Files changed (86) hide show
  1. mcp_vector_search/__init__.py +10 -0
  2. mcp_vector_search/cli/__init__.py +1 -0
  3. mcp_vector_search/cli/commands/__init__.py +1 -0
  4. mcp_vector_search/cli/commands/auto_index.py +397 -0
  5. mcp_vector_search/cli/commands/chat.py +534 -0
  6. mcp_vector_search/cli/commands/config.py +393 -0
  7. mcp_vector_search/cli/commands/demo.py +358 -0
  8. mcp_vector_search/cli/commands/index.py +762 -0
  9. mcp_vector_search/cli/commands/init.py +658 -0
  10. mcp_vector_search/cli/commands/install.py +869 -0
  11. mcp_vector_search/cli/commands/install_old.py +700 -0
  12. mcp_vector_search/cli/commands/mcp.py +1254 -0
  13. mcp_vector_search/cli/commands/reset.py +393 -0
  14. mcp_vector_search/cli/commands/search.py +796 -0
  15. mcp_vector_search/cli/commands/setup.py +1133 -0
  16. mcp_vector_search/cli/commands/status.py +584 -0
  17. mcp_vector_search/cli/commands/uninstall.py +404 -0
  18. mcp_vector_search/cli/commands/visualize/__init__.py +39 -0
  19. mcp_vector_search/cli/commands/visualize/cli.py +265 -0
  20. mcp_vector_search/cli/commands/visualize/exporters/__init__.py +12 -0
  21. mcp_vector_search/cli/commands/visualize/exporters/html_exporter.py +33 -0
  22. mcp_vector_search/cli/commands/visualize/exporters/json_exporter.py +29 -0
  23. mcp_vector_search/cli/commands/visualize/graph_builder.py +709 -0
  24. mcp_vector_search/cli/commands/visualize/layout_engine.py +469 -0
  25. mcp_vector_search/cli/commands/visualize/server.py +201 -0
  26. mcp_vector_search/cli/commands/visualize/state_manager.py +428 -0
  27. mcp_vector_search/cli/commands/visualize/templates/__init__.py +16 -0
  28. mcp_vector_search/cli/commands/visualize/templates/base.py +218 -0
  29. mcp_vector_search/cli/commands/visualize/templates/scripts.py +3670 -0
  30. mcp_vector_search/cli/commands/visualize/templates/styles.py +779 -0
  31. mcp_vector_search/cli/commands/visualize.py.original +2536 -0
  32. mcp_vector_search/cli/commands/watch.py +287 -0
  33. mcp_vector_search/cli/didyoumean.py +520 -0
  34. mcp_vector_search/cli/export.py +320 -0
  35. mcp_vector_search/cli/history.py +295 -0
  36. mcp_vector_search/cli/interactive.py +342 -0
  37. mcp_vector_search/cli/main.py +484 -0
  38. mcp_vector_search/cli/output.py +414 -0
  39. mcp_vector_search/cli/suggestions.py +375 -0
  40. mcp_vector_search/config/__init__.py +1 -0
  41. mcp_vector_search/config/constants.py +24 -0
  42. mcp_vector_search/config/defaults.py +200 -0
  43. mcp_vector_search/config/settings.py +146 -0
  44. mcp_vector_search/core/__init__.py +1 -0
  45. mcp_vector_search/core/auto_indexer.py +298 -0
  46. mcp_vector_search/core/config_utils.py +394 -0
  47. mcp_vector_search/core/connection_pool.py +360 -0
  48. mcp_vector_search/core/database.py +1237 -0
  49. mcp_vector_search/core/directory_index.py +318 -0
  50. mcp_vector_search/core/embeddings.py +294 -0
  51. mcp_vector_search/core/exceptions.py +89 -0
  52. mcp_vector_search/core/factory.py +318 -0
  53. mcp_vector_search/core/git_hooks.py +345 -0
  54. mcp_vector_search/core/indexer.py +1002 -0
  55. mcp_vector_search/core/llm_client.py +453 -0
  56. mcp_vector_search/core/models.py +294 -0
  57. mcp_vector_search/core/project.py +350 -0
  58. mcp_vector_search/core/scheduler.py +330 -0
  59. mcp_vector_search/core/search.py +952 -0
  60. mcp_vector_search/core/watcher.py +322 -0
  61. mcp_vector_search/mcp/__init__.py +5 -0
  62. mcp_vector_search/mcp/__main__.py +25 -0
  63. mcp_vector_search/mcp/server.py +752 -0
  64. mcp_vector_search/parsers/__init__.py +8 -0
  65. mcp_vector_search/parsers/base.py +296 -0
  66. mcp_vector_search/parsers/dart.py +605 -0
  67. mcp_vector_search/parsers/html.py +413 -0
  68. mcp_vector_search/parsers/javascript.py +643 -0
  69. mcp_vector_search/parsers/php.py +694 -0
  70. mcp_vector_search/parsers/python.py +502 -0
  71. mcp_vector_search/parsers/registry.py +223 -0
  72. mcp_vector_search/parsers/ruby.py +678 -0
  73. mcp_vector_search/parsers/text.py +186 -0
  74. mcp_vector_search/parsers/utils.py +265 -0
  75. mcp_vector_search/py.typed +1 -0
  76. mcp_vector_search/utils/__init__.py +42 -0
  77. mcp_vector_search/utils/gitignore.py +250 -0
  78. mcp_vector_search/utils/gitignore_updater.py +212 -0
  79. mcp_vector_search/utils/monorepo.py +339 -0
  80. mcp_vector_search/utils/timing.py +338 -0
  81. mcp_vector_search/utils/version.py +47 -0
  82. mcp_vector_search-0.15.7.dist-info/METADATA +884 -0
  83. mcp_vector_search-0.15.7.dist-info/RECORD +86 -0
  84. mcp_vector_search-0.15.7.dist-info/WHEEL +4 -0
  85. mcp_vector_search-0.15.7.dist-info/entry_points.txt +3 -0
  86. mcp_vector_search-0.15.7.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,318 @@
1
+ """Component factory for creating commonly used objects."""
2
+
3
+ import functools
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, TypeVar
8
+
9
+ import typer
10
+ from loguru import logger
11
+
12
+ from ..cli.output import print_error
13
+ from ..config.settings import ProjectConfig
14
+ from .auto_indexer import AutoIndexer
15
+ from .database import ChromaVectorDatabase, PooledChromaVectorDatabase, VectorDatabase
16
+ from .embeddings import CodeBERTEmbeddingFunction, create_embedding_function
17
+ from .indexer import SemanticIndexer
18
+ from .project import ProjectManager
19
+ from .search import SemanticSearchEngine
20
+
21
+ F = TypeVar("F", bound=Callable[..., Any])
22
+
23
+
24
+ @dataclass
25
+ class ComponentBundle:
26
+ """Bundle of commonly used components."""
27
+
28
+ project_manager: ProjectManager
29
+ config: ProjectConfig
30
+ database: VectorDatabase
31
+ indexer: SemanticIndexer
32
+ embedding_function: CodeBERTEmbeddingFunction
33
+ search_engine: SemanticSearchEngine | None = None
34
+ auto_indexer: AutoIndexer | None = None
35
+
36
+
37
+ class ComponentFactory:
38
+ """Factory for creating commonly used components."""
39
+
40
+ @staticmethod
41
+ def create_project_manager(project_root: Path) -> ProjectManager:
42
+ """Create a project manager."""
43
+ return ProjectManager(project_root)
44
+
45
+ @staticmethod
46
+ def load_config(project_root: Path) -> tuple[ProjectManager, ProjectConfig]:
47
+ """Load project configuration."""
48
+ project_manager = ComponentFactory.create_project_manager(project_root)
49
+ config = project_manager.load_config()
50
+ return project_manager, config
51
+
52
+ @staticmethod
53
+ def create_embedding_function(
54
+ model_name: str,
55
+ ) -> tuple[CodeBERTEmbeddingFunction, Any]:
56
+ """Create embedding function."""
57
+ return create_embedding_function(model_name)
58
+
59
+ @staticmethod
60
+ def create_database(
61
+ config: ProjectConfig,
62
+ embedding_function: CodeBERTEmbeddingFunction,
63
+ use_pooling: bool = True, # Enable pooling by default for 13.6% performance boost
64
+ **pool_kwargs,
65
+ ) -> VectorDatabase:
66
+ """Create vector database."""
67
+ if use_pooling:
68
+ # Set default pool parameters if not provided
69
+ pool_defaults = {
70
+ "max_connections": 10,
71
+ "min_connections": 2,
72
+ "max_idle_time": 300.0,
73
+ }
74
+ pool_defaults.update(pool_kwargs)
75
+
76
+ return PooledChromaVectorDatabase(
77
+ persist_directory=config.index_path,
78
+ embedding_function=embedding_function,
79
+ collection_name="code_search",
80
+ **pool_defaults,
81
+ )
82
+ else:
83
+ return ChromaVectorDatabase(
84
+ persist_directory=config.index_path,
85
+ embedding_function=embedding_function,
86
+ collection_name="code_search",
87
+ )
88
+
89
+ @staticmethod
90
+ def create_indexer(
91
+ database: VectorDatabase, project_root: Path, config: ProjectConfig
92
+ ) -> SemanticIndexer:
93
+ """Create semantic indexer."""
94
+ return SemanticIndexer(
95
+ database=database,
96
+ project_root=project_root,
97
+ config=config,
98
+ )
99
+
100
+ @staticmethod
101
+ def create_search_engine(
102
+ database: VectorDatabase,
103
+ project_root: Path,
104
+ similarity_threshold: float = 0.7,
105
+ auto_indexer: AutoIndexer | None = None,
106
+ enable_auto_reindex: bool = True,
107
+ ) -> SemanticSearchEngine:
108
+ """Create semantic search engine."""
109
+ return SemanticSearchEngine(
110
+ database=database,
111
+ project_root=project_root,
112
+ similarity_threshold=similarity_threshold,
113
+ auto_indexer=auto_indexer,
114
+ enable_auto_reindex=enable_auto_reindex,
115
+ )
116
+
117
+ @staticmethod
118
+ def create_auto_indexer(
119
+ indexer: SemanticIndexer,
120
+ database: VectorDatabase,
121
+ auto_reindex_threshold: int = 5,
122
+ staleness_threshold: float = 300.0,
123
+ ) -> AutoIndexer:
124
+ """Create auto-indexer."""
125
+ return AutoIndexer(
126
+ indexer=indexer,
127
+ database=database,
128
+ auto_reindex_threshold=auto_reindex_threshold,
129
+ staleness_threshold=staleness_threshold,
130
+ )
131
+
132
+ @staticmethod
133
+ async def create_standard_components(
134
+ project_root: Path,
135
+ use_pooling: bool = True, # Enable pooling by default for performance
136
+ include_search_engine: bool = False,
137
+ include_auto_indexer: bool = False,
138
+ similarity_threshold: float = 0.7,
139
+ auto_reindex_threshold: int = 5,
140
+ **pool_kwargs,
141
+ ) -> ComponentBundle:
142
+ """Create standard set of components for CLI commands.
143
+
144
+ Args:
145
+ project_root: Project root directory
146
+ use_pooling: Whether to use connection pooling
147
+ include_search_engine: Whether to create search engine
148
+ include_auto_indexer: Whether to create auto-indexer
149
+ similarity_threshold: Default similarity threshold for search
150
+ auto_reindex_threshold: Max files to auto-reindex
151
+ **pool_kwargs: Additional arguments for connection pool
152
+
153
+ Returns:
154
+ ComponentBundle with requested components
155
+ """
156
+ # Load configuration
157
+ project_manager, config = ComponentFactory.load_config(project_root)
158
+
159
+ # Create embedding function
160
+ embedding_function, _ = ComponentFactory.create_embedding_function(
161
+ config.embedding_model
162
+ )
163
+
164
+ # Create database
165
+ database = ComponentFactory.create_database(
166
+ config=config,
167
+ embedding_function=embedding_function,
168
+ use_pooling=use_pooling,
169
+ **pool_kwargs,
170
+ )
171
+
172
+ # Create indexer
173
+ indexer = ComponentFactory.create_indexer(
174
+ database=database,
175
+ project_root=project_root,
176
+ config=config,
177
+ )
178
+
179
+ # Create optional components
180
+ search_engine = None
181
+ auto_indexer = None
182
+
183
+ if include_auto_indexer:
184
+ auto_indexer = ComponentFactory.create_auto_indexer(
185
+ indexer=indexer,
186
+ database=database,
187
+ auto_reindex_threshold=auto_reindex_threshold,
188
+ )
189
+
190
+ if include_search_engine:
191
+ search_engine = ComponentFactory.create_search_engine(
192
+ database=database,
193
+ project_root=project_root,
194
+ similarity_threshold=similarity_threshold,
195
+ auto_indexer=auto_indexer,
196
+ enable_auto_reindex=include_auto_indexer,
197
+ )
198
+
199
+ return ComponentBundle(
200
+ project_manager=project_manager,
201
+ config=config,
202
+ database=database,
203
+ indexer=indexer,
204
+ embedding_function=embedding_function,
205
+ search_engine=search_engine,
206
+ auto_indexer=auto_indexer,
207
+ )
208
+
209
+
210
+ class DatabaseContext:
211
+ """Context manager for database lifecycle management."""
212
+
213
+ def __init__(self, database: VectorDatabase):
214
+ """Initialize database context.
215
+
216
+ Args:
217
+ database: Vector database instance
218
+ """
219
+ self.database = database
220
+
221
+ async def __aenter__(self) -> VectorDatabase:
222
+ """Enter context and initialize database."""
223
+ await self.database.initialize()
224
+ return self.database
225
+
226
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
227
+ """Exit context and close database."""
228
+ await self.database.close()
229
+
230
+
231
+ def handle_cli_errors(operation_name: str) -> Callable[[F], F]:
232
+ """Decorator for consistent CLI error handling.
233
+
234
+ Args:
235
+ operation_name: Name of the operation for error messages
236
+
237
+ Returns:
238
+ Decorator function
239
+ """
240
+
241
+ def decorator(func: F) -> F:
242
+ @functools.wraps(func)
243
+ async def async_wrapper(*args, **kwargs):
244
+ try:
245
+ return await func(*args, **kwargs)
246
+ except Exception as e:
247
+ logger.error(f"{operation_name} failed: {e}")
248
+ print_error(f"{operation_name} failed: {e}")
249
+ raise typer.Exit(1)
250
+
251
+ @functools.wraps(func)
252
+ def sync_wrapper(*args, **kwargs):
253
+ try:
254
+ return func(*args, **kwargs)
255
+ except Exception as e:
256
+ logger.error(f"{operation_name} failed: {e}")
257
+ print_error(f"{operation_name} failed: {e}")
258
+ raise typer.Exit(1)
259
+
260
+ # Return appropriate wrapper based on function type
261
+ if hasattr(func, "__code__") and "await" in func.__code__.co_names:
262
+ return async_wrapper
263
+ else:
264
+ return sync_wrapper
265
+
266
+ return decorator
267
+
268
+
269
+ class ConfigurationService:
270
+ """Centralized configuration management service."""
271
+
272
+ def __init__(self, project_root: Path):
273
+ """Initialize configuration service.
274
+
275
+ Args:
276
+ project_root: Project root directory
277
+ """
278
+ self.project_root = project_root
279
+ self._project_manager: ProjectManager | None = None
280
+ self._config: ProjectConfig | None = None
281
+
282
+ @property
283
+ def project_manager(self) -> ProjectManager:
284
+ """Get project manager (lazy loaded)."""
285
+ if self._project_manager is None:
286
+ self._project_manager = ProjectManager(self.project_root)
287
+ return self._project_manager
288
+
289
+ @property
290
+ def config(self) -> ProjectConfig:
291
+ """Get project configuration (lazy loaded)."""
292
+ if self._config is None:
293
+ self._config = self.project_manager.load_config()
294
+ return self._config
295
+
296
+ def ensure_initialized(self) -> bool:
297
+ """Ensure project is initialized.
298
+
299
+ Returns:
300
+ True if project is initialized, False otherwise
301
+ """
302
+ if not self.project_manager.is_initialized():
303
+ print_error("Project not initialized. Run 'mcp-vector-search init' first.")
304
+ return False
305
+ return True
306
+
307
+ def reload_config(self) -> None:
308
+ """Reload configuration from disk."""
309
+ self._config = None
310
+
311
+ def save_config(self, config: ProjectConfig) -> None:
312
+ """Save configuration to disk.
313
+
314
+ Args:
315
+ config: Configuration to save
316
+ """
317
+ self.project_manager.save_config(config)
318
+ self._config = config
@@ -0,0 +1,345 @@
1
+ """Git hooks for automatic reindexing."""
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from loguru import logger
9
+
10
+
11
+ class GitHookManager:
12
+ """Manages Git hooks for automatic reindexing."""
13
+
14
+ def __init__(self, project_root: Path):
15
+ """Initialize Git hook manager.
16
+
17
+ Args:
18
+ project_root: Project root directory
19
+ """
20
+ self.project_root = project_root
21
+ self.git_dir = project_root / ".git"
22
+ self.hooks_dir = self.git_dir / "hooks"
23
+
24
+ def is_git_repo(self) -> bool:
25
+ """Check if project is a Git repository."""
26
+ return self.git_dir.exists() and self.git_dir.is_dir()
27
+
28
+ def install_hooks(self, hook_types: list[str] | None = None) -> bool:
29
+ """Install Git hooks for automatic reindexing.
30
+
31
+ Args:
32
+ hook_types: List of hook types to install (default: ['post-commit', 'post-merge'])
33
+
34
+ Returns:
35
+ True if hooks were installed successfully
36
+ """
37
+ if not self.is_git_repo():
38
+ logger.error("Not a Git repository")
39
+ return False
40
+
41
+ if hook_types is None:
42
+ hook_types = ["post-commit", "post-merge", "post-checkout"]
43
+
44
+ success = True
45
+ for hook_type in hook_types:
46
+ if not self._install_hook(hook_type):
47
+ success = False
48
+
49
+ return success
50
+
51
+ def uninstall_hooks(self, hook_types: list[str] | None = None) -> bool:
52
+ """Uninstall Git hooks.
53
+
54
+ Args:
55
+ hook_types: List of hook types to uninstall (default: all MCP hooks)
56
+
57
+ Returns:
58
+ True if hooks were uninstalled successfully
59
+ """
60
+ if hook_types is None:
61
+ hook_types = ["post-commit", "post-merge", "post-checkout"]
62
+
63
+ success = True
64
+ for hook_type in hook_types:
65
+ if not self._uninstall_hook(hook_type):
66
+ success = False
67
+
68
+ return success
69
+
70
+ def _install_hook(self, hook_type: str) -> bool:
71
+ """Install a specific Git hook."""
72
+ try:
73
+ hook_file = self.hooks_dir / hook_type
74
+
75
+ # Create hooks directory if it doesn't exist
76
+ self.hooks_dir.mkdir(exist_ok=True)
77
+
78
+ # Generate hook script
79
+ hook_script = self._generate_hook_script(hook_type)
80
+
81
+ if hook_file.exists():
82
+ # If hook already exists, try to integrate with it
83
+ return self._integrate_with_existing_hook(hook_file, hook_script)
84
+ else:
85
+ # Create new hook
86
+ hook_file.write_text(hook_script)
87
+ hook_file.chmod(0o755) # Make executable
88
+ logger.info(f"Installed {hook_type} hook")
89
+ return True
90
+
91
+ except Exception as e:
92
+ logger.error(f"Failed to install {hook_type} hook: {e}")
93
+ return False
94
+
95
+ def _uninstall_hook(self, hook_type: str) -> bool:
96
+ """Uninstall a specific Git hook."""
97
+ try:
98
+ hook_file = self.hooks_dir / hook_type
99
+
100
+ if not hook_file.exists():
101
+ return True # Already uninstalled
102
+
103
+ content = hook_file.read_text()
104
+
105
+ # Check if this is our hook or integrated
106
+ if "# MCP Vector Search Hook" in content:
107
+ if (
108
+ content.strip().startswith("#!/bin/bash")
109
+ and "# MCP Vector Search Hook" in content
110
+ ):
111
+ # This is our hook, remove it
112
+ hook_file.unlink()
113
+ logger.info(f"Uninstalled {hook_type} hook")
114
+ else:
115
+ # This is integrated, remove our part
116
+ return self._remove_from_existing_hook(hook_file)
117
+
118
+ return True
119
+
120
+ except Exception as e:
121
+ logger.error(f"Failed to uninstall {hook_type} hook: {e}")
122
+ return False
123
+
124
+ def _generate_hook_script(self, hook_type: str) -> str:
125
+ """Generate Git hook script."""
126
+ python_path = sys.executable
127
+ project_root = str(self.project_root)
128
+
129
+ script = f"""#!/bin/bash
130
+ # MCP Vector Search Hook - {hook_type}
131
+ # Auto-generated - do not edit manually
132
+
133
+ # Check if mcp-vector-search is available
134
+ if ! command -v mcp-vector-search &> /dev/null; then
135
+ # Try using Python directly
136
+ if [ -f "{python_path}" ]; then
137
+ PYTHON_CMD="{python_path}"
138
+ else
139
+ PYTHON_CMD="python3"
140
+ fi
141
+
142
+ # Try to run via Python module
143
+ if $PYTHON_CMD -m mcp_vector_search --help &> /dev/null; then
144
+ MCP_CMD="$PYTHON_CMD -m mcp_vector_search"
145
+ else
146
+ # Silently exit if not available
147
+ exit 0
148
+ fi
149
+ else
150
+ MCP_CMD="mcp-vector-search"
151
+ fi
152
+
153
+ # Change to project directory
154
+ cd "{project_root}" || exit 0
155
+
156
+ # Run auto-indexing check
157
+ $MCP_CMD auto-index check --auto-reindex --max-files 10 &> /dev/null || true
158
+
159
+ # Exit successfully (don't block Git operations)
160
+ exit 0
161
+ """
162
+ return script
163
+
164
+ def _integrate_with_existing_hook(self, hook_file: Path, our_script: str) -> bool:
165
+ """Integrate our hook with an existing hook."""
166
+ try:
167
+ existing_content = hook_file.read_text()
168
+
169
+ # Check if our hook is already integrated
170
+ if "# MCP Vector Search Hook" in existing_content:
171
+ logger.info(f"Hook {hook_file.name} already integrated")
172
+ return True
173
+
174
+ # Add our hook to the end
175
+ integrated_content = existing_content.rstrip() + "\n\n" + our_script
176
+
177
+ # Backup original
178
+ backup_file = hook_file.with_suffix(hook_file.suffix + ".backup")
179
+ backup_file.write_text(existing_content)
180
+
181
+ # Write integrated version
182
+ hook_file.write_text(integrated_content)
183
+
184
+ logger.info(f"Integrated with existing {hook_file.name} hook")
185
+ return True
186
+
187
+ except Exception as e:
188
+ logger.error(f"Failed to integrate with existing hook: {e}")
189
+ return False
190
+
191
+ def _remove_from_existing_hook(self, hook_file: Path) -> bool:
192
+ """Remove our hook from an existing integrated hook."""
193
+ try:
194
+ content = hook_file.read_text()
195
+
196
+ # Find and remove our section
197
+ lines = content.split("\n")
198
+ new_lines = []
199
+ skip_section = False
200
+
201
+ for line in lines:
202
+ if "# MCP Vector Search Hook" in line:
203
+ skip_section = True
204
+ continue
205
+ elif skip_section and line.strip() == "":
206
+ # End of our section
207
+ skip_section = False
208
+ continue
209
+ elif not skip_section:
210
+ new_lines.append(line)
211
+
212
+ # Write back the cleaned content
213
+ hook_file.write_text("\n".join(new_lines))
214
+
215
+ logger.info(f"Removed MCP hook from {hook_file.name}")
216
+ return True
217
+
218
+ except Exception as e:
219
+ logger.error(f"Failed to remove from existing hook: {e}")
220
+ return False
221
+
222
+ def get_hook_status(self) -> dict:
223
+ """Get status of Git hooks."""
224
+ if not self.is_git_repo():
225
+ return {"is_git_repo": False}
226
+
227
+ hook_types = ["post-commit", "post-merge", "post-checkout"]
228
+ status = {
229
+ "is_git_repo": True,
230
+ "hooks_dir_exists": self.hooks_dir.exists(),
231
+ "hooks": {},
232
+ }
233
+
234
+ for hook_type in hook_types:
235
+ hook_file = self.hooks_dir / hook_type
236
+ hook_status = {
237
+ "exists": hook_file.exists(),
238
+ "executable": False,
239
+ "has_mcp_hook": False,
240
+ "is_mcp_only": False,
241
+ }
242
+
243
+ if hook_file.exists():
244
+ try:
245
+ hook_status["executable"] = os.access(hook_file, os.X_OK)
246
+ content = hook_file.read_text()
247
+ hook_status["has_mcp_hook"] = "# MCP Vector Search Hook" in content
248
+ hook_status["is_mcp_only"] = (
249
+ hook_status["has_mcp_hook"]
250
+ and content.strip().startswith("#!/bin/bash")
251
+ and content.count("# MCP Vector Search Hook") == 1
252
+ )
253
+ except Exception:
254
+ pass
255
+
256
+ status["hooks"][hook_type] = hook_status
257
+
258
+ return status
259
+
260
+
261
+ class GitChangeDetector:
262
+ """Detects changed files from Git operations."""
263
+
264
+ @staticmethod
265
+ def get_changed_files_since_commit(
266
+ commit_hash: str, project_root: Path
267
+ ) -> set[Path]:
268
+ """Get files changed since a specific commit.
269
+
270
+ Args:
271
+ commit_hash: Git commit hash
272
+ project_root: Project root directory
273
+
274
+ Returns:
275
+ Set of changed file paths
276
+ """
277
+ try:
278
+ result = subprocess.run( # nosec B607
279
+ ["git", "diff", "--name-only", commit_hash, "HEAD"],
280
+ cwd=project_root,
281
+ capture_output=True,
282
+ text=True,
283
+ check=True,
284
+ )
285
+
286
+ changed_files = set()
287
+ for line in result.stdout.strip().split("\n"):
288
+ if line:
289
+ file_path = project_root / line
290
+ if file_path.exists():
291
+ changed_files.add(file_path)
292
+
293
+ return changed_files
294
+
295
+ except subprocess.CalledProcessError:
296
+ return set()
297
+
298
+ @staticmethod
299
+ def get_changed_files_in_last_commit(project_root: Path) -> set[Path]:
300
+ """Get files changed in the last commit.
301
+
302
+ Args:
303
+ project_root: Project root directory
304
+
305
+ Returns:
306
+ Set of changed file paths
307
+ """
308
+ try:
309
+ result = subprocess.run( # nosec B607
310
+ ["git", "diff", "--name-only", "HEAD~1", "HEAD"],
311
+ cwd=project_root,
312
+ capture_output=True,
313
+ text=True,
314
+ check=True,
315
+ )
316
+
317
+ changed_files = set()
318
+ for line in result.stdout.strip().split("\n"):
319
+ if line:
320
+ file_path = project_root / line
321
+ if file_path.exists():
322
+ changed_files.add(file_path)
323
+
324
+ return changed_files
325
+
326
+ except subprocess.CalledProcessError:
327
+ return set()
328
+
329
+ @staticmethod
330
+ def should_trigger_reindex(
331
+ changed_files: set[Path], file_extensions: list[str]
332
+ ) -> bool:
333
+ """Check if changed files should trigger reindexing.
334
+
335
+ Args:
336
+ changed_files: Set of changed file paths
337
+ file_extensions: List of file extensions to monitor
338
+
339
+ Returns:
340
+ True if reindexing should be triggered
341
+ """
342
+ for file_path in changed_files:
343
+ if file_path.suffix in file_extensions:
344
+ return True
345
+ return False