use std::collections::HashMap; use std::future::Future; use std::pin::Pin; use std::sync::{Arc, Mutex}; use std::time::Duration; #[cfg(feature = "local_fs")] use std::path::Path; use futures::future::ready; #[cfg(feature = "local_fs")] use ignore::gitignore::Gitignore; use galaxy_util::standardized_path::StandardizedPath; use galaxyui::r#async::{BoxFuture, SpawnedFutureHandle}; #[cfg(feature = "local_fs")] use galaxyui::SingletonEntity; use galaxyui::{Entity, ModelContext, ModelHandle}; #[cfg(feature = "local_fs")] use crate::watcher::DirectoryWatcher; #[cfg(feature = "local_fs")] use crate::{ entry::{matches_gitignores, should_ignore_git_path}, gitignores_for_directory, }; use crate::{watcher::TaskQueue, RepoMetadataError, RepositoryUpdate}; /// Trait for entities that want to subscribe to repository file changes. pub trait RepositorySubscriber: Send + Sync { /// Called when the subscriber is first added to build initial state. /// Returns a Future that completes when the scan is finished. fn on_scan( &mut self, repository: &Repository, ctx: &mut ModelContext, ) -> Pin + Send + 'static>>; /// Called when file changes are detected in the repository. /// Returns a Future that completes once updates are processed. fn on_files_updated( &mut self, repository: &Repository, update: &RepositoryUpdate, ctx: &mut ModelContext, ) -> Pin + Send + 'static>>; fn on_unsubscribe(&mut self, _ctx: &mut ModelContext) {} } /// A unique identifier for repository subscribers. pub type SubscriberId = usize; pub struct StartWatching { pub subscriber_id: SubscriberId, pub registration_future: BoxFuture<'static, Result<(), RepoMetadataError>>, } /// Model for tracking a code repository that Warp is aware of. pub struct Repository { /// The root directory of the repository. root_dir: StandardizedPath, /// External git directory path (e.g., for worktrees). This is the /// path to the **exact** per-worktree gitdir (e.g. `.git/worktrees/foo`). /// For the main worktree this is `None` (the gitdir is `root_dir/.git`). external_git_directory: Option, /// The shared `.git` root directory that all worktrees of the same repo /// have in common. Derived from `external_git_directory` by walking up to /// the `.git` component. `None` when the repo is not a linked worktree. common_git_directory: Option, /// Collection of subscribers interested in file changes. subscribers: HashMap>, /// Counter for generating unique subscriber IDs. next_subscriber_id: SubscriberId, /// Cached gitignore patterns for this repository. #[cfg(feature = "local_fs")] gitignores: Vec, task_queue: ModelHandle, } impl Repository { /// Creates a new Repository instance. pub(super) fn new( root_dir: StandardizedPath, external_git_directory: Option, task_queue: ModelHandle, ) -> Self { #[cfg(feature = "local_fs")] let gitignores = { let local_path = root_dir.to_local_path_lossy(); gitignores_for_directory(&local_path) }; let common_git_directory = external_git_directory.as_ref().and_then(|ext| { ext.to_local_path() .and_then(|local| Self::derive_common_git_dir(&local)) .and_then(|p| StandardizedPath::try_from_local(&p).ok()) // Only store when it differs from external_git_directory. .filter(|common| common != ext) }); Self { root_dir, external_git_directory, common_git_directory, subscribers: HashMap::new(), next_subscriber_id: 0, #[cfg(feature = "local_fs")] gitignores, task_queue, } } /// Walk ancestors of the given path to find the `.git` component and return /// it as the shared git root. For example, /// `/repo/.git/worktrees/foo` → `/repo/.git`. fn derive_common_git_dir(external_git_dir: &std::path::Path) -> Option { for ancestor in external_git_dir.ancestors() { if ancestor.file_name().and_then(|n| n.to_str()) == Some(".git") { return Some(ancestor.to_path_buf()); } } None } /// The root directory of this repository. pub fn root_dir(&self) -> &StandardizedPath { &self.root_dir } /// The external git directory of this repository, if any. /// This is used for worktrees where the .git directory is external to the working tree. pub fn external_git_directory(&self) -> Option<&StandardizedPath> { self.external_git_directory.as_ref() } /// Returns the path to the actual `.git` directory for this repository. /// /// For normal repositories this is `root_dir/.git`. For worktrees, the /// `.git` entry in the working tree is a file (not a directory), so this /// returns the resolved `external_git_directory` instead. /// Subscribers should use this for per-worktree files like `index.lock`. pub fn git_dir(&self) -> std::path::PathBuf { self.external_git_directory .as_ref() .and_then(|d| d.to_local_path()) .unwrap_or_else(|| self.root_dir.to_local_path_lossy().join(".git")) } /// Returns the shared `.git` root directory. /// /// For normal repos this is the same as `git_dir()`. For linked worktrees /// this is the common `.git` directory that all worktrees share (e.g. /// `/repo/.git`), distinct from the per-worktree gitdir. pub fn common_git_dir(&self) -> std::path::PathBuf { self.common_git_directory .as_ref() .and_then(|d| d.to_local_path()) .unwrap_or_else(|| self.git_dir()) } /// Returns the current watcher count. pub fn watcher_count(&self) -> usize { self.subscribers.len() } /// Starts watching this repository with the given subscriber. /// /// If this is the first subscriber, the repository root will be added to the /// RepositoryWatcher's set of watched paths. #[cfg_attr(not(feature = "local_fs"), allow(unused_variables))] pub fn start_watching( &mut self, subscriber: Box, ctx: &mut ModelContext, ) -> StartWatching { let subscriber_id = self.next_subscriber_id; self.next_subscriber_id += 1; // If this is the first subscriber, we need to start watching the repository #[cfg(feature = "local_fs")] let should_start_watching = self.subscribers.is_empty(); self.subscribers.insert(subscriber_id, subscriber); #[cfg(feature = "local_fs")] let registration_future: BoxFuture<'static, Result<(), RepoMetadataError>> = if should_start_watching { // Prepare list of directories to watch let mut directories_to_watch = vec![self.root_dir.clone()]; // Watch the per-worktree gitdir for worktree-specific events // (HEAD, index.lock under .git/worktrees//). if let Some(external_git_dir) = &self.external_git_directory { directories_to_watch.push(external_git_dir.clone()); } // For linked worktrees, also watch .git/refs so shared ref // changes (refs/heads/*) are visible even when the main // worktree isn't registered. if let Some(common_git_dir) = &self.common_git_directory { if let Some(common_local) = common_git_dir.to_local_path() { let refs_dir = common_local.join("refs").join("heads"); if let Ok(refs_std) = StandardizedPath::from_local_canonicalized(&refs_dir) { directories_to_watch.push(refs_std); } } } Box::pin(DirectoryWatcher::handle(ctx).update(ctx, |watcher, ctx| { watcher.start_watching_directories(directories_to_watch, ctx) })) } else { Box::pin(ready(Ok(()))) }; #[cfg(not(feature = "local_fs"))] let registration_future: BoxFuture<'static, Result<(), RepoMetadataError>> = Box::pin(async move { Ok(()) }); let self_handle = ctx.handle(); self.task_queue.update(ctx, |queue, ctx| { queue.enqueue_scan(self_handle, subscriber_id, ctx); }); StartWatching { subscriber_id, registration_future, } } /// Stops watching this repository for the given subscriber. /// /// If this was the last subscriber, the repository root will be removed from the /// RepositoryWatcher's set of watched paths. #[cfg_attr(not(feature = "local_fs"), allow(unused_variables))] pub fn stop_watching(&mut self, subscriber_id: SubscriberId, ctx: &mut ModelContext) { let Some(mut subscriber) = self.subscribers.remove(&subscriber_id) else { return; }; subscriber.on_unsubscribe(ctx); if self.subscribers.is_empty() { // If this was the last subscriber, notify the RepWatcher to stop watching. log::debug!( "All subscribers removed for {}, stopping watcher", self.root_dir ); #[cfg(feature = "local_fs")] { DirectoryWatcher::handle(ctx).update(ctx, |watcher, ctx| { // Stop watching the working tree directory std::mem::drop(watcher.stop_watching_directory(&self.root_dir, ctx)); // Mirror start_watching: stop per-worktree gitdir + shared refs. if let Some(external_git_dir) = &self.external_git_directory { std::mem::drop(watcher.stop_watching_directory(external_git_dir, ctx)); } if let Some(common_git_dir) = &self.common_git_directory { if let Some(common_local) = common_git_dir.to_local_path() { let refs_dir = common_local.join("refs").join("heads"); if let Ok(refs_std) = StandardizedPath::from_local_canonicalized(&refs_dir) { std::mem::drop(watcher.stop_watching_directory(&refs_std, ctx)); } } } }); } } } /// Calls scan on a specific subscriber if it exists. Returns Some(Future) if the subscriber exists, None otherwise. pub(crate) fn scan_subscriber( &mut self, subscriber_id: SubscriberId, ctx: &mut ModelContext, ) -> Option + Send + 'static>>> { if let Some(mut subscriber) = self.subscribers.remove(&subscriber_id) { let future = subscriber.on_scan(self, ctx); self.subscribers.insert(subscriber_id, subscriber); Some(future) } else { None } } /// Notifies a specific subscriber about file changes. #[cfg(feature = "local_fs")] pub(crate) fn notify_subscriber( &mut self, subscriber_id: SubscriberId, update: &RepositoryUpdate, ctx: &mut ModelContext, ) -> Option + Send + 'static>>> { if let Some(mut subscriber) = self.subscribers.remove(&subscriber_id) { let future = subscriber.on_files_updated(self, update, ctx); self.subscribers.insert(subscriber_id, subscriber); Some(future) } else { None } } /// Returns the subscriber IDs for this repository. #[cfg(feature = "local_fs")] pub(crate) fn get_subscriber_ids(&self) -> Vec { self.subscribers.keys().cloned().collect() } /// Checks if a path is gitignored within this repository. #[cfg(feature = "local_fs")] pub fn check_gitignore_status(&self, path: &Path) -> bool { // Check if path is a .git internal file if should_ignore_git_path(path) { return true; } // Check if path matches gitignore patterns let is_dir = path.is_dir(); matches_gitignores(path, is_dir, &self.gitignores, true) } } impl Entity for Repository { type Event = (); } /// Coalescing merge for RepositoryUpdate with normalization rules. fn merge_repository_updates(acc: &mut RepositoryUpdate, incoming: &RepositoryUpdate) { // 1) Moves first for (to, from) in &incoming.moved { if acc.added.remove(from) { acc.added.insert(to.clone()); return; } if acc.modified.remove(from) { acc.modified.insert(to.clone()); return; } // Collapse chain: if `from` was a prior destination, pull its original source let original_from = if let Some(prev_from) = acc.moved.remove(from) { prev_from } else { from.clone() }; acc.moved.insert(to.clone(), original_from); } // 2) Adds next for p in &incoming.added { acc.deleted.remove(p); acc.moved.remove(p); acc.modified.remove(p); acc.added.insert(p.clone()); } // 3) Modifies next for p in &incoming.modified { if acc.added.contains(p) { continue; } acc.deleted.remove(p); acc.moved.remove(p); acc.modified.insert(p.clone()); } // 4) Deletes last for p in &incoming.deleted { // Added then removed within window => cancel if acc.added.remove(p) { continue; } acc.modified.remove(p); // Removing a move target => delete original source instead if let Some(from) = acc.moved.remove(p) { acc.deleted.insert(from); continue; } // Deleting the source of a recorded move is redundant; move already implies source removal let is_from_of_some_move = acc.moved.values().any(|f| f == p); if is_from_of_some_move { continue; } acc.deleted.insert(p.clone()); } acc.commit_updated |= incoming.commit_updated; acc.index_lock_detected |= incoming.index_lock_detected; } /// A generic debouncing layer for any RepositorySubscriber. pub struct BufferingRepositorySubscriber { inner: Arc>, state: Arc>, debounce: Duration, } #[derive(Default)] struct BufferState { pending: RepositoryUpdate, /// Monotonic counter incremented for each incoming update; used to implement true debounce. version: u64, /// Whether the background flusher loop is currently running. flush_handle: Option, } impl BufferingRepositorySubscriber { pub fn new(inner: S, debounce: Duration) -> Self { Self { inner: Arc::new(Mutex::new(inner)), state: Arc::new(Mutex::new(BufferState::default())), debounce, } } } impl RepositorySubscriber for BufferingRepositorySubscriber where S: RepositorySubscriber + Send + Sync + 'static, { fn on_scan( &mut self, repository: &Repository, ctx: &mut ModelContext, ) -> Pin + Send + 'static>> { self.inner.lock().unwrap().on_scan(repository, ctx) } fn on_files_updated( &mut self, _repository: &Repository, update: &RepositoryUpdate, ctx: &mut ModelContext, ) -> Pin + Send + 'static>> { { let mut st = self.state.lock().unwrap(); merge_repository_updates(&mut st.pending, update); st.version = st.version.wrapping_add(1); // Start a single background flusher if it's not already running. if st.flush_handle.is_none() { let inner = Arc::clone(&self.inner); let state = Arc::clone(&self.state); let wait = self.debounce; st.flush_handle = Some(ctx.spawn( async move { // Loop until we observe a quiet period (version stable for `wait`). loop { // Capture current version, then wait. let start_version = { let st = state.lock().unwrap(); st.version }; galaxyui::r#async::Timer::after(wait).await; // If version unchanged, we're quiet; flush pending and exit loop. let maybe_merged = { // Yield before flushing to check if the current flush is cancelled. futures_lite::future::yield_now().await; let mut st = state.lock().unwrap(); if st.version == start_version { st.flush_handle = None; Some(std::mem::take(&mut st.pending)) } else { // Newer update arrived during the wait; try waiting again. None } }; if let Some(merged) = maybe_merged { break (inner, merged); } } }, |repo_model, (inner, merged), repo_ctx| { if merged.is_empty() { return; } if let Ok(mut inner) = inner.lock() { let fut = inner.on_files_updated(repo_model, &merged, repo_ctx); // Drive the subscriber's async update to completion. repo_ctx.spawn(fut, |_, _, _| {}); } }, )); } } Box::pin(ready(())) } fn on_unsubscribe(&mut self, _ctx: &mut ModelContext) { let Ok(mut st) = self.state.lock() else { return; }; if let Some(handle) = st.flush_handle.take() { handle.abort(); } } }