diff --git a/src/commands/serve.rs b/src/commands/serve.rs index a6c1731..e34c925 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -10,7 +10,13 @@ pub fn execute_serve() -> anyhow::Result<()> { let conn = crate::index::open_global_index()?; let _project_id = crate::index::ensure_project(&conn, &project_root)?; - let stats = crate::index::index_project(&conn, &project_root, false)?; + let skip_patterns = crate::index::prepare_index_config(None); + let stats = crate::index::index_project_with_skip( + &conn, + &project_root, + false, + skip_patterns.as_deref(), + )?; if stats.files_indexed > 0 { eprintln!( diff --git a/src/commands/watch.rs b/src/commands/watch.rs index c18920e..904d40b 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -38,12 +38,7 @@ pub fn run_watch( let config = crate::config::loader::load_config(config_path, None, None, None, None, false).ok(); // Same merged exclusion set as `cora index` (#521). - let skip_patterns: Option> = config.as_ref().map(|c| { - let mut pats = c.ignore.files.clone(); - pats.extend(c.rules_config.index_skip_files.iter().cloned()); - pats.dedup(); - pats - }); + let skip_patterns = crate::index::skip_patterns_from_config(config.as_ref()); // Resolve embedding backend let brain_mode = config diff --git a/src/index/graph.rs b/src/index/graph.rs index b065fbe..a810b01 100644 --- a/src/index/graph.rs +++ b/src/index/graph.rs @@ -113,12 +113,12 @@ pub fn find_callers( symbol_name: &str, limit: usize, ) -> anyhow::Result> { - let pattern = format!("%{symbol_name}%"); + let pattern = symbol_name; let mut stmt = conn.prepare( "SELECT DISTINCT cg.caller, cg.file, cg.line FROM call_graph cg - WHERE cg.callee LIKE ?1 AND cg.project_id = ?2 + WHERE cg.callee = ?1 AND cg.project_id = ?2 LIMIT ?3", )?; @@ -146,13 +146,13 @@ pub fn find_callers_cross_project( symbol_name: &str, limit: usize, ) -> anyhow::Result> { - let pattern = format!("%{symbol_name}%"); + let pattern = symbol_name; let mut stmt = conn.prepare( "SELECT DISTINCT cg.caller, cg.file, cg.line, p.root_path FROM call_graph cg JOIN projects p ON cg.project_id = p.id - WHERE cg.callee LIKE ?1 + WHERE cg.callee = ?1 LIMIT ?2", )?; @@ -178,12 +178,12 @@ pub fn find_callees( symbol_name: &str, limit: usize, ) -> anyhow::Result> { - let pattern = format!("%{symbol_name}%"); + let pattern = symbol_name; let mut stmt = conn.prepare( "SELECT DISTINCT cg.callee, cg.file, cg.line FROM call_graph cg - WHERE cg.caller LIKE ?1 AND cg.project_id = ?2 + WHERE cg.caller = ?1 AND cg.project_id = ?2 LIMIT ?3", )?; @@ -372,13 +372,13 @@ fn find_callees_edges( symbol_name: &str, limit: usize, ) -> anyhow::Result> { - let pattern = format!("%{symbol_name}%"); + let pattern = symbol_name; // Try edges table first (has typed relationships) let mut stmt = conn.prepare( "SELECT source, kind, target, file, line FROM edges - WHERE source LIKE ?1 AND project_id = ?2 + WHERE source = ?1 AND project_id = ?2 LIMIT ?3", )?; @@ -406,7 +406,7 @@ fn find_callees_edges( let mut stmt = conn.prepare( "SELECT caller, 'CALLS', callee, file, line FROM call_graph - WHERE caller LIKE ?1 AND project_id = ?2 + WHERE caller = ?1 AND project_id = ?2 LIMIT ?3", )?; @@ -436,12 +436,12 @@ fn find_callers_edges( symbol_name: &str, limit: usize, ) -> anyhow::Result> { - let pattern = format!("%{symbol_name}%"); + let pattern = symbol_name; let mut stmt = conn.prepare( "SELECT source, kind, target, file, line FROM edges - WHERE target LIKE ?1 AND project_id = ?2 + WHERE target = ?1 AND project_id = ?2 LIMIT ?3", )?; @@ -468,7 +468,7 @@ fn find_callers_edges( let mut stmt = conn.prepare( "SELECT caller, 'CALLS', callee, file, line FROM call_graph - WHERE callee LIKE ?1 AND project_id = ?2 + WHERE callee = ?1 AND project_id = ?2 LIMIT ?3", )?; @@ -1404,6 +1404,75 @@ pub fn remember_with_contradiction(content: &str) -> usize { content.len() } pub fn maintenance() -> usize { let store = Store; store.remember_with_contradiction("note") + + fn call(caller: &str, callee: &str, file: &str) -> CallEdge { + CallEdge { + caller: caller.to_string(), + callee: callee.to_string(), + file: file.to_string(), + line: 1, + } + } + + /// Regression: callers were matched with `LIKE '%name%'`, so removing + /// `run` flagged callers of `rerun`, and `_`/`%` acted as wildcards. + #[test] + fn test_find_callers_exact_match_only() { + let conn = mem_conn(); + let pid = test_project(&conn); + store_edges( + &conn, + pid, + &[ + call("a", "run", "a.rs"), + call("b", "rerun", "b.rs"), + call("c", "get_users", "c.rs"), + call("d", "getXusers", "d.rs"), + ], + ) + .unwrap(); + + let callers = find_callers(&conn, pid, "run", 100).unwrap(); + assert_eq!(callers.len(), 1); + assert_eq!(callers[0].caller, "a"); + + // `_` must not behave as a single-char wildcard. + let callers = find_callers(&conn, pid, "get_users", 100).unwrap(); + assert_eq!(callers.len(), 1); + assert_eq!(callers[0].caller, "c"); + + // `%` must not match everything. + assert!(find_callers(&conn, pid, "%", 100).unwrap().is_empty()); + assert!(find_callers_cross_project(&conn, "run", 100) + .unwrap() + .iter() + .all(|r| r.caller == "a")); + } + + /// Exact matching must not break transitive traversal. + #[test] + fn test_impact_analysis_and_trace_exact_recursion() { + let conn = mem_conn(); + let pid = test_project(&conn); + store_edges( + &conn, + pid, + &[ + call("mid", "run", "m.rs"), + call("top", "mid", "t.rs"), + call("other", "rerun", "o.rs"), + ], + ) + .unwrap(); + + let impact = impact_analysis(&conn, pid, "run", 3).unwrap(); + let names: Vec<&str> = impact.iter().map(|n| n.symbol.as_str()).collect(); + assert_eq!(names, vec!["mid", "top"]); + + let trace = trace_path(&conn, pid, "run", 3, TraceDirection::Incoming).unwrap(); + let names: Vec<&str> = trace.iter().map(|n| n.symbol.as_str()).collect(); + assert_eq!(names, vec!["mid", "top"]); + } } "#, "rs", diff --git a/src/index/mod.rs b/src/index/mod.rs index 32b520e..9579664 100644 --- a/src/index/mod.rs +++ b/src/index/mod.rs @@ -183,12 +183,11 @@ fn index_file_in_tx( tx.execute( "INSERT INTO files (path, fingerprint, last_indexed, language, symbol_count, project_id) VALUES (?1, ?2, datetime('now'), ?3, ?4, ?5) - ON CONFLICT(path) DO UPDATE SET + ON CONFLICT(project_id, path) DO UPDATE SET fingerprint = excluded.fingerprint, last_indexed = excluded.last_indexed, language = excluded.language, - symbol_count = excluded.symbol_count, - project_id = excluded.project_id", + symbol_count = excluded.symbol_count", rusqlite::params![ file_path, fingerprint, @@ -282,9 +281,38 @@ fn load_all_fingerprints( Ok(map) } -/// Index a project directory, respecting .gitignore. -/// -/// Returns summary stats. +/// Exclusion patterns for indexing = review's `ignore.files` plus +/// `index.skip_files`, so every index entry point (index, watch, serve) honors +/// the same ignores (#521). `None` when no config could be loaded. +pub fn skip_patterns_from_config( + config: Option<&crate::config::schema::Config>, +) -> Option> { + config.map(|c| { + let mut pats = c.ignore.files.clone(); + pats.extend(c.rules_config.index_skip_files.iter().cloned()); + pats.dedup(); + pats + }) +} + +/// Load config for an index run: resolves the brain embedding backend and +/// vector store, and returns the skip patterns (`None` if config failed to load). +pub fn prepare_index_config(config_path: Option<&str>) -> Option> { + let config = + crate::config::loader::load_config(config_path, None, None, None, None, false).ok(); + let brain_mode = config + .as_ref() + .map(|c| c.brain.embedding.to_string()) + .unwrap_or_else(|| "auto".to_string()); + crate::embed::resolve_backend(&brain_mode); + vector::apply_config_store(config.as_ref()); + skip_patterns_from_config(config.as_ref()) +} + +/// Index a project directory with NO skip patterns, respecting .gitignore. +/// Test-only: production entry points must go through +/// `index_project_with_skip` with the resolved config. +#[cfg(test)] pub fn index_project(conn: &Connection, root: &Path, verbose: bool) -> anyhow::Result { index_project_with_id(conn, ensure_project(conn, root)?, root, verbose, None) } @@ -359,6 +387,9 @@ fn index_project_with_id( skip_patterns: Option<&[String]>, ) -> anyhow::Result { let mut stats = IndexStats::default(); + // Every indexable file seen on disk this run (post language + skip + // filters). Anything stored for the project but absent here is stale. + let mut walked: std::collections::HashSet = std::collections::HashSet::new(); // Collect files to index let mut files_to_index: Vec<(String, String, String, String)> = Vec::new(); // (rel_str, content, language, cheap_fp) @@ -398,6 +429,7 @@ fn index_project_with_id( } stats.files_scanned += 1; + walked.insert(rel_str.clone()); // Compute mtime:size fingerprint — cheap, no file read needed. // metadata() is a stat() call, ~microseconds per file. @@ -520,6 +552,15 @@ fn index_project_with_id( ); } + // Drop rows for files deleted, gitignored, or newly excluded since the + // last run so symbols/call graph/edges never outlive their source file. + let stale: Vec = stored_fingerprints + .keys() + .filter(|p| !walked.contains(*p)) + .cloned() + .collect(); + stats.files_pruned = prune_paths(conn, project_id, &stale)?; + // Update project's last_indexed timestamp conn.execute( "UPDATE projects SET last_indexed = datetime('now') WHERE id = ?1", @@ -536,7 +577,7 @@ fn index_project_with_id( // (legacy pre-0.3.0 vecq file, vecq #542: if all files are unchanged this // would be the only chance to re-embed — without it the vector signal // stays dead until a file actually changes). - if stats.files_indexed > 0 || brain::vector_index_needs_rebuild() { + if stats.files_indexed > 0 || stats.files_pruned > 0 || brain::vector_index_needs_rebuild() { match brain::embed_project(conn, project_id) { Ok(n) => { stats.embedded_symbols = Some(n); @@ -619,44 +660,44 @@ pub fn index_stats(conn: &Connection, project_id: i64) -> anyhow::Result anyhow::Result { - let mut deleted = 0; - let mut stmt = conn.prepare("SELECT path FROM files WHERE project_id = ?1")?; let paths: Vec = stmt .query_map(rusqlite::params![project_id], |row| row.get::<_, String>(0))? .filter_map(|r| r.ok()) .collect(); - let to_prune: Vec<&String> = paths - .iter() + let to_prune: Vec = paths + .into_iter() .filter(|path| !root.join(path).exists()) .collect(); - if !to_prune.is_empty() { - let tx = conn.unchecked_transaction()?; - for path in &to_prune { - tx.execute( - "DELETE FROM symbols WHERE file = ?1 AND project_id = ?2", - rusqlite::params![path, project_id], - )?; - tx.execute( - "DELETE FROM call_graph WHERE file = ?1 AND project_id = ?2", - rusqlite::params![path, project_id], - )?; + prune_paths(conn, project_id, &to_prune) +} + +/// Delete all index data (symbols, call graph, edges, file row) for the given +/// project-relative paths in a single transaction. Returns the number of files. +fn prune_paths(conn: &Connection, project_id: i64, paths: &[String]) -> anyhow::Result { + if paths.is_empty() { + return Ok(0); + } + + let tx = conn.unchecked_transaction()?; + for path in paths { + for table in ["symbols", "call_graph", "edges"] { tx.execute( - "DELETE FROM files WHERE path = ?1 AND project_id = ?2", + &format!("DELETE FROM {table} WHERE file = ?1 AND project_id = ?2"), rusqlite::params![path, project_id], )?; } - tx.commit()?; - deleted = to_prune.len(); - } - - if deleted > 0 { - info!("Pruned {deleted} deleted files from index"); + tx.execute( + "DELETE FROM files WHERE path = ?1 AND project_id = ?2", + rusqlite::params![path, project_id], + )?; } + tx.commit()?; - Ok(deleted) + info!("Pruned {} deleted files from index", paths.len()); + Ok(paths.len()) } #[cfg(test)] @@ -675,6 +716,8 @@ pub struct IndexStats { pub files_skipped: usize, /// Files excluded by config skip patterns (ignore.files / index.skip_files). pub files_excluded: usize, + /// Stale files (deleted / ignored / newly excluded) removed from the index. + pub files_pruned: usize, pub symbols_indexed: usize, pub errors: usize, pub embedded_symbols: Option, @@ -1001,4 +1044,132 @@ pub struct AuthService { ); assert_eq!(summary.total_files, 1); } + + /// Regression: `files` was keyed by `path` alone, so two projects with + /// the same relative path overwrote each other's row (fingerprint + + /// project_id) and both re-indexed forever. + #[test] + fn test_same_relative_path_in_two_projects() { + let conn = mem_conn(); + let mut roots = Vec::new(); + for body in ["pub fn alpha() {}\n", "pub fn beta() {}\n"] { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("lib.rs"), body).unwrap(); + roots.push(tmp); + } + + for tmp in &roots { + let s = index_project(&conn, tmp.path(), false).unwrap(); + assert_eq!(s.files_indexed, 1); + } + // Both projects must now be fully up to date. + for tmp in &roots { + let s = index_project(&conn, tmp.path(), false).unwrap(); + assert_eq!(s.files_indexed, 0, "unchanged project must not re-index"); + assert_eq!(s.files_skipped, 1); + let pid = ensure_project(&conn, tmp.path()).unwrap(); + assert_eq!(index_stats(&conn, pid).unwrap().total_files, 1); + } + let rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM files WHERE path = 'lib.rs'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(rows, 2); + } + + fn count_for_file(conn: &Connection, table: &str, project_id: i64, file: &str) -> i64 { + let col = if table == "files" { "path" } else { "file" }; + conn.query_row( + &format!("SELECT COUNT(*) FROM {table} WHERE {col} = ?1 AND project_id = ?2"), + rusqlite::params![file, project_id], + |r| r.get(0), + ) + .unwrap() + } + + /// Regression: stale files were only pruned via `--prune`; a normal index + /// run must drop symbols, call_graph, edges and the file row. + #[test] + fn test_index_run_auto_prunes_deleted_files() { + let conn = mem_conn(); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write(root.join("keep.rs"), "pub fn keep() { helper(); }\n").unwrap(); + std::fs::write(root.join("gone.rs"), "pub fn gone() { helper(); }\n").unwrap(); + + index_project(&conn, root, false).unwrap(); + let pid = ensure_project(&conn, root).unwrap(); + // Seed an edge row directly so the check holds without tree-sitter. + for f in ["keep.rs", "gone.rs"] { + conn.execute( + "INSERT INTO edges (source, kind, target, file, line, project_id) + VALUES ('a', 'CALLS', 'b', ?1, 1, ?2)", + rusqlite::params![f, pid], + ) + .unwrap(); + } + assert!(count_for_file(&conn, "symbols", pid, "gone.rs") > 0); + assert!(count_for_file(&conn, "call_graph", pid, "gone.rs") > 0); + + std::fs::remove_file(root.join("gone.rs")).unwrap(); + let stats = index_project(&conn, root, false).unwrap(); + assert_eq!(stats.files_pruned, 1); + + for table in ["symbols", "call_graph", "edges", "files"] { + assert_eq!(count_for_file(&conn, table, pid, "gone.rs"), 0, "{table}"); + } + assert!(count_for_file(&conn, "symbols", pid, "keep.rs") > 0); + assert_eq!(count_for_file(&conn, "edges", pid, "keep.rs"), 1); + } + + /// `prune_deleted` (the `--prune` path) must also clear `edges`. + #[test] + fn test_prune_deleted_clears_edges() { + let conn = mem_conn(); + let pid = test_project(&conn); + index_file(&conn, pid, "gone.rs", "fn removed() {}", "rs").unwrap(); + conn.execute( + "INSERT INTO edges (source, kind, target, file, line, project_id) + VALUES ('a', 'CALLS', 'b', 'gone.rs', 1, ?1)", + [pid], + ) + .unwrap(); + let tmp = tempfile::tempdir().unwrap(); + assert_eq!(prune_deleted(&conn, pid, tmp.path()).unwrap(), 1); + assert_eq!(count_for_file(&conn, "edges", pid, "gone.rs"), 0); + } + + /// Regression: serve/watch called the no-skip entry point, undoing + /// `ignore.files` / `index.skip_files`. Patterns come from one shared + /// resolver and the skip-aware entry point must keep excluded files out. + #[test] + fn test_skip_patterns_from_config_and_exclusion() { + let mut config = crate::config::schema::Config::default(); + config.ignore.files = vec!["examples/**".to_string()]; + config.rules_config.index_skip_files = vec!["vendor/**".to_string()]; + let pats = skip_patterns_from_config(Some(&config)).unwrap(); + assert!(pats.contains(&"examples/**".to_string())); + assert!(pats.contains(&"vendor/**".to_string())); + assert!(skip_patterns_from_config(None).is_none()); + + let conn = mem_conn(); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir(root.join("examples")).unwrap(); + std::fs::write( + root.join("examples").join("demo.py"), + "def demo():\n pass\n", + ) + .unwrap(); + std::fs::write(root.join("core.py"), "def core_fn():\n pass\n").unwrap(); + + let stats = index_project_with_skip(&conn, root, false, Some(&pats)).unwrap(); + assert_eq!(stats.files_excluded, 1); + let pid = ensure_project(&conn, root).unwrap(); + assert_eq!(count_for_file(&conn, "files", pid, "examples/demo.py"), 0); + assert_eq!(count_for_file(&conn, "files", pid, "core.py"), 1); + } } diff --git a/src/index/schema.rs b/src/index/schema.rs index c5fb847..32c1ede 100644 --- a/src/index/schema.rs +++ b/src/index/schema.rs @@ -4,7 +4,7 @@ use rusqlite::Connection; /// Current schema version. #[allow(dead_code)] -const SCHEMA_VERSION: i32 = 7; +const SCHEMA_VERSION: i32 = 8; /// Run database migrations (creates tables if not exist). pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> { @@ -43,6 +43,9 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> { if current < 7 { migrate_v7(conn)?; } + if current < 8 { + migrate_v8(conn)?; + } Ok(()) } @@ -388,6 +391,42 @@ fn migrate_v7(conn: &Connection) -> anyhow::Result<()> { Ok(()) } +/// Migration v8: key `files` by `(project_id, path)` instead of `path` alone. +/// +/// `path` was a global PRIMARY KEY, so two projects with the same relative +/// path (e.g. `src/main.rs`) overwrote each other's fingerprint row and +/// `project_id`, forcing a perpetual re-index. The table is rebuilt in one +/// transaction; rows without a `project_id` (pre-v2 leftovers) are dropped +/// since they cannot be attributed and will be re-created on the next index. +fn migrate_v8(conn: &Connection) -> anyhow::Result<()> { + let tx = conn.unchecked_transaction()?; + tx.execute_batch( + " + DROP TABLE IF EXISTS files_v8; + CREATE TABLE files_v8 ( + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + path TEXT NOT NULL, + fingerprint TEXT NOT NULL, + last_indexed TEXT NOT NULL, + language TEXT NOT NULL DEFAULT 'unknown', + symbol_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (project_id, path) + ); + INSERT OR REPLACE INTO files_v8 + (project_id, path, fingerprint, last_indexed, language, symbol_count) + SELECT project_id, path, fingerprint, last_indexed, language, symbol_count + FROM files WHERE project_id IS NOT NULL; + DROP TABLE files; + ALTER TABLE files_v8 RENAME TO files; + CREATE INDEX IF NOT EXISTS idx_files_project ON files(project_id); + ", + )?; + tx.execute("INSERT INTO schema_version (version) VALUES (8)", [])?; + tx.commit()?; + + Ok(()) +} + /// Compute a stable hash of the indexing-relevant config. /// /// Any change to these fields will invalidate all stored fingerprints, @@ -686,7 +725,7 @@ mod tests { }) .unwrap(); assert_eq!(version, SCHEMA_VERSION); - assert_eq!(version, 7); + assert_eq!(version, 8); } #[test] @@ -787,4 +826,78 @@ mod tests { assert_eq!(h1, h2, "empty patterns should be deterministic"); assert!(!h1.is_empty(), "hash should not be empty"); } + + /// v8: files is keyed by (project_id, path); upgrade from a v7 database + /// preserves rows and lets two projects share a relative path. + #[test] + fn test_migrate_v8_files_keyed_by_project_and_path() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at TEXT DEFAULT (datetime('now')));", + ) + .unwrap(); + migrate_v1(&conn).unwrap(); + migrate_v2(&conn).unwrap(); + migrate_v3(&conn).unwrap(); + migrate_v4(&conn).unwrap(); + migrate_v5(&conn).unwrap(); + migrate_v6(&conn).unwrap(); + migrate_v7(&conn).unwrap(); + + let p1 = get_or_create_project(&conn, "/tmp/p1").unwrap(); + conn.execute( + "INSERT INTO files (path, fingerprint, last_indexed, project_id) + VALUES ('main.rs', 'fp1', 'now', ?1)", + [p1], + ) + .unwrap(); + // Orphan row from before project tracking: cannot be attributed. + conn.execute( + "INSERT INTO files (path, fingerprint, last_indexed) VALUES ('old.rs', 'x', 'now')", + [], + ) + .unwrap(); + + run_migrations(&conn).unwrap(); + // Idempotent re-run. + run_migrations(&conn).unwrap(); + + let version: i32 = conn + .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) + .unwrap(); + assert_eq!(version, 8); + + let p2 = get_or_create_project(&conn, "/tmp/p2").unwrap(); + conn.execute( + "INSERT INTO files (path, fingerprint, last_indexed, project_id) + VALUES ('main.rs', 'fp2', 'now', ?1)", + [p2], + ) + .unwrap(); + + let fp: String = conn + .query_row( + "SELECT fingerprint FROM files WHERE path = 'main.rs' AND project_id = ?1", + [p1], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(fp, "fp1", "migrated row must survive"); + let total: i64 = conn + .query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0)) + .unwrap(); + assert_eq!(total, 2, "orphan dropped, both project rows present"); + + // Same (project, path) twice is still a conflict. + assert!( + conn.execute( + "INSERT INTO files (path, fingerprint, last_indexed, project_id) + VALUES ('main.rs', 'dup', 'now', ?1)", + [p1], + ) + .is_err() + ); + } } diff --git a/src/main.rs b/src/main.rs index 1558085..a9f956e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -737,8 +737,13 @@ async fn main() -> Result<()> { } else if watch { // Initial index eprintln!("{}", "🔍 Initial index...".cyan()); - let stats = - index::index_project(&conn, &project_root, verbose || cli.global.verbose)?; + let skip_patterns = index::prepare_index_config(cli.global.config.as_deref()); + let stats = index::index_project_with_skip( + &conn, + &project_root, + verbose || cli.global.verbose, + skip_patterns.as_deref(), + )?; eprintln!( "{}", format!( @@ -751,7 +756,12 @@ async fn main() -> Result<()> { // Poll loop: re-index changed files every 2 seconds loop { std::thread::sleep(std::time::Duration::from_secs(2)); - let stats = index::index_project(&conn, &project_root, false)?; + let stats = index::index_project_with_skip( + &conn, + &project_root, + false, + skip_patterns.as_deref(), + )?; if stats.files_indexed > 0 { eprintln!( "{}", @@ -764,32 +774,8 @@ async fn main() -> Result<()> { } } } else { - // Load config for config-hash invalidation + brain embedding backend - let config = crate::config::loader::load_config( - cli.global.config.as_deref(), - None, - None, - None, - None, - false, - ) - .ok(); - // Exclusion patterns = review's ignore.files + index.skip_files - // so dead-code/index scanners respect the same ignores (#521). - let skip_patterns: Option> = config.as_ref().map(|c| { - let mut pats = c.ignore.files.clone(); - pats.extend(c.rules_config.index_skip_files.iter().cloned()); - pats.dedup(); - pats - }); - - // Resolve embedding backend from brain config - let brain_mode = config - .as_ref() - .map(|c| c.brain.embedding.to_string()) - .unwrap_or_else(|| "auto".to_string()); - crate::embed::resolve_backend(&brain_mode); - index::vector::apply_config_store(config.as_ref()); + // Config-hash invalidation, skip patterns, brain embedding backend. + let skip_patterns = index::prepare_index_config(cli.global.config.as_deref()); eprintln!("{}", "🔍 Indexing project...".cyan()); let stats = index::index_project_with_skip(