use std::fs; use std::future::Future; use std::pin::Pin; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Duration; use futures::channel::mpsc; use futures::{FutureExt as _, StreamExt as _}; use galaxy_util::standardized_path::StandardizedPath; use galaxyui_core::r#async::Timer; use galaxyui_core::{App, ModelContext, ModelHandle}; use virtual_fs::{Stub, VirtualFS}; use crate::repositories::stub_git_repository; use crate::repository::{RepositorySubscriber, TrackedRemoteRef}; use crate::watcher::{DirectoryWatcher, TaskQueue}; use crate::{CanonicalizedPath, RepoMetadataError, Repository, RepositoryUpdate}; #[test] fn test_add_repository_success() { VirtualFS::test("add_repo_success", |dirs, mut vfs| { stub_git_repository(&mut vfs, "git_repo"); let git_repo = dirs.tests().join("git_repo"); let canonical_path = StandardizedPath::from_local_canonicalized(&git_repo).unwrap(); App::test((), |mut app| async move { let watcher_handle = app.add_model(DirectoryWatcher::new); let result = watcher_handle.update(&mut app, |watcher, ctx| { watcher.add_directory(canonical_path.clone(), ctx) }); assert!(result.is_ok()); let repo_handle = result.unwrap(); // Verify the repository was registered let repo_path = repo_handle.read(&app, |repo, _ctx| repo.root_dir().clone()); assert_eq!(repo_path.to_local_path().as_deref().unwrap(), git_repo); // Verify it's in the watcher's registry watcher_handle.read(&app, |watcher, _ctx| { assert!(watcher.is_directory_watched(&canonical_path)); }); }); }); } #[test] fn test_existing_directory_registration_is_enriched_with_external_git_directory() { VirtualFS::test( "enrich_existing_directory_with_external_git_directory", |dirs, mut vfs| { vfs.mkdir("repo/.git/worktrees/worktree").mkdir("worktree"); let worktree_path = dirs.tests().join("worktree"); let external_git_dir = dirs.tests().join("repo/.git/worktrees/worktree"); let common_git_dir = dirs.tests().join("repo/.git"); App::test((), |mut app| async move { let watcher_handle = app.add_model(DirectoryWatcher::new_for_testing); let worktree_path = StandardizedPath::from_local_canonicalized(&worktree_path).unwrap(); let external_git_dir = StandardizedPath::from_local_canonicalized(&external_git_dir).unwrap(); let raw_directory_handle = watcher_handle .update(&mut app, |watcher, ctx| { watcher.add_directory(worktree_path.clone(), ctx) }) .unwrap(); raw_directory_handle.read(&app, |repository, _ctx| { assert!(repository.external_git_directory().is_none()); }); let enriched_directory_handle = watcher_handle .update(&mut app, |watcher, ctx| { watcher.add_directory_with_git_dir( worktree_path.clone(), Some(external_git_dir.clone()), ctx, ) }) .unwrap(); assert_eq!(raw_directory_handle, enriched_directory_handle); enriched_directory_handle.read(&app, |repository, _ctx| { assert_eq!(repository.external_git_directory(), Some(&external_git_dir)); assert_eq!(repository.common_git_dir(), common_git_dir); }); let reused_directory_handle = watcher_handle .update(&mut app, |watcher, ctx| { watcher.add_directory(worktree_path, ctx) }) .unwrap(); assert_eq!(enriched_directory_handle, reused_directory_handle); reused_directory_handle.read(&app, |repository, _ctx| { assert_eq!(repository.external_git_directory(), Some(&external_git_dir)); }); }); }, ); } #[test] fn test_add_repository_non_existent() { VirtualFS::test("add_repo_nonexistent", |dirs, _vfs| { let non_existent = dirs.tests().join("non_existent"); // Don't create the directory App::test((), |mut app| async move { let watcher_handle = app.add_model(DirectoryWatcher::new); let result = watcher_handle.update(&mut app, |watcher, ctx| { // This will fail because we can't canonicalize a non-existent path let canonical_result = CanonicalizedPath::try_from(&non_existent); match canonical_result { Ok(canonical_path) => watcher.add_directory(canonical_path.into(), ctx), Err(_) => Err(RepoMetadataError::RepoNotFound( "Path does not exist".to_string(), )), } }); assert!(result.is_err()); match result.unwrap_err() { RepoMetadataError::RepoNotFound(_) => {} // Expected _ => panic!("Expected RepoNotFound error"), } }); }); } /// A test subscriber that uses async channels to signal scan completion. struct TestSubscriber { scan_completed_tx: mpsc::UnboundedSender<()>, update_completed_tx: mpsc::UnboundedSender, active_tasks: Arc, } impl TestSubscriber { fn new( scan_completed_tx: mpsc::UnboundedSender<()>, update_completed_tx: mpsc::UnboundedSender, active_scans: Arc, ) -> Self { Self { scan_completed_tx, update_completed_tx, active_tasks: active_scans, } } } impl RepositorySubscriber for TestSubscriber { fn on_scan( &mut self, _repository: &Repository, _ctx: &mut ModelContext, ) -> Pin + Send + 'static>> { let scan_completed_tx = self.scan_completed_tx.clone(); let active_tasks = self.active_tasks.clone(); Box::pin(async move { let prev = active_tasks.fetch_add(1, Ordering::SeqCst); // Ensure we never exceed the allowed concurrency. assert!( prev < super::MAX_CONCURRENT_TASKS, "Started a scan while already at concurrency limit" ); // Simulate scan work. Timer::after(Duration::from_millis(10)).await; active_tasks.fetch_sub(1, Ordering::SeqCst); let _ = scan_completed_tx.unbounded_send(()); }) } fn on_files_updated( &mut self, _repository: &Repository, update: &RepositoryUpdate, _ctx: &mut ModelContext, ) -> Pin + Send + 'static>> { let update = update.clone(); let update_completed_tx = self.update_completed_tx.clone(); let active_tasks = self.active_tasks.clone(); Box::pin(async move { let prev = active_tasks.fetch_add(1, Ordering::SeqCst); // Ensure we never exceed the allowed concurrency. assert!( prev < super::MAX_CONCURRENT_TASKS, "Started an incremental update while already at concurrency limit" ); // Simulate update work. Timer::after(Duration::from_millis(10)).await; active_tasks.fetch_sub(1, Ordering::SeqCst); let _ = update_completed_tx.unbounded_send(update); }) } } #[test] #[ignore = "flaky test: CODE-1748"] fn test_task_queue_processes_all_tasks() { VirtualFS::test("task_queue_processes_all_tasks", |dirs, mut vfs| { stub_git_repository(&mut vfs, "repo1"); stub_git_repository(&mut vfs, "repo2"); let repo1 = dirs.tests().join("repo1"); let repo2 = dirs.tests().join("repo2"); App::test((), |mut app| async move { let watcher_handle = app.add_singleton_model(DirectoryWatcher::new); let repo1_handle = watcher_handle .update(&mut app, |watcher, ctx| { watcher.add_directory( StandardizedPath::from_local_canonicalized(&repo1).unwrap(), ctx, ) }) .unwrap(); let repo2_handle = watcher_handle .update(&mut app, |watcher, ctx| { watcher.add_directory( StandardizedPath::from_local_canonicalized(&repo2).unwrap(), ctx, ) }) .unwrap(); let active_tasks = Arc::new(AtomicUsize::new(0)); let (scan_tx, mut scan_rx) = mpsc::unbounded::<()>(); let (update_tx, _) = mpsc::unbounded::(); // Spawn 10 watchers per repository for _ in 0..10 { let subscriber1 = TestSubscriber::new(scan_tx.clone(), update_tx.clone(), active_tasks.clone()); let subscriber2 = TestSubscriber::new(scan_tx.clone(), update_tx.clone(), active_tasks.clone()); let start = repo1_handle.update(&mut app, |repo, ctx| { repo.start_watching(Box::new(subscriber1), ctx) }); start .registration_future .await .expect("Failed to add subscriber"); let start = repo2_handle.update(&mut app, |repo, ctx| { repo.start_watching(Box::new(subscriber2), ctx) }); start .registration_future .await .expect("Failed to add subscriber"); } // Wait for all scans to complete by consuming all messages for _ in 0..20 { scan_rx.next().await.expect("Scan should complete"); } // Ensure no active scans remain assert_eq!( active_tasks.load(Ordering::SeqCst), 0, "Scans not completed" ); let queue = watcher_handle.read(&app, |watcher, _| watcher.processing_queue.clone()); wait_for_queue_complete(queue, &mut app).await; }); }); } #[test] fn test_scan_queue_handles_nonexistent_subscriber() { VirtualFS::test("scan_queue_nonexistent_subscriber", |dirs, mut vfs| { // Create a git repository stub_git_repository(&mut vfs, "repo"); let repo_path = dirs.tests().join("repo"); App::test((), |mut app| async move { let watcher_handle = app.add_singleton_model(DirectoryWatcher::new); // Create repository let repo_handle = watcher_handle .update(&mut app, |watcher, ctx| { watcher.add_directory( StandardizedPath::from_local_canonicalized(&repo_path).unwrap(), ctx, ) }) .unwrap(); // Manually enqueue a scan request for a non-existent subscriber watcher_handle.update(&mut app, |watcher, ctx| { watcher.processing_queue.update(ctx, |queue, ctx| { queue.enqueue_scan(repo_handle.clone().downgrade(), 999, ctx); // Non-existent subscriber ID }); }); // Add a real subscriber to test that the queue continues processing let (tx, mut rx) = mpsc::unbounded::<()>(); let (update_tx, _) = mpsc::unbounded::(); let active_scans = Arc::new(AtomicUsize::new(0)); let subscriber = TestSubscriber::new(tx, update_tx, active_scans.clone()); std::mem::drop(repo_handle.update(&mut app, |repo, ctx| { repo.start_watching(Box::new(subscriber), ctx) })); // Wait for processing to complete rx.next().await.unwrap(); // Verify the real subscriber's scan completed despite the invalid scan request assert_eq!( active_scans.load(Ordering::SeqCst), 0, "Real subscriber scan should complete" ); let queue = watcher_handle.read(&app, |watcher, _| watcher.processing_queue.clone()); wait_for_queue_complete(queue, &mut app).await; }); }); } #[test] #[ignore = "CODE-1071 - test is flaky and needs to be fixed"] fn test_file_updates_delivered() { env_logger::init(); VirtualFS::test("file_updates_delivered", |dirs, mut vfs| { stub_git_repository(&mut vfs, "test_repo"); vfs.with_files(vec![ Stub::FileWithContent("test_repo/file1.txt", "content1"), Stub::FileWithContent("test_repo/file2.txt", "content2"), ]); let repo_path = dirs.tests().join("test_repo"); App::test((), |mut app| async move { let watcher_handle = app.add_singleton_model(DirectoryWatcher::new); let repo_handle = watcher_handle .update(&mut app, |watcher, ctx| { watcher.add_directory( StandardizedPath::from_local_canonicalized(&repo_path).unwrap(), ctx, ) }) .unwrap(); let task_count = Arc::new(AtomicUsize::new(0)); let (scan_tx, mut scan_rx) = mpsc::unbounded::<()>(); let (update_tx, mut update_rx) = mpsc::unbounded::(); let subscriber_count = 3; for _ in 0..subscriber_count { let subscriber = TestSubscriber::new(scan_tx.clone(), update_tx.clone(), task_count.clone()); let start = repo_handle.update(&mut app, |repo, ctx| { repo.start_watching(Box::new(subscriber), ctx) }); start .registration_future .await .expect("Failed to add subscriber"); } // Wait for all initial scans to complete. for _ in 0..subscriber_count { scan_rx.next().await.expect("Scan should complete"); } let file1_path = repo_path.join("file1.txt"); let file2_path = repo_path.join("file2.txt"); fs::write(&file1_path, "new content").expect("Updating file 1 failed"); fs::write(&file2_path, "new content").expect("Updating file 2 failed"); // Ensure that all subscribers receive the updates. for _ in 0..subscriber_count { let update = update_rx.next().await.expect("Update should complete"); let mut changed: Vec<_> = update.into_added_or_modified().map(|tf| tf.path).collect(); changed.sort(); assert_eq!(changed, vec![file1_path.clone(), file2_path.clone()]); } let queue = watcher_handle.read(&app, |watcher, _| watcher.processing_queue.clone()); wait_for_queue_complete(queue, &mut app).await; }); }); } /// Wait for a task queue to have finished all its tasks. This will not directly advance the queue, but allows the UI framework to mark async tasks as complete. async fn wait_for_queue_complete(queue: ModelHandle, app: &mut App) { const DELAY: Duration = Duration::from_millis(5); const ATTEMPTS: u32 = 10; for _ in 0..ATTEMPTS { let complete = queue.update(app, |queue, _| { queue.active_tasks == 0 && queue.pending_tasks.is_empty() }); if complete { return; } Timer::after(DELAY).await; } queue.read(app, |queue, _| { panic!( "Queue not empty after {:?}: {} pending tasks, {} active", DELAY * ATTEMPTS, queue.pending_tasks.len(), queue.active_tasks ); }) } #[test] fn test_is_git_internal_path() { use std::path::Path; use crate::entry::is_git_internal_path; // .git/ internal paths should be detected assert!(is_git_internal_path(Path::new("/repo/.git/HEAD"))); assert!(is_git_internal_path(Path::new("/repo/.git/index"))); assert!(is_git_internal_path(Path::new( "/repo/.git/refs/heads/main" ))); assert!(is_git_internal_path(Path::new("/repo/.git/config"))); assert!(is_git_internal_path(Path::new("/repo/.git/objects/abc123"))); assert!(is_git_internal_path(Path::new("/repo/.git"))); #[cfg(windows)] assert!(is_git_internal_path(Path::new(r"C:\repo\.git\HEAD"))); // Non-git files should not be detected assert!(!is_git_internal_path(Path::new("/repo/src/main.rs"))); assert!(!is_git_internal_path(Path::new("/repo/README.md"))); } #[test] fn test_remote_tracking_ref_routes_only_to_repos_tracking_that_ref() { VirtualFS::test("remote_tracking_ref_routes", |dirs, mut vfs| { stub_git_repository(&mut vfs, "repo"); vfs.mkdir("repo/.git/worktrees"); vfs.mkdir("repo/.git/worktrees/wt"); vfs.mkdir("repo/.git/refs/remotes"); vfs.mkdir("repo/.git/refs/remotes/origin"); vfs.mkdir("wt"); vfs.with_files(vec![ Stub::FileWithContent("repo/.git/refs/remotes/origin/main", "abc123"), Stub::FileWithContent("repo/.git/refs/remotes/origin/feature", "def456"), Stub::FileWithContent("repo/.git/worktrees/wt/HEAD", "ref: refs/heads/feature"), ]); let repo_path = dirs.tests().join("repo"); let worktree_path = dirs.tests().join("wt"); let external_git_dir = dirs.tests().join("repo/.git/worktrees/wt"); let main_remote_ref_path = dirs.tests().join("repo/.git/refs/remotes/origin/main"); let feature_remote_ref_path = dirs.tests().join("repo/.git/refs/remotes/origin/feature"); App::test((), |mut app| async move { let watcher_handle = app.add_singleton_model(DirectoryWatcher::new_for_testing); let main_repo_handle = watcher_handle .update(&mut app, |watcher, ctx| { watcher.add_directory( StandardizedPath::from_local_canonicalized(&repo_path).unwrap(), ctx, ) }) .unwrap(); let worktree_repo_handle = watcher_handle .update(&mut app, |watcher, ctx| { watcher.add_directory_with_git_dir( StandardizedPath::from_local_canonicalized(&worktree_path).unwrap(), Some( StandardizedPath::from_local_canonicalized(&external_git_dir).unwrap(), ), ctx, ) }) .unwrap(); main_repo_handle.update(&mut app, |repo, _| { repo.update_tracked_remote_ref(TrackedRemoteRef::from_full_ref_name( "refs/remotes/origin/main", )); }); worktree_repo_handle.update(&mut app, |repo, _| { repo.update_tracked_remote_ref(TrackedRemoteRef::from_full_ref_name( "refs/remotes/origin/feature", )); }); let main_affected = watcher_handle.update(&mut app, |watcher, ctx| { watcher.find_repos_for_git_event(&main_remote_ref_path, ctx) }); assert_eq!(main_affected, vec![main_repo_handle.clone()]); let feature_affected = watcher_handle.update(&mut app, |watcher, ctx| { watcher.find_repos_for_git_event(&feature_remote_ref_path, ctx) }); assert_eq!(feature_affected, vec![worktree_repo_handle]); }); }); } #[test] fn test_common_config_routes_to_repos_sharing_common_git_dir() { VirtualFS::test("common_config_routes", |dirs, mut vfs| { stub_git_repository(&mut vfs, "repo"); vfs.mkdir("repo/.git/worktrees"); vfs.mkdir("repo/.git/worktrees/wt"); vfs.mkdir("wt"); vfs.with_files(vec![Stub::FileWithContent( "repo/.git/worktrees/wt/HEAD", "ref: refs/heads/feature", )]); let repo_path = dirs.tests().join("repo"); let worktree_path = dirs.tests().join("wt"); let external_git_dir = dirs.tests().join("repo/.git/worktrees/wt"); let common_config_path = dirs.tests().join("repo/.git/config"); App::test((), |mut app| async move { let watcher_handle = app.add_singleton_model(DirectoryWatcher::new_for_testing); let main_repo_handle = watcher_handle .update(&mut app, |watcher, ctx| { watcher.add_directory( StandardizedPath::from_local_canonicalized(&repo_path).unwrap(), ctx, ) }) .unwrap(); let worktree_repo_handle = watcher_handle .update(&mut app, |watcher, ctx| { watcher.add_directory_with_git_dir( StandardizedPath::from_local_canonicalized(&worktree_path).unwrap(), Some( StandardizedPath::from_local_canonicalized(&external_git_dir).unwrap(), ), ctx, ) }) .unwrap(); let affected = watcher_handle.update(&mut app, |watcher, ctx| { watcher.find_repos_for_git_event(&common_config_path, ctx) }); assert_eq!(affected.len(), 2); assert!(affected.contains(&main_repo_handle)); assert!(affected.contains(&worktree_repo_handle)); }); }); } #[test] fn test_commit_related_files_excluded_from_update_lists() { VirtualFS::test("commit_files_excluded", |dirs, mut vfs| { log::info!("Start setting up test vfs"); stub_git_repository(&mut vfs, "test_repo"); // Create .git/refs/heads directory structure vfs.mkdir("test_repo/.git/refs/heads"); vfs.with_files(vec![ Stub::FileWithContent("test_repo/.git/HEAD", "ref: refs/heads/main"), Stub::FileWithContent("test_repo/.git/refs/heads/main", "abc123def456"), Stub::FileWithContent("test_repo/regular_file.txt", "content"), ]); let repo_path = dirs.tests().join("test_repo"); App::test((), |mut app| async move { let watcher_handle = app.add_singleton_model(DirectoryWatcher::new); let repo_handle = watcher_handle .update(&mut app, |watcher, ctx| { watcher.add_directory( StandardizedPath::from_local_canonicalized(&repo_path).unwrap(), ctx, ) }) .unwrap(); let (scan_tx, mut scan_rx) = mpsc::unbounded::<()>(); let (update_tx, mut update_rx) = mpsc::unbounded::(); let active_scans = Arc::new(AtomicUsize::new(0)); let subscriber = TestSubscriber::new(scan_tx.clone(), update_tx.clone(), active_scans.clone()); log::info!("Start setting up watcher"); let start = repo_handle.update(&mut app, |repo, ctx| { repo.start_watching(Box::new(subscriber), ctx) }); start .registration_future .await .expect("Failed to add subscriber"); log::info!("Finished setting up watcher"); // Wait for initial scan to complete futures::select! { scan = scan_rx.next().fuse() => { scan.expect("Scan channel closed while waiting for initial repository scan"); } _ = futures::FutureExt::fuse(Timer::after(Duration::from_secs(5))) => { panic!("Timed out waiting for initial repository scan"); } } log::info!("Initial scan completed"); // Update both a regular file and a commit-related file let regular_file_path = repo_path.join("regular_file.txt"); let head_file_path = repo_path.join(".git/HEAD"); let branch_file_path = repo_path.join(".git/refs/heads/main"); std::fs::write(®ular_file_path, "new content") .expect("Updating regular file failed"); std::fs::write(&head_file_path, "ref: refs/heads/feature") .expect("Updating HEAD failed"); std::fs::write(&branch_file_path, "def456abc123").expect("Updating branch ref failed"); log::info!("Wrote files: regular_file.txt, .git/HEAD, .git/refs/heads/main"); let update_timeout = Duration::from_secs(5); let timeout = futures::FutureExt::fuse(Timer::after(update_timeout)); futures::pin_mut!(timeout); let mut updates = Vec::new(); loop { if updates .iter() .any(|update: &RepositoryUpdate| update.commit_updated) && updates.iter().any(|update| { update .added_or_modified() .any(|file| file.path == regular_file_path) }) { break; } futures::select! { update = update_rx.next().fuse() => { match update { Some(update) => { log::info!("Received update: {update:?}"); updates.push(update); } None => { panic!( "Update channel closed while waiting for watcher updates after modifying regular_file.txt, .git/HEAD, and .git/refs/heads/main. Received {} update(s): {updates:#?}", updates.len() ); } } } _ = timeout => { panic!( "Timed out after {update_timeout:?} waiting for watcher updates after modifying regular_file.txt, .git/HEAD, and .git/refs/heads/main. Expected at least one update with commit_updated=true and one update containing {}. Received {} update(s): {updates:#?}", regular_file_path.display(), updates.len() ); } } } // Verify that commit_updated is true assert!( updates.iter().any(|update| update.commit_updated), "commit_updated should be true when git commit files change" ); // Verify that git files are NOT in the added list, but regular files are assert!( updates.iter().any(|update| update .added_or_modified() .any(|file| file.path == regular_file_path)), "Regular file should be in added/modified list" ); assert!( updates.iter().all(|update| update .added_or_modified() .all(|file| file.path != head_file_path)), "Git HEAD file should NOT be in added/modified list" ); assert!( updates.iter().all(|update| update .added_or_modified() .all(|file| file.path != branch_file_path)), "Git branch ref file should NOT be in added/modified list" ); // The update should not be considered empty due to commit_updated being true assert!( updates .iter() .any(|update| update.commit_updated && !update.is_empty()), "Update should not be empty when commit_updated is true" ); let queue = watcher_handle.read(&app, |watcher, _| watcher.processing_queue.clone()); wait_for_queue_complete(queue, &mut app).await; }); }); }