use std::{ collections::{hash_map::Entry, HashMap, HashSet, VecDeque}, future::Future, hash::{Hash, Hasher}, path::{Path, PathBuf}, pin::Pin, }; #[cfg(feature = "local_fs")] use futures::{future::OptionFuture, FutureExt as _}; use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity, WeakModelHandle}; use warp_util::standardized_path::StandardizedPath; use crate::{repository::SubscriberId, RepoMetadataError, Repository}; cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { use watcher::{BulkFilesystemWatcher, BulkFilesystemWatcherEvent}; use crate::entry::{ extract_worktree_git_dir, is_commit_related_git_file, is_git_internal_path, is_index_lock_file, is_shared_git_ref, }; /// Duration between filesystem watch events in milliseconds const FILESYSTEM_WATCHER_DEBOUNCE_MILLI_SECS: u64 = 500; } } const MAX_CONCURRENT_TASKS: usize = 2; /// A global singleton model that records and watches directory changes. /// It is important to note that the directory here doesn't equal to a git repository. To /// reference a whether a path is a git repository or not, check `DetectedRepositories`. pub struct DirectoryWatcher { /// Map of known directories to watch. directories: HashMap>, /// The filesystem watcher for monitoring changes. #[cfg(feature = "local_fs")] watcher: Option>, /// Handle to the internal processing queue model that orders scan & update tasks. processing_queue: ModelHandle, } impl DirectoryWatcher { pub fn new(ctx: &mut ModelContext) -> Self { cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { let fs_watcher = ctx.add_model(|ctx| { BulkFilesystemWatcher::new( std::time::Duration::from_millis(FILESYSTEM_WATCHER_DEBOUNCE_MILLI_SECS), ctx, ) }); ctx.subscribe_to_model(&fs_watcher, Self::handle_watcher_event); } else { // Silence an unused parameter warning. let _ = ctx; } } let processing_queue = ctx.add_model(TaskQueue::new); ctx.subscribe_to_model(&processing_queue, Self::handle_queue_event); Self { directories: Default::default(), #[cfg(feature = "local_fs")] watcher: Some(fs_watcher), processing_queue, } } /// Test-only constructor that uses a stub filesystem watcher with no background thread, /// preventing thread leaks in tests. #[cfg(any(test, feature = "test-util"))] pub fn new_for_testing(ctx: &mut ModelContext) -> Self { cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { let fs_watcher = ctx.add_model(|_ctx| BulkFilesystemWatcher::new_for_test()); ctx.subscribe_to_model(&fs_watcher, Self::handle_watcher_event); } else { let _ = ctx; } } let processing_queue = ctx.add_model(TaskQueue::new); ctx.subscribe_to_model(&processing_queue, Self::handle_queue_event); Self { directories: Default::default(), #[cfg(feature = "local_fs")] watcher: Some(fs_watcher), processing_queue, } } /// Given a path, return the watched directory that contains it. pub fn get_watched_directory_for_path(&self, path: &Path) -> Option> { let standardized = StandardizedPath::from_local_canonicalized(path).ok()?; self.find_containing_directory(&standardized) } /// Find the watched directory that contains the given path, if any. fn find_containing_directory( &self, path: &StandardizedPath, ) -> Option> { let mut current = Some(path.clone()); while let Some(ancestor) = current { if let Some(repo) = self.directories.get(&ancestor) { return Some(repo.clone()); } current = ancestor.parent(); } None } /// Check if a directory is registered for the given path. pub fn is_directory_watched(&self, path: &StandardizedPath) -> bool { self.directories.contains_key(path) } /// Find repositories affected by a git directory change using three-tier /// scope-aware routing: /// /// 1. **Worktree-specific** (`.git/worktrees//…`): only the repo /// whose `external_git_directory` matches the extracted worktree gitdir. /// 2. **Shared refs** (`.git/refs/heads/*`): all repos whose /// `common_git_dir()` is a prefix of the event path (main repo + /// all linked worktrees). /// 3. **Repo-specific** (`.git/HEAD`, `.git/index.lock`, etc.): only the /// repo whose working tree directly contains `.git` (main repo). #[cfg(feature = "local_fs")] fn find_repos_for_git_event( &self, git_path: &Path, ctx: &ModelContext, ) -> Vec> { let mut affected: Vec> = Vec::new(); // Tier 1: route to the single linked worktree. if let Some(wt_dir) = extract_worktree_git_dir(git_path) { log::debug!( "[GIT_EVENT_ROUTING] tier=worktree-specific path={}", git_path.display() ); let wt_std = StandardizedPath::from_local_canonicalized(wt_dir.as_path()).ok(); for repo_handle in self.directories.values() { if let Some(ext) = repo_handle.as_ref(ctx).external_git_directory() { if wt_std.as_ref() == Some(ext) && !affected.iter().any(|r| r == repo_handle) { affected.push(repo_handle.clone()); } } } } else if is_shared_git_ref(git_path) { // Tier 2: shared ref — broadcast to every repo whose // common_git_dir() is a prefix of the event path. log::debug!( "[GIT_EVENT_ROUTING] tier=shared-ref path={}", git_path.display() ); let standardized = StandardizedPath::from_local_canonicalized(git_path).ok(); if let Some(ref std_path) = standardized { if let Some(repo) = self.find_containing_directory(std_path) { if !affected.iter().any(|r| r == &repo) { affected.push(repo); } } for repo_handle in self.directories.values() { let common = repo_handle.read(ctx, |repo, _| repo.common_git_dir()); if let Ok(common_std) = StandardizedPath::try_from_local(common.as_path()) { if std_path.starts_with(&common_std) && !affected.iter().any(|r| r == repo_handle) { affected.push(repo_handle.clone()); } } } } } else { // Tier 3: repo-specific (.git/HEAD, .git/index.lock) — only the // repo whose root_dir directly contains .git. log::debug!( "[GIT_EVENT_ROUTING] tier=repo-specific path={}", git_path.display() ); let standardized = StandardizedPath::from_local_canonicalized(git_path).ok(); if let Some(ref std_path) = standardized { if let Some(repo) = self.find_containing_directory(std_path) { affected.push(repo); } } } let repo_roots: Vec<_> = affected .iter() .map(|r| r.as_ref(ctx).root_dir().to_string()) .collect(); log::debug!( "[GIT_EVENT_ROUTING] path={} affected_repos=[{}]", git_path.display(), repo_roots.join(", ") ); affected } /// Register a known code directory. If the directory already exists, it will not be re-registered. pub fn add_directory( &mut self, repository_path: StandardizedPath, ctx: &mut ModelContext, ) -> Result, RepoMetadataError> { self.add_directory_with_git_dir(repository_path, None, ctx) } /// Register a known code directory with optional external git directory. /// If the directory already exists, it will not be re-registered. pub fn add_directory_with_git_dir( &mut self, repository_path: StandardizedPath, external_git_directory: Option, ctx: &mut ModelContext, ) -> Result, RepoMetadataError> { let local_path = repository_path .to_local_path() .ok_or_else(|| RepoMetadataError::PathEncodingMismatch(repository_path.clone()))?; if !local_path.exists() { return Err(RepoMetadataError::RepoNotFound(repository_path.to_string())); } if !local_path.is_dir() { return Err(RepoMetadataError::InvalidPath( "Repository path must be a directory".to_string(), )); } // Check if there's an existing registration to reuse. let entry = self.directories.entry(repository_path); if let Entry::Occupied(ref entry) = entry { log::debug!("Using already-registered repository"); return Ok(entry.get().clone()); } // The repository is either not registered, or has expired. let queue_handle = self.processing_queue.clone(); let repository_handle = ctx.add_model(|_ctx| { Repository::new( entry.key().clone(), external_git_directory.clone(), queue_handle, ) }); entry.insert_entry(repository_handle.clone()); Ok(repository_handle) } /// Starts watching multiple directories for filesystem changes. /// /// The returned future resolves once all directories are registered. #[cfg(feature = "local_fs")] pub(crate) fn start_watching_directories( &mut self, directory_paths: Vec, ctx: &mut ModelContext, ) -> impl Future> { let futures: Vec<_> = directory_paths .into_iter() .map(|path| self.start_watching_directory(&path, ctx)) .collect(); async move { for future in futures { future.await?; } Ok(()) } } /// Starts watching a directory for filesystem changes. /// /// The returned future resolves once the directory is registered. Filesystem changes before /// this may not be observed. #[cfg(feature = "local_fs")] pub(crate) fn start_watching_directory( &mut self, directory_path: &StandardizedPath, ctx: &mut ModelContext, ) -> impl Future> { let local_path = directory_path.to_local_path(); let registration_future = if let Some(ref watcher) = self.watcher { if let Some(local_path) = local_path.clone() { watcher.update(ctx, |watcher, _ctx| { use crate::entry::should_ignore_git_path; use notify_debouncer_full::notify::{RecursiveMode, WatchFilter}; use std::sync::Arc; let watch_filter = WatchFilter::with_filter(Arc::new(move |watch_path| { !should_ignore_git_path(watch_path) })); Some(watcher.register_path(&local_path, watch_filter, RecursiveMode::Recursive)) }) } else { log::warn!("Cannot watch non-local path: {directory_path}"); None } } else { log::warn!("No watcher available"); None }; let path_display = directory_path.to_string(); OptionFuture::from(registration_future).map(move |result| match result { Some(Ok(())) => { log::debug!("Started watching {path_display}"); Ok(()) } Some(Err(e)) => { log::debug!("Failed to start watching {path_display}: {e:#}"); Err(e.into()) } None => Ok(()), }) } /// Stops watching a directory for filesystem changes. #[cfg(feature = "local_fs")] pub(crate) fn stop_watching_directory( &mut self, directory_path: &StandardizedPath, ctx: &mut ModelContext, ) -> impl Future> { cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { let local_path = directory_path.to_local_path(); let unregistration_future = if let Some(ref watcher) = self.watcher { if let Some(local_path) = local_path { watcher.update(ctx, |watcher, _ctx| { Some(watcher.unregister_path(&local_path)) }) } else { log::warn!("Cannot unwatch non-local path: {directory_path}"); None } } else { log::warn!("No watcher available"); None }; let path_display = directory_path.to_string(); OptionFuture::from(unregistration_future).map(move |result| match result { Some(Ok(())) => { log::debug!("Stopped watching {path_display}"); Ok(()) } Some(Err(e)) => { log::warn!("Failed to stop watching {path_display}: {e:#}"); Err(e) } None => Ok(()), }) } else { async { Ok(()) } } } } /// Handles events from the internal task queue. fn handle_queue_event(&mut self, event: &TaskQueueEvent, ctx: &mut ModelContext) { let &TaskQueueEvent::TaskEnqueued = event; self.processing_queue.update(ctx, |queue, ctx| { queue.advance(ctx); }); } #[cfg(feature = "local_fs")] fn find_existing_subpath(path: &PathBuf) -> Option { // Attempt to find a subdirectory that exists in the filesystem. let mut current = path.to_owned(); while !current.as_path().exists() { if !current.pop() { return None; } } Some(current) } /// Handles filesystem watcher events. #[cfg(feature = "local_fs")] fn handle_watcher_event( &mut self, event: &BulkFilesystemWatcherEvent, ctx: &mut ModelContext, ) { // Group changes by repository let mut repo_updates: HashMap, RepositoryUpdate> = HashMap::new(); let mut process_upsert_paths = |paths: &HashSet, insert: &mut dyn FnMut( &mut RepositoryUpdate, TargetFile, )| { for path in paths { // Check if this is a .git/ internal event (e.g. HEAD, index, refs update). if is_git_internal_path(path) { let affected = self.find_repos_for_git_event(path, ctx); for repo_handle in &affected { let repo_update = repo_updates.entry(repo_handle.clone()).or_default(); if is_commit_related_git_file(path) { repo_update.commit_updated = true; } if is_index_lock_file(path) { repo_update.index_lock_detected = true; } } if !affected.is_empty() { let is_commit = is_commit_related_git_file(path); let is_lock = is_index_lock_file(path); log::debug!( "[GIT_EVENT_ROUTING] dispatched path={} commit_updated={is_commit} index_lock={is_lock} to {} repo(s)", path.display(), affected.len() ); } continue; } // For non-git files, use standard path lookup if let Ok(standardized) = StandardizedPath::from_local_canonicalized(path.as_path()) { if let Some(repo_handle) = self.find_containing_directory(&standardized) { let is_ignored = repo_handle.read(ctx, |repo, _| repo.check_gitignore_status(path)); let target_file = TargetFile::new(path.to_path_buf(), is_ignored); let repo_update = repo_updates.entry(repo_handle).or_default(); insert(repo_update, target_file); } } } }; // Process added files process_upsert_paths(&event.added, &mut |repo_update, target_file| { repo_update.added.insert(target_file); }); // Process modified files process_upsert_paths(&event.modified, &mut |repo_update, target_file| { if !repo_update.added.contains(&target_file) { repo_update.modified.insert(target_file); } }); // Process deleted files for path in &event.deleted { // Check if this is a .git/ internal event. if is_git_internal_path(path) { let affected = self.find_repos_for_git_event(path, ctx); for repo_handle in affected { let repo_update = repo_updates.entry(repo_handle).or_default(); if is_commit_related_git_file(path) { repo_update.commit_updated = true; } if is_index_lock_file(path) { repo_update.index_lock_detected = true; } } } else { // Because this file will no longer exist, which will fail canonicalization. // We will just try the directory path instead, which hopefully still exists. if let Some(existing_subpath) = Self::find_existing_subpath(path) { if let Ok(standardized) = StandardizedPath::from_local_canonicalized(existing_subpath.as_path()) { if let Some(repo_handle) = self.find_containing_directory(&standardized) { // Gitignore checking is pattern-based and doesn't require file existence let is_ignored = repo_handle.read(ctx, |repo, _| repo.check_gitignore_status(path)); let target_file = TargetFile::new(path.to_path_buf(), is_ignored); let repo_update = repo_updates.entry(repo_handle).or_default(); repo_update.deleted.insert(target_file); } } } } } // Process moved files for (to_path, from_path) in &event.moved { // Check if this is a .git/ internal event. if is_git_internal_path(to_path) || is_git_internal_path(from_path) { // Merge affected repos from both paths let mut affected = self.find_repos_for_git_event(to_path, ctx); for repo in self.find_repos_for_git_event(from_path, ctx) { if !affected.iter().any(|r| r == &repo) { affected.push(repo); } } let paths = [to_path.as_path(), from_path.as_path()]; for repo_handle in affected { let repo_update = repo_updates.entry(repo_handle).or_default(); for p in &paths { if is_commit_related_git_file(p) { repo_update.commit_updated = true; } if is_index_lock_file(p) { repo_update.index_lock_detected = true; } } } } else if let Ok(standardized) = StandardizedPath::from_local_canonicalized(to_path.as_path()) { if let Some(repo_handle) = self.find_containing_directory(&standardized) { let to_is_ignored = repo_handle.read(ctx, |repo, _| repo.check_gitignore_status(to_path)); let from_is_ignored = repo_handle.read(ctx, |repo, _| repo.check_gitignore_status(from_path)); let to_target = TargetFile::new(to_path.to_path_buf(), to_is_ignored); let from_target = TargetFile::new(from_path.to_path_buf(), from_is_ignored); let repo_update = repo_updates.entry(repo_handle).or_default(); repo_update.moved.insert(to_target, from_target); } } } self.processing_queue.update(ctx, |queue, ctx| { for (repo_handle, repo_update) in repo_updates { let subscriber_ids = repo_handle.read(ctx, |repo, _| repo.get_subscriber_ids()); for subscriber_id in subscriber_ids { queue.enqueue_incremental_update( repo_handle.downgrade(), subscriber_id, repo_update.clone(), ctx, ); } } }); } } impl Entity for DirectoryWatcher { type Event = (); } impl SingletonEntity for DirectoryWatcher {} /// Represents a file in a repository with its gitignore status. #[derive(Debug, Clone)] pub struct TargetFile { pub path: PathBuf, pub is_ignored: bool, } impl TargetFile { pub fn new(path: PathBuf, is_ignored: bool) -> Self { Self { path, is_ignored } } } impl Hash for TargetFile { fn hash(&self, state: &mut H) { self.path.hash(state); self.is_ignored.hash(state); } } impl PartialEq for TargetFile { fn eq(&self, other: &Self) -> bool { self.path == other.path && self.is_ignored == other.is_ignored } } impl Eq for TargetFile {} impl PartialOrd for TargetFile { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for TargetFile { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.path .cmp(&other.path) .then_with(|| self.is_ignored.cmp(&other.is_ignored)) } } /// Changes detected in a repository. #[derive(Debug, Clone, Default)] pub struct RepositoryUpdate { /// Files that were added. pub added: HashSet, /// Files whose contents were modified. pub modified: HashSet, /// Files that were deleted. pub deleted: HashSet, /// Files that were moved (to_path, from_path). pub moved: HashMap, /// Whether a commit-related file changed (`.git/HEAD` or `.git/refs/heads/*`). pub commit_updated: bool, /// Whether the git index lock file was created or removed (`.git/index.lock`). pub index_lock_detected: bool, } impl RepositoryUpdate { /// Returns true if this update contains no changes. pub fn is_empty(&self) -> bool { self.added.is_empty() && self.modified.is_empty() && self.deleted.is_empty() && self.moved.is_empty() && !self.commit_updated && !self.index_lock_detected } /// Iterator over all created and modified files. /// /// Most consumers don't care about the added-vs-modified distinction. pub fn added_or_modified(&self) -> impl Iterator { self.added.iter().chain(self.modified.iter()) } /// Owned iterator over all created and modified files. pub fn into_added_or_modified(self) -> impl Iterator { self.added.into_iter().chain(self.modified) } pub fn contains_added_or_modified(&self, file: &TargetFile) -> bool { self.added.contains(file) || self.modified.contains(file) } } /// An asynchronous task in a watched repository. #[derive(Clone)] enum Task { /// Perform an initial (or re-)scan for the given subscriber on the repository. Scan { repository: WeakModelHandle, subscriber_id: SubscriberId, }, #[cfg(feature = "local_fs")] /// Deliver an incremental update (filesystem changes) to a specific repository subscriber. Update { repository: WeakModelHandle, subscriber_id: SubscriberId, update: RepositoryUpdate, }, } impl Task { fn execute( self, ctx: &mut ModelContext, ) -> Option + Send>>> { match self { Task::Scan { repository, subscriber_id, } => { if let Some(repository) = repository.upgrade(ctx) { repository.update(ctx, |repository, ctx| { repository.scan_subscriber(subscriber_id, ctx) }) } else { None } } #[cfg(feature = "local_fs")] Task::Update { repository, subscriber_id, update, } => { if let Some(repository) = repository.upgrade(ctx) { repository.update(ctx, |repository, ctx| { repository.notify_subscriber(subscriber_id, &update, ctx) }) } else { None } } } } } #[derive(Clone)] pub enum TaskQueueEvent { TaskEnqueued, } /// Lightweight task queue model for watched repositories. The [`RepositoryWatcher`] model uses this /// to limit throughput for CPU- or disk-intensive update operations. #[derive(Default)] pub(crate) struct TaskQueue { /// Tasks which have not yet been executed. pending_tasks: VecDeque, active_tasks: usize, } impl TaskQueue { fn new(_ctx: &mut ModelContext) -> Self { Self::default() } /// Enqueue a new task. fn enqueue(&mut self, task: Task, ctx: &mut ModelContext) { self.pending_tasks.push_back(task); // Notify the watcher that a new task has been enqueued. This prevents circular model // updates by ensuring new tasks aren't immediately dequeued. ctx.emit(TaskQueueEvent::TaskEnqueued); } /// Advance through the queue by executing new tasks, up to the concurrency limit. fn advance(&mut self, ctx: &mut ModelContext) { while self.active_tasks < MAX_CONCURRENT_TASKS { let Some(task) = self.pending_tasks.pop_front() else { break; }; if let Some(future) = task.execute(ctx) { self.active_tasks += 1; ctx.spawn(future, move |me, _, ctx| { me.handle_task_completion(ctx); }); } } } fn handle_task_completion(&mut self, ctx: &mut ModelContext) { self.active_tasks -= 1; self.advance(ctx); } /// Convenience helpers for enqueuing specific task kinds. pub(crate) fn enqueue_scan( &mut self, repository: WeakModelHandle, subscriber_id: SubscriberId, ctx: &mut ModelContext, ) { self.enqueue( Task::Scan { repository, subscriber_id, }, ctx, ); } #[cfg(feature = "local_fs")] pub(crate) fn enqueue_incremental_update( &mut self, repository: WeakModelHandle, subscriber_id: SubscriberId, update: RepositoryUpdate, ctx: &mut ModelContext, ) { self.enqueue( Task::Update { repository, subscriber_id, update, }, ctx, ); } } impl Entity for TaskQueue { type Event = TaskQueueEvent; } #[cfg(test)] #[path = "watcher_tests.rs"] mod tests;