Files
galaxy/crates/repo_metadata/src/watcher_tests.rs
T

724 lines
28 KiB
Rust

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 virtual_fs::{Stub, VirtualFS};
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui_core::r#async::Timer;
use galaxyui_core::{App, ModelContext, ModelHandle};
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<RepositoryUpdate>,
active_tasks: Arc<AtomicUsize>,
}
impl TestSubscriber {
fn new(
scan_completed_tx: mpsc::UnboundedSender<()>,
update_completed_tx: mpsc::UnboundedSender<RepositoryUpdate>,
active_scans: Arc<AtomicUsize>,
) -> 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<Repository>,
) -> Pin<Box<dyn Future<Output = ()> + 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<Repository>,
) -> Pin<Box<dyn Future<Output = ()> + 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::<RepositoryUpdate>();
// 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::<RepositoryUpdate>();
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::<RepositoryUpdate>();
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<TaskQueue>, 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::<RepositoryUpdate>();
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(&regular_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;
});
});
}