diff --git a/Cargo.lock b/Cargo.lock index 4051c0f..b82c510 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1118,7 +1118,9 @@ dependencies = [ "codegraph-yaml", "codegraph-zig", "dashmap", + "fs2", "globset", + "ignore", "lru", "notify", "regex", @@ -2221,6 +2223,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b17771570a2b94107741a7b033f19132c2eee21d59d21b24d2ced26500bd66e" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.25.10" diff --git a/crates/codegraph-server/Cargo.toml b/crates/codegraph-server/Cargo.toml index 3028865..5138761 100644 --- a/crates/codegraph-server/Cargo.toml +++ b/crates/codegraph-server/Cargo.toml @@ -37,6 +37,8 @@ tracing-subscriber.workspace = true lru.workspace = true regex.workspace = true globset.workspace = true +ignore = "0.4" +fs2.workspace = true # CodeGraph ecosystem codegraph.workspace = true diff --git a/crates/codegraph-server/src/indexer.rs b/crates/codegraph-server/src/indexer.rs index 3733afa..2a8f205 100644 --- a/crates/codegraph-server/src/indexer.rs +++ b/crates/codegraph-server/src/indexer.rs @@ -9,6 +9,7 @@ use crate::index_state::IndexState; use crate::parser_registry::ParserRegistry; +use crate::path_filter::WorkspaceFilter; use crate::watcher::GraphUpdater; use codegraph::CodeGraph; use std::path::{Path, PathBuf}; @@ -323,6 +324,21 @@ pub struct IndexResult { pub parser_errors_by_language: std::collections::HashMap, } +type DirectoryIndexFuture<'a> = std::pin::Pin< + Box< + dyn std::future::Future< + Output = ( + usize, + usize, + usize, + std::collections::HashMap, + std::collections::HashMap, + ), + > + Send + + 'a, + >, +>; + /// Shared indexer for walking directories, hashing files, and parsing them /// into a [`CodeGraph`]. pub struct Indexer { @@ -368,14 +384,8 @@ impl Indexer { let mut result = IndexResult::default(); for folder in folders { - // Bounty 2026-05-03 — extend exclude_patterns from this - // folder's `.codegraphignore` if present. Per-folder so each - // workspace can have its own rules; doesn't pollute other - // folders' configs. - let mut folder_config = config.clone(); - folder_config.extend_from_codegraphignore(folder); let (total, parsed, skipped, by_lang, parser_errors) = self - .index_directory(graph, folder, &folder_config, 0, counter.clone()) + .index_directory(graph, folder, config, 0, counter.clone()) .await; result.total_files += total; result.files_parsed += parsed; @@ -429,20 +439,23 @@ impl Indexer { config: &'a IndexConfig, depth: u32, counter: Arc, - ) -> std::pin::Pin< - Box< - dyn std::future::Future< - Output = ( - usize, - usize, - usize, - std::collections::HashMap, - std::collections::HashMap, - ), - > + Send - + 'a, - >, - > { + ) -> DirectoryIndexFuture<'a> { + Box::pin(async move { + let mut filter = WorkspaceFilter::new(dir, config); + self.index_directory_filtered(graph, dir, config, depth, counter, &mut filter) + .await + }) + } + + fn index_directory_filtered<'a>( + &'a self, + graph: &'a Arc>, + dir: &'a Path, + config: &'a IndexConfig, + depth: u32, + counter: Arc, + filter: &'a mut WorkspaceFilter, + ) -> DirectoryIndexFuture<'a> { Box::pin(async move { use std::sync::atomic::Ordering; @@ -462,7 +475,6 @@ impl Indexer { return (0, 0, 0, empty_by_lang, empty_errors); } - let exclude_set = config.build_exclude_set(); let supported_extensions = self.parsers.supported_extensions(); tracing::debug!("Scanning directory: {:?}", dir); @@ -492,35 +504,20 @@ impl Indexer { let path = entry.path(); - // Skip hidden files and directories - if let Some(name) = path.file_name() { - if name.to_string_lossy().starts_with('.') { - continue; - } + if !filter.allows(&path, path.is_dir()) { + continue; } if path.is_dir() { - let dir_name = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - - // Skip hardcoded exclude directories - if config.exclude_dirs.iter().any(|e| e == &dir_name) { - continue; - } - - // Skip directories matching user-configured exclude globs - let path_str = path.to_string_lossy(); - if exclude_set.is_match(path_str.as_ref()) - || exclude_set.is_match(dir_name.as_str()) - { - tracing::info!("Skipping {:?}: matched exclude pattern", path); - continue; - } - let (t, p, s, child_by_lang, child_errors) = self - .index_directory(graph, &path, config, depth + 1, counter.clone()) + .index_directory_filtered( + graph, + &path, + config, + depth + 1, + counter.clone(), + filter, + ) .await; total += t; parsed += p; @@ -532,25 +529,6 @@ impl Indexer { *parser_errors.entry(lang).or_insert(0) += count; } } else if path.is_file() { - // Skip files matching exclude globs - let path_str = path.to_string_lossy(); - if exclude_set.is_match(path_str.as_ref()) { - continue; - } - - // Skip files that exceed the configurable size limit - if let Ok(metadata) = std::fs::metadata(&path) { - if metadata.len() > config.max_file_size_bytes { - tracing::info!( - "Skipping {:?}: file size {} exceeds limit of {}", - path, - metadata.len(), - config.max_file_size_bytes - ); - continue; - } - } - // Check if file has a supported extension if let Some(ext) = path.extension() { let ext_str = ext.to_string_lossy(); diff --git a/crates/codegraph-server/src/lib.rs b/crates/codegraph-server/src/lib.rs index 448cab6..5361237 100644 --- a/crates/codegraph-server/src/lib.rs +++ b/crates/codegraph-server/src/lib.rs @@ -47,6 +47,7 @@ pub mod mcp; pub mod memory; pub mod metadata; pub mod parser_registry; +mod path_filter; pub mod runtime_deps; pub mod telemetry; pub mod watcher; diff --git a/crates/codegraph-server/src/main.rs b/crates/codegraph-server/src/main.rs index ce49297..3ae483b 100644 --- a/crates/codegraph-server/src/main.rs +++ b/crates/codegraph-server/src/main.rs @@ -125,6 +125,30 @@ struct Args { socket: Option, } +impl Args { + fn engine_args(&self) -> Vec { + let mut args = vec![ + "--embedding-model".into(), + self.embedding_model.clone().into(), + "--max-files".into(), + self.max_files.to_string().into(), + ]; + for directory in &self.exclude { + args.extend(["--exclude".into(), directory.into()]); + } + if self.graph_only { + args.push("--graph-only".into()); + } + if let Some(profile) = &self.profile { + args.extend(["--profile".into(), profile.into()]); + } + if self.full_body_embedding { + args.push("--full-body-embedding".into()); + } + args + } +} + /// Default engine socket path (`~/.codegraph/cg-engine.sock`). fn default_socket_path() -> PathBuf { let home = std::env::var_os("HOME") @@ -336,7 +360,7 @@ async fn run() { std::env::current_dir().expect("Failed to get current directory") }); if let Err(e) = - codegraph_server::mcp::engine::connect(&sock, workspace, &args.embedding_model).await + codegraph_server::mcp::engine::connect(&sock, workspace, &args.engine_args()).await { eprintln!("connect failed: {e}"); std::process::exit(1); @@ -422,6 +446,14 @@ async fn run() { exclude_dirs: args.exclude.clone(), max_files: args.max_files, full_body_embedding: args.full_body_embedding, + graph_only: args.graph_only, + tool_profile: codegraph_server::mcp::tools::ToolProfile::from_str_or_all( + &args + .profile + .clone() + .or_else(|| std::env::var("CODEGRAPH_TOOL_PROFILE").ok()) + .unwrap_or_default(), + ), seeds: args.workspace.clone(), }; if let Err(e) = codegraph_server::mcp::engine::serve(cfg).await { @@ -520,6 +552,44 @@ async fn run() { } } +#[cfg(test)] +mod engine_args_tests { + use super::*; + + #[test] + fn auto_spawn_preserves_resource_settings() { + let client = Args::try_parse_from([ + "codegraph-server", + "--connect", + "--graph-only", + "--profile", + "graph", + "--exclude", + "cache", + "--exclude", + "generated", + "--max-files", + "12", + "--embedding-model", + "static", + ]) + .unwrap(); + let mut command = vec![ + std::ffi::OsString::from("codegraph-server"), + "--serve".into(), + ]; + command.extend(client.engine_args()); + let engine = Args::try_parse_from(command).unwrap(); + assert!(engine.serve); + assert!(engine.graph_only); + assert_eq!(engine.profile.as_deref(), Some("graph")); + assert_eq!(engine.exclude, ["cache", "generated"]); + assert_eq!(engine.max_files, 12); + assert_eq!(engine.embedding_model, "static"); + assert_eq!(engine.full_body_embedding, client.full_body_embedding); + } +} + #[cfg(test)] mod crash_breadcrumb_tests { use super::{classify_panic, write_crash_breadcrumb}; diff --git a/crates/codegraph-server/src/mcp/engine.rs b/crates/codegraph-server/src/mcp/engine.rs index db5b523..5d1e970 100644 --- a/crates/codegraph-server/src/mcp/engine.rs +++ b/crates/codegraph-server/src/mcp/engine.rs @@ -21,6 +21,7 @@ use std::path::PathBuf; use codegraph_memory::EmbeddingBackend; use super::server::McpServer; +use super::tools::ToolProfile; /// Configuration for a running engine. pub struct EngineConfig { @@ -29,6 +30,8 @@ pub struct EngineConfig { pub exclude_dirs: Vec, pub max_files: usize, pub full_body_embedding: bool, + pub graph_only: bool, + pub tool_profile: ToolProfile, /// Workspaces to pre-load at startup (optional; others load on attach). pub seeds: Vec, } @@ -71,11 +74,11 @@ mod imp { use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; - use tokio::sync::Mutex; + use tokio::sync::{Mutex, OnceCell}; use codegraph_memory::VectorEngine; - type Registry = Arc>>>; + type Registry = Arc>>>>>; struct Engine { cfg: EngineConfig, @@ -96,10 +99,18 @@ mod imp { let ws = workspace .canonicalize() .unwrap_or_else(|_| workspace.clone()); - if let Some(s) = engine.registry.lock().await.get(&ws).cloned() { - return s; - } + let slot = { + let mut registry = engine.registry.lock().await; + Arc::clone( + registry + .entry(ws.clone()) + .or_insert_with(|| Arc::new(OnceCell::new())), + ) + }; + Arc::clone(slot.get_or_init(|| load_workspace(engine, ws)).await) + } + async fn load_workspace(engine: &Engine, ws: PathBuf) -> Arc { tracing::info!("Engine: loading workspace {}", ws.display()); let mut server = McpServer::new( vec![ws.clone()], @@ -107,17 +118,14 @@ mod imp { engine.cfg.max_files, engine.cfg.embedding_model.clone(), engine.cfg.full_body_embedding, - ); + ) + .with_graph_only(engine.cfg.graph_only || engine.shared_engine.is_none()) + .with_tool_profile(engine.cfg.tool_profile); if let Some(shared) = &engine.shared_engine { server.set_shared_engine(Arc::clone(shared)).await; } server.ensure_indexed().await; - let server = Arc::new(server); - - let mut reg = engine.registry.lock().await; - // Another connection may have loaded it while we built — prefer theirs. - reg.entry(ws).or_insert_with(|| Arc::clone(&server)); - server + Arc::new(server) } /// First line of a connection: an attach frame selects the workspace. @@ -207,6 +215,24 @@ mod imp { } pub async fn serve(cfg: EngineConfig) -> Result<(), String> { + // Hold the lock for the engine lifetime. Probing a socket alone races + // while another auto-spawned engine is still loading its first graph. + if let Some(parent) = cfg.socket_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("socket directory: {e}"))?; + } + let lock = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(cfg.socket_path.with_extension("lock")) + .map_err(|e| format!("engine lock: {e}"))?; + if let Err(error) = fs2::FileExt::try_lock_exclusive(&lock) { + if error.kind() == std::io::ErrorKind::WouldBlock { + return Ok(()); + } + return Err(format!("engine lock: {error}")); + } // Don't start a second engine over a live one (handles auto-spawn races). if engine_is_live(&cfg.socket_path).await { tracing::info!("Engine: another instance is already live — exiting"); @@ -216,7 +242,9 @@ mod imp { // One model for the whole engine. Gate on free memory the same way the // per-workspace path does, so a constrained box runs graph-only instead // of OOM-crashing on the model load. - let shared_engine = { + let shared_engine = if cfg.graph_only { + None + } else { let mut sys = sysinfo::System::new(); sys.refresh_memory(); let avail = sys.available_memory(); @@ -310,7 +338,7 @@ mod imp { /// Spawn a detached engine for `socket_path` using this binary, so the engine /// outlives the shim. Best-effort; the caller retries the connect. - fn spawn_engine(socket_path: &std::path::Path, embedding_model: &str) { + fn spawn_engine(socket_path: &std::path::Path, engine_args: &[std::ffi::OsString]) { let exe = match std::env::current_exe() { Ok(e) => e, Err(e) => { @@ -322,8 +350,7 @@ mod imp { cmd.arg("--serve") .arg("--socket") .arg(socket_path) - .arg("--embedding-model") - .arg(embedding_model) + .args(engine_args) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); @@ -339,7 +366,7 @@ mod imp { pub async fn connect( socket_path: &std::path::Path, workspace: PathBuf, - embedding_model: &str, + engine_args: &[std::ffi::OsString], ) -> Result<(), String> { use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -347,7 +374,7 @@ mod imp { let stream = match UnixStream::connect(socket_path).await { Ok(s) => s, Err(_) => { - spawn_engine(socket_path, embedding_model); + spawn_engine(socket_path, engine_args); let mut connected = None; for _ in 0..60 { tokio::time::sleep(Duration::from_millis(500)).await; @@ -404,6 +431,49 @@ mod imp { } Ok(()) } + + #[cfg(test)] + mod tests { + use super::*; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_attaches_share_one_graph_only_backend() { + let temporary = tempfile::tempdir().unwrap(); + let workspace = temporary.path().to_path_buf(); + std::fs::write(workspace.join("source.rs"), "pub fn shared_symbol() {}\n").unwrap(); + let engine = Arc::new(Engine { + cfg: EngineConfig { + socket_path: workspace.join("engine.sock"), + embedding_model: EmbeddingBackend::parse("bge-small"), + exclude_dirs: vec![], + max_files: 10, + full_body_embedding: true, + graph_only: true, + tool_profile: ToolProfile::Core, + seeds: vec![], + }, + registry: Arc::new(Mutex::new(HashMap::new())), + shared_engine: None, + active: AtomicUsize::new(0), + idle_since: AtomicU64::new(0), + }); + let barrier = Arc::new(tokio::sync::Barrier::new(3)); + let [first, second] = [0, 1].map(|_| { + let engine = Arc::clone(&engine); + let barrier = Arc::clone(&barrier); + let workspace = workspace.clone(); + tokio::spawn(async move { + barrier.wait().await; + get_or_load(&engine, workspace).await + }) + }); + barrier.wait().await; + let (first, second) = tokio::join!(first, second); + let (first, second) = (first.unwrap(), second.unwrap()); + assert!(Arc::ptr_eq(&first, &second)); + assert_eq!(engine.registry.lock().await.len(), 1); + } + } } #[cfg(unix)] @@ -418,7 +488,7 @@ pub async fn serve(_cfg: EngineConfig) -> Result<(), String> { pub async fn connect( _socket_path: &std::path::Path, _workspace: PathBuf, - _embedding_model: &str, + _engine_args: &[std::ffi::OsString], ) -> Result<(), String> { Err("the socket engine is not yet supported on this platform".to_string()) } diff --git a/crates/codegraph-server/src/mcp/file_watcher.rs b/crates/codegraph-server/src/mcp/file_watcher.rs index e45203f..f498b69 100644 --- a/crates/codegraph-server/src/mcp/file_watcher.rs +++ b/crates/codegraph-server/src/mcp/file_watcher.rs @@ -9,11 +9,13 @@ //! resolves cross-file imports, and rebuilds search indexes. use crate::ai_query::QueryEngine; +use crate::indexer::IndexConfig; use crate::parser_registry::ParserRegistry; +use crate::path_filter::WorkspaceFilter; use crate::watcher::GraphUpdater; -use codegraph::CodeGraph; +use codegraph::{CodeGraph, NodeId}; use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -22,23 +24,16 @@ use tokio::sync::{mpsc, RwLock}; /// Debounce interval — wait 2 seconds after last change before processing. const DEBOUNCE_MS: u64 = 2000; -/// Directories to skip when watching -const SKIP_DIRS: &[&str] = &[ - "node_modules", - "target", - "__pycache__", - ".git", - "dist", - "build", - "out", - "vendor", - "coverage", - "logs", -]; - /// Watches workspace directories for file changes and auto-indexes them. pub struct McpFileWatcher { _watcher: RecommendedWatcher, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for McpFileWatcher { + fn drop(&mut self) { + self.task.abort(); + } } /// Shared context for the watcher's async task. @@ -47,6 +42,7 @@ struct WatcherCtx { parsers: Arc, query_engine: Arc, supported_extensions: Vec, + max_files: usize, } impl McpFileWatcher { @@ -58,6 +54,7 @@ impl McpFileWatcher { parsers: Arc, query_engine: Arc, directories: &[PathBuf], + index_config: IndexConfig, ) -> Result { let (tx, mut rx) = mpsc::channel::(100); @@ -90,9 +87,14 @@ impl McpFileWatcher { parsers, query_engine, supported_extensions, + max_files: index_config.max_files, }; - tokio::spawn(async move { + let mut filters: Vec<_> = directories + .iter() + .map(|directory| WorkspaceFilter::new(directory, &index_config)) + .collect(); + let task = tokio::spawn(async move { let debounce = Duration::from_millis(DEBOUNCE_MS); let mut pending: HashSet = HashSet::new(); let mut deleted: HashSet = HashSet::new(); @@ -103,8 +105,17 @@ impl McpFileWatcher { event = rx.recv() => { match event { Some(event) => { + if !matches!(event.kind, EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)) { + continue; + } for path in &event.paths { - if !is_watchable(path, &ctx.supported_extensions) { + if matches!(path.file_name().and_then(|n| n.to_str()), Some(".gitignore" | ".codegraphignore")) { + for filter in &mut filters { + filter.reload(&index_config); + } + continue; + } + if !is_watchable(path, &ctx.supported_extensions, &mut filters) { continue; } match event.kind { @@ -129,11 +140,13 @@ impl McpFileWatcher { // Check debounce timer if let Some(last) = last_event { if last.elapsed() >= debounce && (!pending.is_empty() || !deleted.is_empty()) { - let changed: Vec = pending.drain().collect(); - let removed: Vec = deleted.drain().collect(); + let changed: Vec = pending.drain() + .filter(|p| is_watchable(p, &ctx.supported_extensions, &mut filters)).collect(); + let removed: Vec = deleted.drain() + .filter(|p| is_watchable(p, &ctx.supported_extensions, &mut filters)).collect(); last_event = None; - process_changes(&ctx, &changed, &removed).await; + process_changes(&ctx, &changed, &removed, &mut filters).await; } } } @@ -144,30 +157,26 @@ impl McpFileWatcher { let watch_count = directories.len(); tracing::info!("MCP file watcher started ({} directories)", watch_count); - Ok(McpFileWatcher { _watcher: watcher }) + Ok(McpFileWatcher { + _watcher: watcher, + task, + }) } } /// Check if a path is a supported source file worth watching. -fn is_watchable(path: &Path, supported_extensions: &[String]) -> bool { +fn is_watchable( + path: &Path, + supported_extensions: &[String], + filters: &mut [WorkspaceFilter], +) -> bool { // Skip directories if path.is_dir() { return false; } - // Skip paths in excluded directories - let path_str = path.to_string_lossy(); - for skip in SKIP_DIRS { - if path_str.contains(&format!("/{skip}/")) || path_str.contains(&format!("\\{skip}\\")) { - return false; - } - } - - // Skip hidden files - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - if name.starts_with('.') { - return false; - } + if !filters.iter_mut().any(|filter| filter.allows(path, false)) { + return false; } // Check extension @@ -179,8 +188,16 @@ fn is_watchable(path: &Path, supported_extensions: &[String]) -> bool { } /// Process accumulated file changes: re-index changed files, remove deleted files. -async fn process_changes(ctx: &WatcherCtx, changed: &[PathBuf], removed: &[PathBuf]) { +async fn process_changes( + ctx: &WatcherCtx, + changed: &[PathBuf], + removed: &[PathBuf], + filters: &mut [WorkspaceFilter], +) { let total = changed.len() + removed.len(); + if total == 0 { + return; + } tracing::info!( "[file-watcher] Processing {} changes ({} modified, {} deleted)", total, @@ -188,19 +205,32 @@ async fn process_changes(ctx: &WatcherCtx, changed: &[PathBuf], removed: &[PathB removed.len() ); + // Snapshot once per batch rather than scanning every graph node for every + // changed/deleted/dependent file. Unknown deleted files need no graph query. + let nodes_by_path = { + let graph = ctx.graph.read().await; + group_file_nodes(&graph) + }; + let absent: HashSet<_> = removed + .iter() + .chain(changed.iter().filter(|p| !p.exists())) + .collect(); + let mut file_count = nodes_by_path.len() + - absent + .iter() + .filter(|p| nodes_by_path.contains_key(**p)) + .count(); + // Handle deleted files let mut had_deletes = false; if !removed.is_empty() { for path in removed { - let path_str = path.to_string_lossy().to_string(); - // Remove vectors before deleting nodes (needs node IDs still in graph) - ctx.query_engine.remove_file_vectors(&path_str).await; // Remove nodes and connected edges let mut graph = ctx.graph.write().await; - if let Ok(old_nodes) = graph.query().property("path", path_str.as_str()).execute() { + if let Some(old_nodes) = nodes_by_path.get(path) { let count = old_nodes.len(); for old_id in old_nodes { - let _ = graph.delete_node(old_id); + let _ = graph.delete_node(*old_id); } if count > 0 { had_deletes = true; @@ -219,16 +249,24 @@ async fn process_changes(ctx: &WatcherCtx, changed: &[PathBuf], removed: &[PathB let mut actual_changed = Vec::new(); for path in changed { if path.exists() { + if !nodes_by_path.contains_key(path) { + if file_count >= ctx.max_files { + tracing::warn!( + "Watcher reached max indexed file limit of {}", + ctx.max_files + ); + continue; + } + file_count += 1; + } actual_changed.push(path.clone()); } else { // File was reported as modified but doesn't exist — treat as delete - let path_str = path.to_string_lossy().to_string(); - ctx.query_engine.remove_file_vectors(&path_str).await; let mut graph = ctx.graph.write().await; - if let Ok(old_nodes) = graph.query().property("path", path_str.as_str()).execute() { + if let Some(old_nodes) = nodes_by_path.get(path) { let count = old_nodes.len(); for old_id in old_nodes { - let _ = graph.delete_node(old_id); + let _ = graph.delete_node(*old_id); } if count > 0 { had_deletes = true; @@ -250,10 +288,8 @@ async fn process_changes(ctx: &WatcherCtx, changed: &[PathBuf], removed: &[PathB { let graph = ctx.graph.read().await; for path in changed { - let path_str = path.to_string_lossy().to_string(); - if let Ok(file_nodes) = graph.query().property("path", path_str.as_str()).execute() - { - for node_id in &file_nodes { + if let Some(file_nodes) = nodes_by_path.get(path) { + for node_id in file_nodes { if let Ok(neighbors) = graph.get_neighbors(*node_id, codegraph::Direction::Incoming) { @@ -261,7 +297,14 @@ async fn process_changes(ctx: &WatcherCtx, changed: &[PathBuf], removed: &[PathB if let Ok(neighbor) = graph.get_node(neighbor_id) { if let Some(dep_path) = neighbor.properties.get_string("path") { let dep = PathBuf::from(dep_path); - if !changed.contains(&dep) && dep.exists() { + if !changed.contains(&dep) + && dep.exists() + && is_watchable( + &dep, + &ctx.supported_extensions, + filters, + ) + { dependents.insert(dep); } } @@ -278,10 +321,9 @@ async fn process_changes(ctx: &WatcherCtx, changed: &[PathBuf], removed: &[PathB for path in changed { { let mut graph = ctx.graph.write().await; - let path_str = path.to_string_lossy().to_string(); - if let Ok(old_nodes) = graph.query().property("path", path_str.as_str()).execute() { + if let Some(old_nodes) = nodes_by_path.get(path) { for old_id in old_nodes { - let _ = graph.delete_node(old_id); + let _ = graph.delete_node(*old_id); } } } @@ -299,12 +341,9 @@ async fn process_changes(ctx: &WatcherCtx, changed: &[PathBuf], removed: &[PathB for dep in &dependents { { let mut graph = ctx.graph.write().await; - let path_str = dep.to_string_lossy().to_string(); - if let Ok(old_nodes) = - graph.query().property("path", path_str.as_str()).execute() - { + if let Some(old_nodes) = nodes_by_path.get(dep) { for old_id in old_nodes { - let _ = graph.delete_node(old_id); + let _ = graph.delete_node(*old_id); } } } @@ -324,6 +363,7 @@ async fn process_changes(ctx: &WatcherCtx, changed: &[PathBuf], removed: &[PathB GraphUpdater::resolve_cross_file_imports(&mut graph); } // Rebuild search indexes + ctx.query_engine.prune_orphan_vectors().await; ctx.query_engine.build_indexes().await; // Incrementally re-embed changed files for path in changed.iter().chain(dependents.iter()) { @@ -336,5 +376,158 @@ async fn process_changes(ctx: &WatcherCtx, changed: &[PathBuf], removed: &[PathB indexed ); } + } else if had_deletes { + ctx.query_engine.prune_orphan_vectors().await; + ctx.query_engine.build_indexes().await; + } +} + +fn group_file_nodes(graph: &CodeGraph) -> HashMap> { + let mut files: HashMap> = HashMap::new(); + for (&id, node) in graph.nodes_iter() { + if let Some(path) = node.properties.get_string("path") { + let path = Path::new(path); + if let Some(nodes) = files.get_mut(path) { + nodes.push(id); + } else { + files.insert(path.to_path_buf(), vec![id]); + } + } + } + files +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ai_query::SearchOptions; + use crate::index_state::IndexState; + use crate::indexer::Indexer; + use tokio::sync::Mutex; + + fn context(max_files: usize) -> WatcherCtx { + let graph = Arc::new(RwLock::new(CodeGraph::in_memory().unwrap())); + WatcherCtx { + query_engine: Arc::new(QueryEngine::new(Arc::clone(&graph))), + graph, + parsers: Arc::new(ParserRegistry::new()), + supported_extensions: vec!["rs".into()], + max_files, + } + } + + async fn has_symbol(ctx: &WatcherCtx, name: &str) -> bool { + ctx.query_engine + .symbol_search(name, &SearchOptions::new()) + .await + .results + .iter() + .any(|result| result.symbol.name == name) + } + + #[tokio::test] + async fn watcher_updates_at_file_limit_and_rebuilds_after_deletion() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + let first = root.join("first.rs"); + let second = root.join("second.rs"); + std::fs::write(&first, "pub fn before_update() {}\n").unwrap(); + std::fs::write(&second, "pub fn second_file() {}\n").unwrap(); + let ctx = context(1); + let config = IndexConfig { + max_files: 1, + ..IndexConfig::default() + }; + let mut filters = [WorkspaceFilter::new(root, &config)]; + process_changes(&ctx, &[first.clone(), second.clone()], &[], &mut filters).await; + assert!(has_symbol(&ctx, "before_update").await); + assert!(!has_symbol(&ctx, "second_file").await); + + std::fs::write(&first, "pub fn after_update() {}\n").unwrap(); + process_changes(&ctx, std::slice::from_ref(&first), &[], &mut filters).await; + assert!(has_symbol(&ctx, "after_update").await); + assert!(!has_symbol(&ctx, "before_update").await); + std::fs::remove_file(&first).unwrap(); + // FSEvents can report a vanished path as modified. + process_changes(&ctx, &[first, second.clone()], &[], &mut filters).await; + assert!(has_symbol(&ctx, "second_file").await); + assert!(!has_symbol(&ctx, "after_update").await); + std::fs::remove_file(&second).unwrap(); + process_changes( + &ctx, + &[], + &[second, root.join("never-indexed.rs")], + &mut filters, + ) + .await; + assert!(!has_symbol(&ctx, "second_file").await); + assert_eq!(ctx.graph.read().await.node_count(), 0); + } + + #[tokio::test] + async fn initial_index_and_live_watcher_share_workspace_filters() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().canonicalize().unwrap(); + std::fs::write(root.join(".gitignore"), "cache/\n").unwrap(); + std::fs::write(root.join(".codegraphignore"), "**/generated/**\n").unwrap(); + for directory in ["cache", "generated", "custom", "target"] { + std::fs::create_dir(root.join(directory)).unwrap(); + std::fs::write( + root.join(directory).join("ignored.rs"), + "pub fn ignored() {}\n", + ) + .unwrap(); + } + let source = root.join("source.rs"); + std::fs::write(&source, "pub fn initial_source() {}\n").unwrap(); + let ctx = context(10); + let mut config = IndexConfig::default(); + config.exclude_dirs.push("custom".into()); + let indexer = Indexer::new( + Arc::clone(&ctx.parsers), + Arc::new(Mutex::new(IndexState::new("watcher-test"))), + ); + let (total, parsed, _, _, _) = indexer + .index_directory( + &ctx.graph, + &root, + &config, + 0, + Arc::new(std::sync::atomic::AtomicUsize::new(0)), + ) + .await; + assert_eq!((total, parsed), (1, 1)); + ctx.query_engine.build_indexes().await; + assert!(has_symbol(&ctx, "initial_source").await); + let watcher = McpFileWatcher::start( + Arc::clone(&ctx.graph), + Arc::clone(&ctx.parsers), + Arc::clone(&ctx.query_engine), + std::slice::from_ref(&root), + config, + ) + .unwrap(); + for directory in ["cache", "generated", "custom", "target"] { + for index in 0..20 { + std::fs::write( + root.join(directory).join(format!("churn{index}.rs")), + "pub fn unwanted_churn() {}\n", + ) + .unwrap(); + } + } + std::fs::write(&source, "pub fn live_update() {}\n").unwrap(); + tokio::time::timeout(Duration::from_secs(15), async { + while !has_symbol(&ctx, "live_update").await { + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .expect("watcher did not index the allowed edit"); + assert!(!has_symbol(&ctx, "unwanted_churn").await); + assert!(!has_symbol(&ctx, "ignored").await); + assert!(!has_symbol(&ctx, "initial_source").await); + assert_eq!(group_file_nodes(&*ctx.graph.read().await).len(), 1); + drop(watcher); } } diff --git a/crates/codegraph-server/src/mcp/server.rs b/crates/codegraph-server/src/mcp/server.rs index 1ecf815..398c2fb 100644 --- a/crates/codegraph-server/src/mcp/server.rs +++ b/crates/codegraph-server/src/mcp/server.rs @@ -937,9 +937,11 @@ impl McpBackend { let config = self.index_config(); // Initialize memory manager for each workspace folder - for folder in &self.workspace_folders { - if let Err(e) = self.memory_manager.initialize(folder).await { - tracing::warn!("Failed to initialize memory manager: {:?}", e); + if !self.graph_only { + for folder in &self.workspace_folders { + if let Err(e) = self.memory_manager.initialize(folder).await { + tracing::warn!("Failed to initialize memory manager: {:?}", e); + } } } @@ -1325,9 +1327,11 @@ impl McpServer { "version": crate::metadata::VERSION, })); - for folder in &self.backend.workspace_folders { - if let Err(e) = self.backend.memory_manager.initialize(folder).await { - tracing::warn!("Failed to initialize memory manager: {:?}", e); + if !self.backend.graph_only { + for folder in &self.backend.workspace_folders { + if let Err(e) = self.backend.memory_manager.initialize(folder).await { + tracing::warn!("Failed to initialize memory manager: {:?}", e); + } } } // Build text/caller/callee indexes from the loaded graph (cheap — no @@ -1372,6 +1376,7 @@ impl McpServer { Arc::clone(&self.backend.parsers), Arc::clone(&self.backend.query_engine), &self.backend.workspace_folders, + self.backend.index_config(), ) { Ok(watcher) => { self._file_watcher = Some(watcher); diff --git a/crates/codegraph-server/src/path_filter.rs b/crates/codegraph-server/src/path_filter.rs new file mode 100644 index 0000000..8e598ff --- /dev/null +++ b/crates/codegraph-server/src/path_filter.rs @@ -0,0 +1,179 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +//! Workspace exclusions shared by directory indexing and MCP file events. + +use crate::indexer::IndexConfig; +use ignore::gitignore::Gitignore; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +pub(crate) struct WorkspaceFilter { + root: PathBuf, + config: IndexConfig, + excludes: globset::GlobSet, + gitignores: HashMap, +} + +impl WorkspaceFilter { + pub(crate) fn new(root: &Path, config: &IndexConfig) -> Self { + let mut config = config.clone(); + config.extend_from_codegraphignore(root); + Self { + root: root.to_path_buf(), + excludes: config.build_exclude_set(), + config, + gitignores: HashMap::new(), + } + } + + pub(crate) fn allows(&mut self, path: &Path, is_dir: bool) -> bool { + let Ok(relative) = path.strip_prefix(&self.root) else { + return false; + }; + let mut current = self.root.clone(); + let mut parents = Vec::new(); + let components: Vec<_> = relative.components().collect(); + let depth = components.len().saturating_sub(usize::from(!is_dir)); + if depth > self.config.max_depth as usize { + return false; + } + for (index, component) in components.iter().enumerate() { + let directory = index + 1 < components.len() || is_dir; + parents.push(current.clone()); + current.push(component); + if self.excluded(¤t, directory) || self.gitignored(¤t, directory, &parents) + { + return false; + } + } + is_dir + || std::fs::metadata(path).map_or(true, |m| m.len() <= self.config.max_file_size_bytes) + } + + fn excluded(&self, path: &Path, is_dir: bool) -> bool { + let Some(name) = path.file_name() else { + return true; + }; + let name = name.to_string_lossy(); + name.starts_with('.') + || self.excludes.is_match(path) + || (is_dir + && (self + .config + .exclude_dirs + .iter() + .any(|dir| dir == name.as_ref()) + || self.excludes.is_match(name.as_ref()))) + } + + fn gitignored(&mut self, path: &Path, is_dir: bool, parents: &[PathBuf]) -> bool { + for parent in parents.iter().rev() { + let matcher = self.gitignores.entry(parent.clone()).or_insert_with(|| { + let (matcher, error) = Gitignore::new(parent.join(".gitignore")); + if let Some(error) = error { + tracing::warn!("Invalid .gitignore in {}: {error}", parent.display()); + } + matcher + }); + match matcher.matched(path, is_dir) { + ignore::Match::Ignore(_) => return true, + ignore::Match::Whitelist(_) => return false, + ignore::Match::None => {} + } + } + false + } + + pub(crate) fn reload(&mut self, config: &IndexConfig) { + *self = Self::new(&self.root, config); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filters_root_relative_paths_including_deleted_files() { + let temporary = tempfile::tempdir().unwrap(); + // An excluded name ABOVE the workspace must not exclude the workspace. + let root = temporary.path().join("target/project"); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write( + root.join(".gitignore"), + "/cache/\n*.generated.rs\n!keep.generated.rs\n", + ) + .unwrap(); + std::fs::write(root.join(".codegraphignore"), "**/custom/**\n").unwrap(); + std::fs::write(root.join("src/.gitignore"), "nested.rs\n").unwrap(); + let mut config = IndexConfig::default(); + config.exclude_dirs.push("manual".into()); + let mut filter = WorkspaceFilter::new(&root, &config); + for (path, expected) in [ + ("src/main.rs", true), + ("cache/gone.rs", false), + ("src/cache/allowed.rs", true), + ("src/nested.rs", false), + ("nested.rs", true), + ("other.generated.rs", false), + ("keep.generated.rs", true), + ("custom/gone.rs", false), + ("manual/gone.rs", false), + (".venv/gone.py", false), + ("tmp/gone.rs", false), + ("target/gone.rs", false), + (".hidden/gone.rs", false), + ] { + assert_eq!(filter.allows(&root.join(path), false), expected, "{path}"); + } + assert!(!filter.allows(&temporary.path().join("outside.rs"), false)); + } + + #[test] + fn honors_parent_exclusion_and_nested_negation() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::create_dir_all(root.join("ignored")).unwrap(); + std::fs::write(root.join(".gitignore"), "*.rs\nignored/\n").unwrap(); + std::fs::write(root.join("src/.gitignore"), "!keep.rs\n").unwrap(); + std::fs::write(root.join("ignored/.gitignore"), "!keep.rs\n").unwrap(); + let mut filter = WorkspaceFilter::new(root, &IndexConfig::default()); + assert!(filter.allows(&root.join("src/keep.rs"), false)); + assert!(!filter.allows(&root.join("src/drop.rs"), false)); + assert!(!filter.allows(&root.join("ignored/keep.rs"), false)); + } + + #[test] + fn reloads_rules_and_enforces_file_size() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + let config = IndexConfig { + max_file_size_bytes: 4, + ..IndexConfig::default() + }; + let mut filter = WorkspaceFilter::new(root, &config); + let file = root.join("source.rs"); + std::fs::write(&file, "12345").unwrap(); + assert!(!filter.allows(&file, false)); + std::fs::write(&file, "1234").unwrap(); + assert!(filter.allows(&file, false)); + std::fs::write(root.join(".gitignore"), "source.rs\n").unwrap(); + filter.reload(&config); + assert!(!filter.allows(&file, false)); + } + + #[test] + fn enforces_the_same_depth_limit_as_directory_indexing() { + let temporary = tempfile::tempdir().unwrap(); + let config = IndexConfig { + max_depth: 1, + ..IndexConfig::default() + }; + let mut filter = WorkspaceFilter::new(temporary.path(), &config); + assert!(filter.allows(&temporary.path().join("src/main.rs"), false)); + assert!(!filter.allows(&temporary.path().join("src/deep/main.rs"), false)); + assert!(!filter.allows(&temporary.path().join("src/deep"), true)); + } +} diff --git a/crates/codegraph-server/tests/graph_only_test.rs b/crates/codegraph-server/tests/graph_only_test.rs new file mode 100644 index 0000000..7d39112 --- /dev/null +++ b/crates/codegraph-server/tests/graph_only_test.rs @@ -0,0 +1,175 @@ +// Copyright 2026 Andrey Vasilevsky +// SPDX-License-Identifier: Apache-2.0 + +use std::process::Stdio; +use std::time::Duration; + +#[tokio::test] +async fn graph_only_indexes_source_without_initializing_memory_or_models() { + let temporary = tempfile::Builder::new() + .prefix("codegraph-harness-") + .tempdir() + .unwrap(); + let workspace = temporary.path().join("workspace"); + std::fs::create_dir(&workspace).unwrap(); + std::fs::write( + workspace.join("source.rs"), + "pub fn graph_only_symbol() {}\n", + ) + .unwrap(); + let mut command = tokio::process::Command::new(env!("CARGO_BIN_EXE_codegraph-server")); + command + .args(["--graph-only", "--embedding-model", "static", "--workspace"]) + .arg(&workspace) + .args([ + "--run-tool", + "codegraph_symbol_search", + "--tool-args", + r#"{"query":"graph_only_symbol"}"#, + ]) + .env("HOME", temporary.path()) + .env("USERPROFILE", temporary.path()) + .env("CODEGRAPH_TELEMETRY", "off") + .env( + "CODEGRAPH_STATIC_MODEL", + temporary.path().join("absent-model"), + ) + .env("CODEGRAPH_SKIP_MEMORY_CHECK", "1") + .env("RUST_LOG", "info") + .stdin(Stdio::null()) + .kill_on_drop(true); + let output = tokio::time::timeout(Duration::from_secs(30), command.output()) + .await + .unwrap() + .unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "{stderr}"); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!( + result["results"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["symbol"]["name"] == "graph_only_symbol"), + "{result}" + ); + assert!(!stderr.contains("MemoryManager::initialize"), "{stderr}"); + assert!(!workspace.join(".codegraph-state/memory").exists()); + assert!(!temporary.path().join(".codegraph/fastembed_cache").exists()); +} + +#[cfg(unix)] +#[tokio::test] +async fn concurrent_relays_auto_start_one_configured_engine() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + let temporary = tempfile::Builder::new() + .prefix("codegraph-harness-") + .tempdir() + .unwrap(); + let workspace = temporary.path().join("workspace"); + std::fs::create_dir_all(workspace.join("generated")).unwrap(); + std::fs::write(workspace.join("source.rs"), "pub fn shared_symbol() {}\n").unwrap(); + std::fs::write( + workspace.join("generated/ignored.rs"), + "pub fn unwanted_symbol() {}\n", + ) + .unwrap(); + // Unix socket paths have a small length limit, especially on macOS. + let socket_dir = tempfile::tempdir().unwrap(); + let socket = socket_dir.path().join("engine.sock"); + let mut clients = Vec::new(); + for _ in 0..2 { + let child = tokio::process::Command::new(env!("CARGO_BIN_EXE_codegraph-server")) + .args(["--connect", "--socket"]) + .arg(&socket) + .arg("--workspace") + .arg(&workspace) + .args([ + "--graph-only", + "--profile", + "core", + "--exclude", + "generated", + "--max-files", + "1", + "--embedding-model", + "static", + ]) + .env("HOME", temporary.path()) + .env("USERPROFILE", temporary.path()) + .env("CODEGRAPH_TELEMETRY", "off") + .env( + "CODEGRAPH_STATIC_MODEL", + temporary.path().join("absent-model"), + ) + .env("CODEGRAPH_SKIP_MEMORY_CHECK", "1") + .env("CODEGRAPH_ENGINE_IDLE_SECS", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .unwrap(); + clients.push(child); + } + let requests = concat!( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}\n", + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"codegraph_symbol_search\",\"arguments\":{\"query\":\"symbol\"}}}\n", + ); + // Queue both clients before reading either reply, exercising concurrent attach. + for child in &mut clients { + child + .stdin + .as_mut() + .unwrap() + .write_all(requests.as_bytes()) + .await + .unwrap(); + } + for child in &mut clients { + let mut lines = BufReader::new(child.stdout.take().unwrap()).lines(); + for id in [1, 2] { + let line = tokio::time::timeout(Duration::from_secs(30), lines.next_line()) + .await + .unwrap() + .unwrap() + .unwrap(); + let response: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(response["id"], id); + assert!(response.get("error").is_none(), "{response}"); + if id == 1 { + assert_eq!(response["result"]["tools"].as_array().unwrap().len(), 8); + } else { + let result: serde_json::Value = serde_json::from_str( + response["result"]["content"][0]["text"].as_str().unwrap(), + ) + .unwrap(); + let names: Vec<_> = result["results"] + .as_array() + .unwrap() + .iter() + .map(|entry| entry["symbol"]["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, ["shared_symbol"]); + } + } + } + for child in &mut clients { + drop(child.stdin.take()); + let status = tokio::time::timeout(Duration::from_secs(10), child.wait()) + .await + .unwrap() + .unwrap(); + assert!(status.success()); + } + tokio::time::timeout(Duration::from_secs(10), async { + while socket.exists() { + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .expect("engine did not exit after its last client disconnected"); + assert!(!workspace.join(".codegraph-state/memory").exists()); + assert!(!temporary.path().join(".codegraph/fastembed_cache").exists()); +} diff --git a/mcp-package/README.md b/mcp-package/README.md index 50d2494..8490aec 100644 --- a/mcp-package/README.md +++ b/mcp-package/README.md @@ -81,6 +81,59 @@ whatever the client passes rather than forwarded twice. | `--graph-only` | off | Skip embeddings — graph + structural tools only. No ONNX model load, 10-50× faster indexing. For CI / one-shot graph queries. | | `--run-tool ` | — | One-shot: index, run a single tool, print result, exit. No MCP handshake. Pair with `--tool-args ''`. | +### Workspace filters and live updates + +Directory indexing and the MCP file watcher use the same filters: built-in +excluded directories, `--exclude`, workspace `.codegraphignore` glob patterns, +and root/nested `.gitignore` rules. Git ignore negation and directory rules are +respected; a negation cannot re-include a file beneath an excluded directory. +Global Git ignore files and `.git/info/exclude` are not read. Hidden paths, +file-size and directory-depth limits also apply to watcher events. The watcher +honors `--max-files` when adding new files while allowing edits to indexed files. + +Keep project-specific rules in the repository's `.gitignore` or +`.codegraphignore`, so every agent uses the same rules. For example, a Git +ignore entry `/cache/` prevents generated source files in that directory from +entering the graph through either initial indexing or subsequent file events. +Changes to either ignore file refresh the watcher's rules. Run +`codegraph_reindex_workspace` with `force: true` to remove previously indexed +files that now match an ignore rule, or index files that became unignored. +Explicit `codegraph_index_files` requests remain available for manual indexing. + +### Optional: one shared engine for multiple agents (Unix) + +Set `CODEGRAPH_ENGINE=1` for the `codegraph-mcp` command in each client's MCP +registration. Each client then launches a thin stdio relay; one local engine +owns a backend and watcher per workspace, shared across concurrent sessions. +The first relay starts the engine automatically. An explicit `--workspace` is +preserved; otherwise the relay uses its working directory. + +```json +{ + "mcpServers": { + "codegraph": { + "command": "codegraph-mcp", + "args": ["--graph-only", "--profile", "graph"], + "env": { "CODEGRAPH_ENGINE": "1" } + } + } +} +``` + +Auto-start forwards `--graph-only`, `--profile`, `--exclude`, `--max-files` and +embedding settings. Engine settings are shared by all clients on that socket: +the first starter determines them, and later connections do not reconfigure a +running engine. Use consistent settings, restart the engine after changing +them, or use distinct `--socket ` values for different configurations. +For centrally managed settings, start the native `codegraph-server --serve` +process explicitly with the desired flags, then connect clients to its socket. + +The default socket is `~/.codegraph/cg-engine.sock`. The engine exits after +30 minutes without clients (`CODEGRAPH_ENGINE_IDLE_SECS` overrides this). +`--graph-only` skips model and memory-manager initialization in both modes. +Without it, the engine shares one embedding model across its workspaces when +memory permits. Windows retains the regular per-session stdio mode. + ### Troubleshooting: embeddings disabled / "Memory manager not initialized" Before loading the ONNX embedding model, the server checks available memory and diff --git a/mcp-package/bin/codegraph-mcp.js b/mcp-package/bin/codegraph-mcp.js index c63b53a..fa83786 100755 --- a/mcp-package/bin/codegraph-mcp.js +++ b/mcp-package/bin/codegraph-mcp.js @@ -223,9 +223,13 @@ const USE_ENGINE = // half-configured server). const WRAPPER_OWNED_FLAGS = new Set(["--mcp", "--connect", "--stdio"]); const clientArgs = process.argv.slice(2).filter((a) => !WRAPPER_OWNED_FLAGS.has(a)); +const hasWorkspace = clientArgs.some( + (arg) => arg === "--workspace" || arg.startsWith("--workspace=") || + (arg.startsWith("-w") && !arg.startsWith("--")) +); const args = USE_ENGINE - ? ["--connect", "--workspace", process.cwd(), ...clientArgs] + ? ["--connect", ...(hasWorkspace ? [] : ["--workspace", process.cwd()]), ...clientArgs] : ["--mcp", ...clientArgs]; // stdin/stdout are inherited (JSON-RPC channel — untouched). diff --git a/mcp-package/test/wrapper-args.test.js b/mcp-package/test/wrapper-args.test.js index 267a114..9f57462 100644 --- a/mcp-package/test/wrapper-args.test.js +++ b/mcp-package/test/wrapper-args.test.js @@ -63,7 +63,7 @@ function stubEngine(dir, exitCode) { return file; } -function runWrapper(clientArgs, exitCode) { +function runWrapper(clientArgs, exitCode, engineMode = false) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cg-wrapper-")); stubEngine(dir, exitCode); try { @@ -80,6 +80,7 @@ function runWrapper(clientArgs, exitCode) { CODEGRAPH_BIN_DIR: dir, CODEGRAPH_SKIP_MODEL_FETCH: "1", CODEGRAPH_TELEMETRY: "off", + CODEGRAPH_ENGINE: engineMode ? "1" : "0", }, encoding: "utf8", timeout: 20000, @@ -131,6 +132,19 @@ if (probe.argv === null) { check(argv[0] === "--mcp", "wrapper supplies --mcp when the client omits it"); } +// --- shared mode must not override an explicit workspace with cwd ---- +if (os.platform() !== "win32") { + for (const workspaceArgs of [["--workspace", "/tmp/explicit"], ["--workspace=/tmp/explicit"], ["-w", "/tmp/explicit"], ["-w/tmp/explicit"]]) { + const clientArgs = [...workspaceArgs, "--graph-only", "--profile", "graph", "--exclude", "cache"]; + const { argv } = runWrapper(clientArgs, 0, true); + check(JSON.stringify(argv) === JSON.stringify(["--connect", ...clientArgs]), + `engine mode preserves the explicit workspace and resource flags (${workspaceArgs[0]})`); + } + const { argv } = runWrapper(["--graph-only"], 0, true); + check(JSON.stringify(argv) === JSON.stringify(["--connect", "--workspace", process.cwd(), "--graph-only"]), + "engine mode defaults to cwd only when no workspace is supplied"); +} + // --- exit 2 is explained rather than reported as a crash ------------- { try {