From 654dd3210f5d793c2b0fcd38442eb683a2465b8f Mon Sep 17 00:00:00 2001 From: sunerpy Date: Tue, 23 Jun 2026 10:55:19 +0800 Subject: [PATCH 1/2] feat(index): add styled progress bar for index and sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users had no feedback during extraction and thought it was stuck. Add an indicatif progress bar to `index` (incl. the `init` index phase) and `sync`. The bar draws to STDERR (stdout carries JSON / golden output) and counts files as they are processed: `{spinner} Indexing [{bar}] {pos}/{len} files ({elapsed}) {wide_msg}`. `--verbose` puts the current file in `{wide_msg}` instead of printing to stdout. A "Scanning files…" stage line precedes the bar; the existing summary stays. It is hidden (`ProgressBar::hidden()`) when stderr is not a TTY or `--quiet` is set, so CI logs and pipes stay clean — no `\r` spam. Progress is a pure side effect: it only counts/displays and never gates, reorders, or alters extraction, so the deterministic golden byte-equivalence is preserved (par_iter / streaming insert order unchanged). sync gains `sync_project_once_with_progress`, a per-file callback variant; the bar lives in the CLI so the watch/extract crates take no TTY dependency. indicatif is pinned to 0.17 — the major already resolved transitively via self_update — so Cargo.lock keeps a single copy. --- Cargo.lock | 22 +++++----- Cargo.toml | 3 ++ crates/codegraph-cli/Cargo.toml | 1 + crates/codegraph-cli/src/main.rs | 70 ++++++++++++++++++++++++++++-- crates/codegraph-watch/src/lib.rs | 4 +- crates/codegraph-watch/src/sync.rs | 24 ++++++++-- 6 files changed, 106 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8f93f454..25de002d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -303,7 +303,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "codegraph-bench" -version = "0.5.2" +version = "0.5.3" dependencies = [ "anyhow", "clap", @@ -318,7 +318,7 @@ dependencies = [ [[package]] name = "codegraph-core" -version = "0.5.2" +version = "0.5.3" dependencies = [ "anyhow", "serde", @@ -334,7 +334,7 @@ dependencies = [ [[package]] name = "codegraph-daemon" -version = "0.5.2" +version = "0.5.3" dependencies = [ "anyhow", "codegraph-core", @@ -351,7 +351,7 @@ dependencies = [ [[package]] name = "codegraph-extract" -version = "0.5.2" +version = "0.5.3" dependencies = [ "anyhow", "codegraph-core", @@ -391,7 +391,7 @@ dependencies = [ [[package]] name = "codegraph-graph" -version = "0.5.2" +version = "0.5.3" dependencies = [ "anyhow", "codegraph-core", @@ -403,7 +403,7 @@ dependencies = [ [[package]] name = "codegraph-mcp" -version = "0.5.2" +version = "0.5.3" dependencies = [ "anyhow", "codegraph-core", @@ -419,7 +419,7 @@ dependencies = [ [[package]] name = "codegraph-resolve" -version = "0.5.2" +version = "0.5.3" dependencies = [ "anyhow", "codegraph-core", @@ -432,7 +432,7 @@ dependencies = [ [[package]] name = "codegraph-rs" -version = "0.5.2" +version = "0.5.3" dependencies = [ "anyhow", "assert_cmd", @@ -448,6 +448,7 @@ dependencies = [ "codegraph-resolve", "codegraph-store", "codegraph-watch", + "indicatif", "jsonc-parser", "predicates", "rstest", @@ -462,7 +463,7 @@ dependencies = [ [[package]] name = "codegraph-store" -version = "0.5.2" +version = "0.5.3" dependencies = [ "anyhow", "codegraph-core", @@ -474,7 +475,7 @@ dependencies = [ [[package]] name = "codegraph-watch" -version = "0.5.2" +version = "0.5.3" dependencies = [ "anyhow", "codegraph-core", @@ -1307,6 +1308,7 @@ dependencies = [ "console", "number_prefix", "portable-atomic", + "rayon", "unicode-width", "web-time", ] diff --git a/Cargo.toml b/Cargo.toml index e6edb8ba..bad27177 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,9 @@ rustix = { version = "0.38", features = ["process"] } clap = { version = "4.5", features = ["derive"] } clap_complete = "4.5" +# Progress bars; major pinned to the one self_update resolves to avoid a duplicate copy. +indicatif = { version = "0.17", features = ["rayon"] } + # JSONC config editing (preserve comments + key order on agent config writes) jsonc-parser = { version = "0.26", features = ["cst", "serde"] } diff --git a/crates/codegraph-cli/Cargo.toml b/crates/codegraph-cli/Cargo.toml index 98c6fc91..c5adab91 100644 --- a/crates/codegraph-cli/Cargo.toml +++ b/crates/codegraph-cli/Cargo.toml @@ -33,6 +33,7 @@ toml = { workspace = true } anyhow = { workspace = true } clap = { workspace = true } clap_complete = { workspace = true } +indicatif = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } time = { workspace = true } diff --git a/crates/codegraph-cli/src/main.rs b/crates/codegraph-cli/src/main.rs index ee02ba02..70bb1e9f 100644 --- a/crates/codegraph-cli/src/main.rs +++ b/crates/codegraph-cli/src/main.rs @@ -6,7 +6,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::fs; -use std::io::{self, BufRead, BufReader, Write}; +use std::io::{self, BufRead, BufReader, IsTerminal, Write}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -24,6 +24,7 @@ use codegraph_mcp::McpServer; use codegraph_resolve::ReferenceResolver; use codegraph_store::queries::SearchResult; use codegraph_store::Store; +use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle}; use serde::Serialize; use serde_json::json; use time::format_description::well_known::Rfc3339; @@ -604,7 +605,7 @@ fn cmd_index(path: Option, force: bool, quiet: bool, verbose: bool) -> if force { remove_db_files(&project)?; } - let result = index_project(&project, true, verbose)?; + let result = index_project_inner(&project, true, verbose, quiet)?; if !quiet { print_index_result(&result); } @@ -620,7 +621,22 @@ fn cmd_sync(path: Option, quiet: bool) -> Result<()> { // sync_project_once self-discovers candidate files via scan_project, so it works // for a cold CLI invocation with no daemon. Hash-gated skip + per-file delete/reinsert // + full re-resolve makes the result equivalent to `index --force`. - let outcome = codegraph_watch::sync_project_once(&project)?; + if !quiet { + eprintln!("Scanning files…"); + } + let bar = spinner( + quiet, + "{spinner:.green} Syncing {pos}/{len} files ({elapsed})", + ); + let mut bar_len_set = false; + let outcome = codegraph_watch::sync_project_once_with_progress(&project, |done, total| { + if !bar_len_set { + bar.set_length(total as u64); + bar_len_set = true; + } + bar.set_position(done as u64); + })?; + bar.finish_and_clear(); if !quiet { println!( "Synced: {} reindexed, {} skipped (unchanged), {} removed in {}", @@ -1025,7 +1041,43 @@ struct IndexSummary { duration_ms: i64, } +// Progress is a pure side effect: it only counts/displays and never gates, +// reorders, or alters extraction, so golden byte-equivalence is preserved. It +// draws to stderr (stdout carries JSON / golden output) and is hidden when +// stderr is not a TTY or `--quiet`, so CI logs and pipes stay clean. +fn progress_bar(len: u64, quiet: bool, template: &str) -> ProgressBar { + if quiet || !io::stderr().is_terminal() { + return ProgressBar::hidden(); + } + let bar = ProgressBar::with_draw_target(Some(len), ProgressDrawTarget::stderr()); + if let Ok(style) = ProgressStyle::with_template(template) { + bar.set_style(style.progress_chars("=>-")); + } + bar +} + +fn spinner(quiet: bool, template: &str) -> ProgressBar { + if quiet || !io::stderr().is_terminal() { + return ProgressBar::hidden(); + } + let bar = ProgressBar::with_draw_target(None, ProgressDrawTarget::stderr()); + if let Ok(style) = ProgressStyle::with_template(template) { + bar.set_style(style); + } + bar.enable_steady_tick(std::time::Duration::from_millis(100)); + bar +} + fn index_project(project: &Path, clear_first: bool, verbose: bool) -> Result { + index_project_inner(project, clear_first, verbose, false) +} + +fn index_project_inner( + project: &Path, + clear_first: bool, + verbose: bool, + quiet: bool, +) -> Result { let started = std::time::Instant::now(); if clear_first { remove_db_files(project)?; @@ -1037,6 +1089,9 @@ fn index_project(project: &Path, clear_first: bool, verbose: bool) -> Result Result = Vec::with_capacity(NODE_FLUSH_ROWS); + let bar = progress_bar( + files.len() as u64, + quiet, + "{spinner:.green} Indexing [{bar:30}] {pos}/{len} files ({elapsed}) {wide_msg}", + ); for relative in files { if verbose { - println!("indexing {relative}"); + bar.set_message(relative.clone()); } let full = project.join(&relative); let source = fs::read_to_string(&full) @@ -1107,7 +1167,9 @@ fn index_project(project: &Path, clear_first: bool, verbose: bool) -> Result) -> Result { + sync_project_once_with_progress(project_root, |_, _| {}) +} + +/// Like [`sync_project_once`] but invokes `on_progress(done, total)` after each +/// candidate file is processed, letting a caller drive a progress bar. The +/// callback is a pure side effect: it never gates or reorders work, so the +/// result stays byte-equivalent to `index --force`. +pub fn sync_project_once_with_progress( + project_root: impl AsRef, + on_progress: impl FnMut(usize, usize), +) -> Result { let project_root = project_root.as_ref(); let config = codegraph_core::config::get_config(); let options = ExtractOptions { @@ -47,7 +58,7 @@ pub fn sync_project_once(project_root: impl AsRef) -> Result } } - sync_paths_with_store(&mut store, project_root, candidates, started) + sync_paths_with_store(&mut store, project_root, candidates, started, on_progress) } pub fn sync_changed_paths( @@ -59,7 +70,7 @@ pub fn sync_changed_paths( let project_root = project_root.as_ref(); let db_path = db_path.as_ref(); let mut store = Store::open(db_path).with_context(|| format!("open {}", db_path.display()))?; - sync_paths_with_store(&mut store, project_root, paths, started) + sync_paths_with_store(&mut store, project_root, paths, started, |_, _| {}) } fn sync_paths_with_store( @@ -67,6 +78,7 @@ fn sync_paths_with_store( project_root: &Path, paths: impl IntoIterator>, started: std::time::Instant, + mut on_progress: impl FnMut(usize, usize), ) -> Result { let policy = WatchPolicy::new(project_root); let mut outcome = SyncOutcome::default(); @@ -77,17 +89,22 @@ fn sync_paths_with_store( let mut reindexed = HashSet::new(); let mut changed_names = HashSet::new(); - for path in paths { + let paths = paths.into_iter().collect::>(); + let total = paths.len(); + for (done, path) in paths.into_iter().enumerate() { let Some(relative) = policy.normalize_relative(path.as_ref()) else { outcome.files_ignored += 1; + on_progress(done + 1, total); continue; }; if !seen.insert(relative.clone()) { + on_progress(done + 1, total); continue; } outcome.files_checked += 1; if !policy.should_handle_file(&relative) { outcome.files_ignored += 1; + on_progress(done + 1, total); continue; } if sync_one( @@ -101,6 +118,7 @@ fn sync_paths_with_store( changed = true; reindexed.insert(relative); } + on_progress(done + 1, total); } if changed { From e9e5cfca9491ab0972980620c84246a77e417055 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Tue, 23 Jun 2026 11:14:05 +0800 Subject: [PATCH 2/2] test(mcp): serialize CODEGRAPH_MCP_TOOLS env access to fix tools/list flake The allowlist test set_var("CODEGRAPH_MCP_TOOLS") under a LOCAL fn-scoped static Mutex, but CODEGRAPH_MCP_TOOLS is read from the process-global env in schemas.rs and cargo runs tests multi-threaded in one process. The default-surface readers (tools_list_matches_upstream_names_and_schemas and default_project_indexed_serves_full_tools_list_after_initialize, both asserting the 4-tool surface) did not share that lock, so a parallel reader could observe the temporarily-set 2-tool allowlist and fail with 'left: 2 right: 4'. Promote ENV_LOCK to a shared module-level static Mutex and have the setter plus both default-surface readers acquire it; recover from poisoning so one failing test cannot cascade-poison the rest. The setter still removes the var before any assertion, so a failed assert never leaks it. Test-only isolation fix: no product code, schemas.rs, or golden tools_list.json change; golden byte-equivalence unaffected. --- crates/codegraph-mcp/tests/golden_mcp.rs | 30 +++++++++++++++++++----- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/crates/codegraph-mcp/tests/golden_mcp.rs b/crates/codegraph-mcp/tests/golden_mcp.rs index 0cfd8679..c3c8d1b3 100644 --- a/crates/codegraph-mcp/tests/golden_mcp.rs +++ b/crates/codegraph-mcp/tests/golden_mcp.rs @@ -12,12 +12,27 @@ use std::fs; use std::io::Cursor; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, MutexGuard}; use codegraph_mcp::McpServer; use serde_json::{json, Value}; static TEMP_SEQ: AtomicU64 = AtomicU64::new(0); +/// Serializes every test that is sensitive to the PROCESS-GLOBAL +/// `CODEGRAPH_MCP_TOOLS` env var (read in `schemas.rs`). cargo runs tests +/// multi-threaded in ONE process, so the allowlist test's `set_var` would +/// otherwise race the default-surface readers (which assert the 4-tool +/// surface) and intermittently observe the 2-tool allowlist instead. Every +/// such test acquires this SHARED lock for the env-set→read window. +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +/// Acquire [`ENV_LOCK`], recovering from poisoning so one failing test does not +/// cascade-poison the rest of the suite. +fn lock_env() -> MutexGuard<'static, ()> { + ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()) +} + fn workspace_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("..") @@ -188,6 +203,7 @@ fn initialize_matches_golden() { #[test] fn tools_list_matches_upstream_names_and_schemas() { + let _env = lock_env(); let project = setup_mini_project(); let (req, golden_resp) = load_golden("tools_list"); let resp = roundtrip(project.path(), req); @@ -252,6 +268,7 @@ fn default_project_indexed_serves_full_tools_list_after_initialize() { // the server with the agent's project root as cwd and no projectPath, so the // CLI must default the project to that indexed root. With a Some(indexed) // default_project, the initialize->tools/list handshake must expose 4 tools. + let _env = lock_env(); let project = index_fixture(&[( "src/app.ts", "export function greet(name: string): string {\n return `hi ${name}`;\n}\n", @@ -322,14 +339,15 @@ fn default_project_unindexed_serves_empty_tools_list() { #[test] fn tools_list_honors_codegraph_mcp_tools_allowlist() { // CODEGRAPH_MCP_TOOLS replaces the default surface with exactly the named - // tools — any of the 8 (tools.ts:711-740). Serialized to avoid env-var - // races with other tests in the same process. - use std::sync::Mutex; - static ENV_LOCK: Mutex<()> = Mutex::new(()); - let _guard = ENV_LOCK.lock().unwrap(); + // tools — any of the 8 (tools.ts:711-740). Serialized via the shared + // ENV_LOCK to avoid env-var races with the default-surface readers in the + // same process. + let _env = lock_env(); let project = setup_mini_project(); - // SAFETY: single-threaded test section guarded by ENV_LOCK. + // SAFETY: single-threaded test section guarded by ENV_LOCK; remove_var runs + // before any assertion that could panic, so a failed assert never leaks the + // var to other tests. unsafe { std::env::set_var("CODEGRAPH_MCP_TOOLS", "impact,files") }; let resp = roundtrip( project.path(),