git: Add support for opening git worktrees (#20164)

Thorsten Ball , Antonio , and Bennet created

This adds support for [git
worktrees](https://matklad.github.io/2024/07/25/git-worktrees.html). It
fixes the errors that show up (git blame not working) and actually adds
support for detecting git changes in a `.git` folder that's outside of
our path (and not even in the ancestor chain of our root path).

(While working on this we discovered that our `.gitignore` handling is
not 100% correct. For example: we do stop processing `.gitignore` files
once we found a `.git` repository and don't go further up the ancestors,
which is correct, but then we also don't take into account the
`excludesFile` that a user might have configured, see:
https://git-scm.com/docs/gitignore)


Closes https://github.com/zed-industries/zed/issues/19842
Closes https://github.com/zed-industries/zed/issues/4670

Release Notes:

- Added support for git worktrees. Zed can now open git worktrees and
the git status in them is correctly handled.

---------

Co-authored-by: Antonio <antonio@zed.dev>
Co-authored-by: Bennet <bennet@zed.dev>

Change summary

crates/fs/src/fs.rs                   | 180 +++++-----------------------
crates/fs/src/linux_watcher.rs        | 121 +++++++++++++++++++
crates/fs/src/mac_watcher.rs          |  70 +++++++++++
crates/git/src/repository.rs          |  12 +
crates/search/src/project_search.rs   |   1 
crates/worktree/src/worktree.rs       | 140 +++++++++++++--------
crates/worktree/src/worktree_tests.rs |  16 +
crates/zed/src/zed.rs                 |   2 
8 files changed, 337 insertions(+), 205 deletions(-)

Detailed changes

crates/fs/src/fs.rs 🔗

@@ -1,3 +1,9 @@
+#[cfg(target_os = "macos")]
+mod mac_watcher;
+
+#[cfg(target_os = "linux")]
+pub mod linux_watcher;
+
 use anyhow::{anyhow, Result};
 use git::GitHostingProviderRegistry;
 
@@ -530,14 +536,21 @@ impl Fs for RealFs {
         Pin<Box<dyn Send + Stream<Item = Vec<PathEvent>>>>,
         Arc<dyn Watcher>,
     ) {
-        use fsevent::{EventStream, StreamFlags};
+        use fsevent::StreamFlags;
 
-        let (tx, rx) = smol::channel::unbounded();
-        let (stream, handle) = EventStream::new(&[path], latency);
-        std::thread::spawn(move || {
-            stream.run(move |events| {
-                smol::block_on(
-                    tx.send(
+        let (events_tx, events_rx) = smol::channel::unbounded();
+        let handles = Arc::new(parking_lot::Mutex::new(collections::BTreeMap::default()));
+        let watcher = Arc::new(mac_watcher::MacWatcher::new(
+            events_tx,
+            Arc::downgrade(&handles),
+            latency,
+        ));
+        watcher.add(path).expect("handles can't be dropped");
+
+        (
+            Box::pin(
+                events_rx
+                    .map(|events| {
                         events
                             .into_iter()
                             .map(|event| {
@@ -555,19 +568,14 @@ impl Fs for RealFs {
                                     kind,
                                 }
                             })
-                            .collect(),
-                    ),
-                )
-                .is_ok()
-            });
-        });
-
-        (
-            Box::pin(rx.chain(futures::stream::once(async move {
-                drop(handle);
-                vec![]
-            }))),
-            Arc::new(RealWatcher {}),
+                            .collect()
+                    })
+                    .chain(futures::stream::once(async move {
+                        drop(handles);
+                        vec![]
+                    })),
+            ),
+            watcher,
         )
     }
 
@@ -580,81 +588,26 @@ impl Fs for RealFs {
         Pin<Box<dyn Send + Stream<Item = Vec<PathEvent>>>>,
         Arc<dyn Watcher>,
     ) {
-        use notify::EventKind;
         use parking_lot::Mutex;
 
         let (tx, rx) = smol::channel::unbounded();
         let pending_paths: Arc<Mutex<Vec<PathEvent>>> = Default::default();
-        let root_path = path.to_path_buf();
-
-        // Check if root path is a symlink
-        let target_path = self.read_link(&path).await.ok();
+        let watcher = Arc::new(linux_watcher::LinuxWatcher::new(tx, pending_paths.clone()));
 
-        watcher::global({
-            let target_path = target_path.clone();
-            |g| {
-                let tx = tx.clone();
-                let pending_paths = pending_paths.clone();
-                g.add(move |event: &notify::Event| {
-                    let kind = match event.kind {
-                        EventKind::Create(_) => Some(PathEventKind::Created),
-                        EventKind::Modify(_) => Some(PathEventKind::Changed),
-                        EventKind::Remove(_) => Some(PathEventKind::Removed),
-                        _ => None,
-                    };
-                    let mut paths = event
-                        .paths
-                        .iter()
-                        .filter_map(|path| {
-                            if let Some(target) = target_path.clone() {
-                                if path.starts_with(target) {
-                                    return Some(PathEvent {
-                                        path: path.clone(),
-                                        kind,
-                                    });
-                                }
-                            } else if path.starts_with(&root_path) {
-                                return Some(PathEvent {
-                                    path: path.clone(),
-                                    kind,
-                                });
-                            }
-                            None
-                        })
-                        .collect::<Vec<_>>();
-
-                    if !paths.is_empty() {
-                        paths.sort();
-                        let mut pending_paths = pending_paths.lock();
-                        if pending_paths.is_empty() {
-                            tx.try_send(()).ok();
-                        }
-                        util::extend_sorted(&mut *pending_paths, paths, usize::MAX, |a, b| {
-                            a.path.cmp(&b.path)
-                        });
-                    }
-                })
-            }
-        })
-        .log_err();
-
-        let watcher = Arc::new(RealWatcher {});
-
-        watcher.add(path).ok(); // Ignore "file doesn't exist error" and rely on parent watcher.
+        watcher.add(&path).ok(); // Ignore "file doesn't exist error" and rely on parent watcher.
+        if let Some(parent) = path.parent() {
+            // watch the parent dir so we can tell when settings.json is created
+            watcher.add(parent).log_err();
+        }
 
         // Check if path is a symlink and follow the target parent
-        if let Some(target) = target_path {
+        if let Some(target) = self.read_link(&path).await.ok() {
             watcher.add(&target).ok();
             if let Some(parent) = target.parent() {
                 watcher.add(parent).log_err();
             }
         }
 
-        // watch the parent dir so we can tell when settings.json is created
-        if let Some(parent) = path.parent() {
-            watcher.add(parent).log_err();
-        }
-
         (
             Box::pin(rx.filter_map({
                 let watcher = watcher.clone();
@@ -784,23 +737,6 @@ impl Watcher for RealWatcher {
     }
 }
 
-#[cfg(target_os = "linux")]
-impl Watcher for RealWatcher {
-    fn add(&self, path: &Path) -> Result<()> {
-        use notify::Watcher;
-        Ok(watcher::global(|w| {
-            w.inotify
-                .lock()
-                .watch(path, notify::RecursiveMode::NonRecursive)
-        })??)
-    }
-
-    fn remove(&self, path: &Path) -> Result<()> {
-        use notify::Watcher;
-        Ok(watcher::global(|w| w.inotify.lock().unwatch(path))??)
-    }
-}
-
 #[cfg(any(test, feature = "test-support"))]
 pub struct FakeFs {
     // Use an unfair lock to ensure tests are deterministic.
@@ -2084,49 +2020,3 @@ mod tests {
         );
     }
 }
-
-#[cfg(target_os = "linux")]
-pub mod watcher {
-    use std::sync::OnceLock;
-
-    use parking_lot::Mutex;
-    use util::ResultExt;
-
-    pub struct GlobalWatcher {
-        // two mutexes because calling inotify.add triggers an inotify.event, which needs watchers.
-        pub(super) inotify: Mutex<notify::INotifyWatcher>,
-        pub(super) watchers: Mutex<Vec<Box<dyn Fn(&notify::Event) + Send + Sync>>>,
-    }
-
-    impl GlobalWatcher {
-        pub(super) fn add(&self, cb: impl Fn(&notify::Event) + Send + Sync + 'static) {
-            self.watchers.lock().push(Box::new(cb))
-        }
-    }
-
-    static INOTIFY_INSTANCE: OnceLock<anyhow::Result<GlobalWatcher, notify::Error>> =
-        OnceLock::new();
-
-    fn handle_event(event: Result<notify::Event, notify::Error>) {
-        let Some(event) = event.log_err() else { return };
-        global::<()>(move |watcher| {
-            for f in watcher.watchers.lock().iter() {
-                f(&event)
-            }
-        })
-        .log_err();
-    }
-
-    pub fn global<T>(f: impl FnOnce(&GlobalWatcher) -> T) -> anyhow::Result<T> {
-        let result = INOTIFY_INSTANCE.get_or_init(|| {
-            notify::recommended_watcher(handle_event).map(|file_watcher| GlobalWatcher {
-                inotify: Mutex::new(file_watcher),
-                watchers: Default::default(),
-            })
-        });
-        match result {
-            Ok(g) => Ok(f(g)),
-            Err(e) => Err(anyhow::anyhow!("{}", e)),
-        }
-    }
-}

crates/fs/src/linux_watcher.rs 🔗

@@ -0,0 +1,121 @@
+use notify::EventKind;
+use parking_lot::Mutex;
+use std::sync::{Arc, OnceLock};
+use util::ResultExt;
+
+use crate::{PathEvent, PathEventKind, Watcher};
+
+pub struct LinuxWatcher {
+    tx: smol::channel::Sender<()>,
+    pending_path_events: Arc<Mutex<Vec<PathEvent>>>,
+}
+
+impl LinuxWatcher {
+    pub fn new(
+        tx: smol::channel::Sender<()>,
+        pending_path_events: Arc<Mutex<Vec<PathEvent>>>,
+    ) -> Self {
+        Self {
+            tx,
+            pending_path_events,
+        }
+    }
+}
+
+impl Watcher for LinuxWatcher {
+    fn add(&self, path: &std::path::Path) -> gpui::Result<()> {
+        let root_path = path.to_path_buf();
+
+        let tx = self.tx.clone();
+        let pending_paths = self.pending_path_events.clone();
+
+        use notify::Watcher;
+
+        global({
+            |g| {
+                g.add(move |event: &notify::Event| {
+                    let kind = match event.kind {
+                        EventKind::Create(_) => Some(PathEventKind::Created),
+                        EventKind::Modify(_) => Some(PathEventKind::Changed),
+                        EventKind::Remove(_) => Some(PathEventKind::Removed),
+                        _ => None,
+                    };
+                    let mut path_events = event
+                        .paths
+                        .iter()
+                        .filter_map(|event_path| {
+                            event_path.starts_with(&root_path).then(|| PathEvent {
+                                path: event_path.clone(),
+                                kind,
+                            })
+                        })
+                        .collect::<Vec<_>>();
+
+                    if !path_events.is_empty() {
+                        path_events.sort();
+                        let mut pending_paths = pending_paths.lock();
+                        if pending_paths.is_empty() {
+                            tx.try_send(()).ok();
+                        }
+                        util::extend_sorted(
+                            &mut *pending_paths,
+                            path_events,
+                            usize::MAX,
+                            |a, b| a.path.cmp(&b.path),
+                        );
+                    }
+                })
+            }
+        })?;
+
+        global(|g| {
+            g.inotify
+                .lock()
+                .watch(path, notify::RecursiveMode::NonRecursive)
+        })??;
+
+        Ok(())
+    }
+
+    fn remove(&self, path: &std::path::Path) -> gpui::Result<()> {
+        use notify::Watcher;
+        Ok(global(|w| w.inotify.lock().unwatch(path))??)
+    }
+}
+
+pub struct GlobalWatcher {
+    // two mutexes because calling inotify.add triggers an inotify.event, which needs watchers.
+    pub(super) inotify: Mutex<notify::INotifyWatcher>,
+    pub(super) watchers: Mutex<Vec<Box<dyn Fn(&notify::Event) + Send + Sync>>>,
+}
+
+impl GlobalWatcher {
+    pub(super) fn add(&self, cb: impl Fn(&notify::Event) + Send + Sync + 'static) {
+        self.watchers.lock().push(Box::new(cb))
+    }
+}
+
+static INOTIFY_INSTANCE: OnceLock<anyhow::Result<GlobalWatcher, notify::Error>> = OnceLock::new();
+
+fn handle_event(event: Result<notify::Event, notify::Error>) {
+    let Some(event) = event.log_err() else { return };
+    global::<()>(move |watcher| {
+        for f in watcher.watchers.lock().iter() {
+            f(&event)
+        }
+    })
+    .log_err();
+}
+
+pub fn global<T>(f: impl FnOnce(&GlobalWatcher) -> T) -> anyhow::Result<T> {
+    let result = INOTIFY_INSTANCE.get_or_init(|| {
+        notify::recommended_watcher(handle_event).map(|file_watcher| GlobalWatcher {
+            inotify: Mutex::new(file_watcher),
+            watchers: Default::default(),
+        })
+    });
+    match result {
+        Ok(g) => Ok(f(g)),
+        Err(e) => Err(anyhow::anyhow!("{}", e)),
+    }
+}

crates/fs/src/mac_watcher.rs 🔗

@@ -0,0 +1,70 @@
+use crate::Watcher;
+use anyhow::{Context as _, Result};
+use collections::{BTreeMap, Bound};
+use fsevent::EventStream;
+use parking_lot::Mutex;
+use std::{
+    path::{Path, PathBuf},
+    sync::Weak,
+    time::Duration,
+};
+
+pub struct MacWatcher {
+    events_tx: smol::channel::Sender<Vec<fsevent::Event>>,
+    handles: Weak<Mutex<BTreeMap<PathBuf, fsevent::Handle>>>,
+    latency: Duration,
+}
+
+impl MacWatcher {
+    pub fn new(
+        events_tx: smol::channel::Sender<Vec<fsevent::Event>>,
+        handles: Weak<Mutex<BTreeMap<PathBuf, fsevent::Handle>>>,
+        latency: Duration,
+    ) -> Self {
+        Self {
+            events_tx,
+            handles,
+            latency,
+        }
+    }
+}
+
+impl Watcher for MacWatcher {
+    fn add(&self, path: &Path) -> Result<()> {
+        let handles = self
+            .handles
+            .upgrade()
+            .context("unable to watch path, receiver dropped")?;
+        let mut handles = handles.lock();
+
+        // Return early if an ancestor of this path was already being watched.
+        if let Some((watched_path, _)) = handles
+            .range::<Path, _>((Bound::Unbounded, Bound::Included(path)))
+            .next_back()
+        {
+            if path.starts_with(watched_path) {
+                return Ok(());
+            }
+        }
+
+        let (stream, handle) = EventStream::new(&[path], self.latency);
+        let tx = self.events_tx.clone();
+        std::thread::spawn(move || {
+            stream.run(move |events| smol::block_on(tx.send(events)).is_ok());
+        });
+        handles.insert(path.into(), handle);
+
+        Ok(())
+    }
+
+    fn remove(&self, path: &Path) -> gpui::Result<()> {
+        let handles = self
+            .handles
+            .upgrade()
+            .context("unable to remove path, receiver dropped")?;
+
+        let mut handles = handles.lock();
+        handles.remove(path);
+        Ok(())
+    }
+}

crates/git/src/repository.rs 🔗

@@ -45,6 +45,8 @@ pub trait GitRepository: Send + Sync {
     fn branch_exits(&self, _: &str) -> Result<bool>;
 
     fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame>;
+
+    fn path(&self) -> PathBuf;
 }
 
 impl std::fmt::Debug for dyn GitRepository {
@@ -83,6 +85,11 @@ impl GitRepository for RealGitRepository {
         }
     }
 
+    fn path(&self) -> PathBuf {
+        let repo = self.repository.lock();
+        repo.path().into()
+    }
+
     fn load_index_text(&self, relative_file_path: &Path) -> Option<String> {
         fn logic(repo: &git2::Repository, relative_file_path: &Path) -> Result<Option<String>> {
             const STAGE_NORMAL: i32 = 0;
@@ -276,6 +283,11 @@ impl GitRepository for FakeGitRepository {
         None
     }
 
+    fn path(&self) -> PathBuf {
+        let state = self.state.lock();
+        state.path.clone()
+    }
+
     fn status(&self, path_prefixes: &[PathBuf]) -> Result<GitStatus> {
         let state = self.state.lock();
         let mut entries = state

crates/search/src/project_search.rs 🔗

@@ -2024,6 +2024,7 @@ pub fn perform_project_search(
     text: impl Into<std::sync::Arc<str>>,
     cx: &mut gpui::VisualTestContext,
 ) {
+    cx.run_until_parked();
     search_view.update(cx, |search_view, cx| {
         search_view
             .query_editor

crates/worktree/src/worktree.rs 🔗

@@ -14,7 +14,6 @@ use futures::{
         oneshot,
     },
     select_biased,
-    stream::select,
     task::Poll,
     FutureExt as _, Stream, StreamExt,
 };
@@ -307,9 +306,11 @@ struct BackgroundScannerState {
 pub struct LocalRepositoryEntry {
     pub(crate) git_dir_scan_id: usize,
     pub(crate) repo_ptr: Arc<dyn GitRepository>,
-    /// Path to the actual .git folder.
+    /// Absolute path to the actual .git folder.
     /// Note: if .git is a file, this points to the folder indicated by the .git file
-    pub(crate) git_dir_path: Arc<Path>,
+    pub(crate) dot_git_dir_abs_path: Arc<Path>,
+    /// Absolute path to the .git file, if we're in a git worktree.
+    pub(crate) dot_git_worktree_abs_path: Option<Arc<Path>>,
 }
 
 impl LocalRepositoryEntry {
@@ -2559,7 +2560,7 @@ impl LocalSnapshot {
                     new_ignores.push((ancestor, None));
                 }
             }
-            if ancestor.join(*DOT_GIT).is_dir() {
+            if ancestor.join(*DOT_GIT).exists() {
                 break;
             }
         }
@@ -2664,7 +2665,7 @@ impl LocalSnapshot {
         let dotgit_paths = self
             .git_repositories
             .iter()
-            .map(|repo| repo.1.git_dir_path.clone())
+            .map(|repo| repo.1.dot_git_dir_abs_path.clone())
             .collect::<HashSet<_>>();
         let work_dir_paths = self
             .repository_entries
@@ -2764,11 +2765,11 @@ impl BackgroundScannerState {
         }
     }
 
-    fn insert_entry(&mut self, mut entry: Entry, fs: &dyn Fs) -> Entry {
+    fn insert_entry(&mut self, mut entry: Entry, fs: &dyn Fs, watcher: &dyn Watcher) -> Entry {
         self.reuse_entry_id(&mut entry);
         let entry = self.snapshot.insert_entry(entry, fs);
         if entry.path.file_name() == Some(&DOT_GIT) {
-            self.build_git_repository(entry.path.clone(), fs);
+            self.insert_git_repository(entry.path.clone(), fs, watcher);
         }
 
         #[cfg(test)]
@@ -2897,10 +2898,11 @@ impl BackgroundScannerState {
         self.snapshot.check_invariants(false);
     }
 
-    fn build_git_repository(
+    fn insert_git_repository(
         &mut self,
         dot_git_path: Arc<Path>,
         fs: &dyn Fs,
+        watcher: &dyn Watcher,
     ) -> Option<(RepositoryWorkDirectory, Arc<dyn GitRepository>)> {
         let work_dir_path: Arc<Path> = match dot_git_path.parent() {
             Some(parent_dir) => {
@@ -2927,15 +2929,16 @@ impl BackgroundScannerState {
             }
         };
 
-        self.build_git_repository_for_path(work_dir_path, dot_git_path, None, fs)
+        self.insert_git_repository_for_path(work_dir_path, dot_git_path, None, fs, watcher)
     }
 
-    fn build_git_repository_for_path(
+    fn insert_git_repository_for_path(
         &mut self,
         work_dir_path: Arc<Path>,
         dot_git_path: Arc<Path>,
         location_in_repo: Option<Arc<Path>>,
         fs: &dyn Fs,
+        watcher: &dyn Watcher,
     ) -> Option<(RepositoryWorkDirectory, Arc<dyn GitRepository>)> {
         let work_dir_id = self
             .snapshot
@@ -2946,9 +2949,31 @@ impl BackgroundScannerState {
             return None;
         }
 
-        let abs_path = self.snapshot.abs_path.join(&dot_git_path);
+        let dot_git_abs_path = self.snapshot.abs_path.join(&dot_git_path);
+
         let t0 = Instant::now();
-        let repository = fs.open_repo(&abs_path)?;
+        let repository = fs.open_repo(&dot_git_abs_path)?;
+
+        let actual_repo_path = repository.path();
+        let actual_dot_git_dir_abs_path: Arc<Path> = Arc::from(
+            actual_repo_path
+                .ancestors()
+                .find(|ancestor| ancestor.file_name() == Some(&*DOT_GIT))?,
+        );
+
+        watcher.add(&actual_repo_path).log_err()?;
+
+        let dot_git_worktree_abs_path = if actual_dot_git_dir_abs_path.as_ref() == dot_git_abs_path
+        {
+            None
+        } else {
+            // The two paths could be different because we opened a git worktree.
+            // When that happens, the .git path in the worktree (`dot_git_abs_path`) is a file that
+            // points to the worktree-subdirectory in the actual .git directory (`git_dir_path`)
+            watcher.add(&dot_git_abs_path).log_err()?;
+            Some(Arc::from(dot_git_abs_path))
+        };
+
         log::trace!("constructed libgit2 repo in {:?}", t0.elapsed());
         let work_directory = RepositoryWorkDirectory(work_dir_path.clone());
 
@@ -2972,7 +2997,8 @@ impl BackgroundScannerState {
             LocalRepositoryEntry {
                 git_dir_scan_id: 0,
                 repo_ptr: repository.clone(),
-                git_dir_path: dot_git_path.clone(),
+                dot_git_dir_abs_path: actual_dot_git_dir_abs_path,
+                dot_git_worktree_abs_path,
             },
         );
 
@@ -3542,23 +3568,27 @@ impl BackgroundScanner {
             }
 
             let ancestor_dot_git = ancestor.join(*DOT_GIT);
-            if ancestor_dot_git.is_dir() {
+            // Check whether the directory or file called `.git` exists (in the
+            // case of worktrees it's a file.)
+            if self
+                .fs
+                .metadata(&ancestor_dot_git)
+                .await
+                .is_ok_and(|metadata| metadata.is_some())
+            {
                 if index != 0 {
                     // We canonicalize, since the FS events use the canonicalized path.
                     if let Some(ancestor_dot_git) =
                         self.fs.canonicalize(&ancestor_dot_git).await.log_err()
                     {
-                        let (ancestor_git_events, _) =
-                            self.fs.watch(&ancestor_dot_git, FS_WATCH_LATENCY).await;
-                        fs_events_rx = select(fs_events_rx, ancestor_git_events).boxed();
-
                         // We associate the external git repo with our root folder and
                         // also mark where in the git repo the root folder is located.
-                        self.state.lock().build_git_repository_for_path(
+                        self.state.lock().insert_git_repository_for_path(
                             Path::new("").into(),
                             ancestor_dot_git.into(),
                             Some(root_abs_path.strip_prefix(ancestor).unwrap().into()),
                             self.fs.as_ref(),
+                            self.watcher.as_ref(),
                         );
                     };
                 }
@@ -3578,7 +3608,7 @@ impl BackgroundScanner {
                     .ignore_stack_for_abs_path(&root_abs_path, true);
                 if ignore_stack.is_abs_path_ignored(&root_abs_path, true) {
                     root_entry.is_ignored = true;
-                    state.insert_entry(root_entry.clone(), self.fs.as_ref());
+                    state.insert_entry(root_entry.clone(), self.fs.as_ref(), self.watcher.as_ref());
                 }
                 state.enqueue_scan_dir(root_abs_path, &root_entry, &scan_job_tx);
             }
@@ -3708,7 +3738,7 @@ impl BackgroundScanner {
         };
 
         let mut relative_paths = Vec::with_capacity(abs_paths.len());
-        let mut dot_git_paths = Vec::new();
+        let mut dot_git_abs_paths = Vec::new();
         abs_paths.sort_unstable();
         abs_paths.dedup_by(|a, b| a.starts_with(b));
         abs_paths.retain(|abs_path| {
@@ -3723,7 +3753,7 @@ impl BackgroundScanner {
                     FsMonitor
                 }
                 let mut fsmonitor_parse_state = None;
-                if let Some(dot_git_dir) = abs_path
+                if let Some(dot_git_abs_path) = abs_path
                     .ancestors()
                     .find(|ancestor| {
                         let file_name = ancestor.file_name();
@@ -3742,12 +3772,9 @@ impl BackgroundScanner {
 
                     })
                 {
-                    let dot_git_path = dot_git_dir
-                        .strip_prefix(&root_canonical_path)
-                        .unwrap_or(dot_git_dir)
-                        .to_path_buf();
-                    if !dot_git_paths.contains(&dot_git_path) {
-                        dot_git_paths.push(dot_git_path);
+                    let dot_git_abs_path = dot_git_abs_path.to_path_buf();
+                    if !dot_git_abs_paths.contains(&dot_git_abs_path) {
+                        dot_git_abs_paths.push(dot_git_abs_path);
                     }
                     is_git_related = true;
                 }
@@ -3790,7 +3817,7 @@ impl BackgroundScanner {
             }
         });
 
-        if relative_paths.is_empty() && dot_git_paths.is_empty() {
+        if relative_paths.is_empty() && dot_git_abs_paths.is_empty() {
             return;
         }
 
@@ -3810,8 +3837,8 @@ impl BackgroundScanner {
         self.update_ignore_statuses(scan_job_tx).await;
         self.scan_dirs(false, scan_job_rx).await;
 
-        if !dot_git_paths.is_empty() {
-            self.update_git_repositories(dot_git_paths).await;
+        if !dot_git_abs_paths.is_empty() {
+            self.update_git_repositories(dot_git_abs_paths).await;
         }
 
         {
@@ -3995,10 +4022,12 @@ impl BackgroundScanner {
             let child_path: Arc<Path> = job.path.join(child_name).into();
 
             if child_name == *DOT_GIT {
-                let repo = self
-                    .state
-                    .lock()
-                    .build_git_repository(child_path.clone(), self.fs.as_ref());
+                let repo = self.state.lock().insert_git_repository(
+                    child_path.clone(),
+                    self.fs.as_ref(),
+                    self.watcher.as_ref(),
+                );
+
                 if let Some((work_directory, repository)) = repo {
                     let t0 = Instant::now();
                     let statuses = repository
@@ -4011,7 +4040,6 @@ impl BackgroundScanner {
                         statuses,
                     });
                 }
-                self.watcher.add(child_abs_path.as_ref()).log_err();
             } else if child_name == *GITIGNORE {
                 match build_gitignore(&child_abs_path, self.fs.as_ref()).await {
                     Ok(ignore) => {
@@ -4221,7 +4249,7 @@ impl BackgroundScanner {
             if let Some((repo_entry, repo)) = state.snapshot.repo_for_path(relative_path) {
                 if let Ok(repo_path) = repo_entry.relativize(&state.snapshot, relative_path) {
                     paths_by_git_repo
-                        .entry(repo.git_dir_path.clone())
+                        .entry(repo.dot_git_dir_abs_path.clone())
                         .or_insert_with(|| RepoPaths {
                             repo: repo.repo_ptr.clone(),
                             repo_paths: Vec::new(),
@@ -4281,7 +4309,7 @@ impl BackgroundScanner {
                         fs_entry.git_status = git_statuses_by_relative_path.remove(path);
                     }
 
-                    state.insert_entry(fs_entry.clone(), self.fs.as_ref());
+                    state.insert_entry(fs_entry.clone(), self.fs.as_ref(), self.watcher.as_ref());
                 }
                 Ok(None) => {
                     self.remove_repo_path(path, &mut state.snapshot);
@@ -4494,13 +4522,22 @@ impl BackgroundScanner {
                         .git_repositories
                         .iter()
                         .find_map(|(entry_id, repo)| {
-                            (repo.git_dir_path.as_ref() == dot_git_dir)
-                                .then(|| (*entry_id, repo.clone()))
+                            if repo.dot_git_dir_abs_path.as_ref() == &dot_git_dir
+                                || repo.dot_git_worktree_abs_path.as_deref() == Some(&dot_git_dir)
+                            {
+                                Some((*entry_id, repo.clone()))
+                            } else {
+                                None
+                            }
                         });
 
                 let (work_directory, repository) = match existing_repository_entry {
                     None => {
-                        match state.build_git_repository(dot_git_dir.into(), self.fs.as_ref()) {
+                        match state.insert_git_repository(
+                            dot_git_dir.into(),
+                            self.fs.as_ref(),
+                            self.watcher.as_ref(),
+                        ) {
                             Some(output) => output,
                             None => continue,
                         }
@@ -4555,19 +4592,14 @@ impl BackgroundScanner {
                     .map_or(false, |entry| {
                         snapshot.entry_for_path(entry.path.join(*DOT_GIT)).is_some()
                     });
-                if exists_in_snapshot {
+
+                if exists_in_snapshot
+                    || matches!(
+                        smol::block_on(self.fs.metadata(&entry.dot_git_dir_abs_path)),
+                        Ok(Some(_))
+                    )
+                {
                     ids_to_preserve.insert(work_directory_id);
-                } else {
-                    let git_dir_abs_path = snapshot.abs_path().join(&entry.git_dir_path);
-                    let git_dir_excluded = self.settings.is_path_excluded(&entry.git_dir_path);
-                    if git_dir_excluded
-                        && !matches!(
-                            smol::block_on(self.fs.metadata(&git_dir_abs_path)),
-                            Ok(None)
-                        )
-                    {
-                        ids_to_preserve.insert(work_directory_id);
-                    }
                 }
             }
 
@@ -4960,7 +4992,7 @@ impl WorktreeModelHandle for Model<Worktree> {
             let local_repo_entry = tree.get_local_repo(&root_entry).unwrap();
             (
                 tree.fs.clone(),
-                local_repo_entry.git_dir_path.clone(),
+                local_repo_entry.dot_git_dir_abs_path.clone(),
                 local_repo_entry.git_dir_scan_id,
             )
         });

crates/worktree/src/worktree_tests.rs 🔗

@@ -720,7 +720,7 @@ async fn test_rescan_with_gitignore(cx: &mut TestAppContext) {
     cx.read(|cx| {
         let tree = tree.read(cx);
         assert_entry_git_state(tree, "tracked-dir/tracked-file1", None, false);
-        assert_entry_git_state(tree, "tracked-dir/ancestor-ignored-file1", None, true);
+        assert_entry_git_state(tree, "tracked-dir/ancestor-ignored-file1", None, false);
         assert_entry_git_state(tree, "ignored-dir/ignored-file1", None, true);
     });
 
@@ -757,7 +757,7 @@ async fn test_rescan_with_gitignore(cx: &mut TestAppContext) {
             Some(GitFileStatus::Added),
             false,
         );
-        assert_entry_git_state(tree, "tracked-dir/ancestor-ignored-file2", None, true);
+        assert_entry_git_state(tree, "tracked-dir/ancestor-ignored-file2", None, false);
         assert_entry_git_state(tree, "ignored-dir/ignored-file2", None, true);
         assert!(tree.entry_for_path(".git").unwrap().is_ignored);
     });
@@ -843,7 +843,7 @@ async fn test_write_file(cx: &mut TestAppContext) {
     .unwrap();
 
     #[cfg(target_os = "linux")]
-    fs::watcher::global(|_| {}).unwrap();
+    fs::linux_watcher::global(|_| {}).unwrap();
 
     cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
         .await;
@@ -2635,6 +2635,12 @@ fn assert_entry_git_state(
     is_ignored: bool,
 ) {
     let entry = tree.entry_for_path(path).expect("entry {path} not found");
-    assert_eq!(entry.git_status, git_status);
-    assert_eq!(entry.is_ignored, is_ignored);
+    assert_eq!(
+        entry.git_status, git_status,
+        "expected {path} to have git status: {git_status:?}"
+    );
+    assert_eq!(
+        entry.is_ignored, is_ignored,
+        "expected {path} to have is_ignored: {is_ignored}"
+    );
 }

crates/zed/src/zed.rs 🔗

@@ -154,7 +154,7 @@ pub fn initialize_workspace(
         .detach();
 
         #[cfg(target_os = "linux")]
-        if let Err(e) = fs::watcher::global(|_| {}) {
+        if let Err(e) = fs::linux_watcher::global(|_| {}) {
             let message = format!(db::indoc!{r#"
                 inotify_init returned {}