From 95d194ab1aa3d9b7af5adc4f82970386cd0f16c5 Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Thu, 20 Aug 2026 17:32:51 +0200 Subject: [PATCH] fix(daemon): reject projectless watch roots --- src/daemon/git_watch.rs | 6 ++- src/daemon/git_watch/tests.rs | 74 +++++++++++++++++++++++++++++++++++ src/serve.rs | 41 ++++++++++++++----- 3 files changed, 110 insertions(+), 11 deletions(-) diff --git a/src/daemon/git_watch.rs b/src/daemon/git_watch.rs index 77870c7e6f..87cdf07700 100644 --- a/src/daemon/git_watch.rs +++ b/src/daemon/git_watch.rs @@ -312,10 +312,14 @@ impl GitWatcher { None => crate::global_db::GlobalDb::open().await, }; if let Some(db) = db { + let open_options = daemon_open_options(&self.inner); let projects = db.code_projects_seen_within(window, cap).await; for record in projects { let root = PathBuf::from(&record.canonical_root); - if root.is_dir() { + if root.is_dir() + && root.join(".git").exists() + && TraceDecay::has_initialized_store_with_options(&root, &open_options).await + { self.ensure_watching(&root).await; } } diff --git a/src/daemon/git_watch/tests.rs b/src/daemon/git_watch/tests.rs index 217180e810..3283d554a8 100644 --- a/src/daemon/git_watch/tests.rs +++ b/src/daemon/git_watch/tests.rs @@ -280,6 +280,80 @@ async fn disabled_watcher_never_registers() { assert!(watcher.health_report().await.is_empty()); } +#[tokio::test] +async fn spawn_skips_recent_registry_rows_without_an_initialized_store() { + let _profile = crate::config::PinnedUserDataDir::new(); + let profile_root = crate::storage::default_profile_root().unwrap(); + let global_db_path = profile_root.join("global.db"); + let global_db = crate::global_db::GlobalDb::open_at(&global_db_path) + .await + .expect("open isolated global registry"); + + let valid = temp_repo(); + crate::storage::write_enrollment_marker( + valid.path(), + &crate::storage::EnrollmentMarker { + project_id: "proj_valid_watch".to_string(), + storage_mode: crate::storage::StorageMode::ProfileSharded, + }, + ) + .expect("write valid enrollment marker"); + let layout = crate::storage::resolve_layout_for_current_profile(valid.path()) + .expect("resolve valid project layout"); + std::fs::create_dir_all(layout.graph_db_path.parent().unwrap()) + .expect("create valid graph directory"); + std::fs::write(&layout.graph_db_path, b"").expect("create valid graph marker"); + global_db + .upsert_code_project("proj_valid_watch", valid.path(), None, None, Some("main")) + .await + .expect("register valid project"); + + let invalid = tempfile::tempdir().unwrap(); + crate::storage::write_enrollment_marker( + invalid.path(), + &crate::storage::EnrollmentMarker { + project_id: "proj_invalid_watch".to_string(), + storage_mode: crate::storage::StorageMode::ProfileSharded, + }, + ) + .expect("write stale enrollment marker"); + let invalid_layout = crate::storage::resolve_layout_for_current_profile(invalid.path()) + .expect("resolve stale project layout"); + std::fs::create_dir_all(invalid_layout.graph_db_path.parent().unwrap()) + .expect("create stale graph directory"); + std::fs::write(&invalid_layout.graph_db_path, b"").expect("create stale graph marker"); + global_db + .upsert_code_project( + "proj_invalid_watch", + invalid.path(), + None, + None, + Some("main"), + ) + .await + .expect("register stale directory-only project"); + + let watcher = GitWatcher::new(fast_watch_config()); + watcher.spawn(Some(global_db_path)).await; + + let watched = watcher + .health_report() + .await + .into_iter() + .map(|(path, _)| path) + .collect::>(); + assert!( + watched.contains(&valid.path().canonicalize().unwrap()), + "an initialized registered project must still be watched" + ); + assert!( + !watched.contains(&invalid.path().canonicalize().unwrap()), + "a stale registry row for an existing non-project directory must not start a watcher" + ); + + watcher.shutdown().await; +} + #[tokio::test] async fn shutdown_cancels_and_joins_watcher_tasks() { let repo = temp_repo(); diff --git a/src/serve.rs b/src/serve.rs index 6f68746087..c6dcb69e7a 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -184,23 +184,31 @@ fn proxy_serve_handshake( let path = sanitize_serve_path_arg(path_arg); let explicit_path = path.is_some(); let mut project_path = if explicit_path { - crate::config::resolve_path(path) + Some(crate::config::resolve_path(path)) } else { - crate::config::resolve_path_with_discovery(None) + original_cwd.and_then(crate::config::discover_project_root) }; - let initialized = TraceDecay::is_initialized(&project_path); - let auto_init_root = (!initialized && crate::config::load_sync_config(&project_path).auto_init) - .then(|| crate::worktree::git_worktree_root(&project_path)) - .flatten(); + let initialized = project_path + .as_deref() + .is_some_and(TraceDecay::is_initialized); + let auto_init_candidate = project_path.as_deref().or(original_cwd); + let auto_init_root = auto_init_candidate + .filter(|candidate| !initialized && crate::config::load_sync_config(candidate).auto_init) + .and_then(crate::worktree::git_worktree_root); if let Some(root) = auto_init_root.as_ref() { - project_path.clone_from(root); + project_path = Some(root.clone()); } - let scope_prefix = serve_scope_prefix(original_cwd, &project_path); - let telemetry_timings = timings || crate::config::load_telemetry_config(&project_path).timings; + let scope_prefix = project_path + .as_deref() + .and_then(|project_path| serve_scope_prefix(original_cwd, project_path)); + let telemetry_timings = timings + || project_path + .as_deref() + .is_some_and(|path| crate::config::load_telemetry_config(path).timings); let mut handshake = crate::daemon::DaemonHandshake::for_current_client( - Some(project_path), + project_path, scope_prefix, telemetry_timings, auto_init_root.is_some(), @@ -234,6 +242,19 @@ pub const DEGRADED_SERVE_STDERR_MARKER: &str = mod tests { use super::*; + #[test] + fn projectless_cwd_does_not_become_a_daemon_project() { + let _profile = crate::config::PinnedUserDataDir::new(); + let cwd = tempfile::tempdir().unwrap(); + + let handshake = proxy_serve_handshake(None, Some(cwd.path()), false) + .expect("build projectless proxy handshake"); + + assert_eq!(handshake.project_path, None); + assert!(!handshake.allow_init); + assert!(handshake.allow_initialize_root_routing); + } + #[tokio::test] async fn direct_project_open_fails_closed() { let path = Path::new("/tmp/tracedecay-direct-open-must-not-run");