Add handling of git's core.excludesFile (#33592)

Cole Miller and Paul Nameless created

Taking over from #28314.

Part of https://github.com/zed-industries/zed/issues/4824

Co-authored-by: Paul Nameless <reacsdas@gmail.com>

Release Notes:

- Zed now respects git's `core.excludesFile` (~/.config/git/ignore) in
addition to .gitignore.

---------

Co-authored-by: Paul Nameless <reacsdas@gmail.com>

Change summary

Cargo.lock                                            |   2 
crates/agent2/src/db.rs                               |   4 
crates/client/src/telemetry.rs                        |   4 
crates/fs/src/fs.rs                                   |  15 
crates/paths/Cargo.toml                               |   4 
crates/paths/src/paths.rs                             |  13 
crates/project/src/project_tests.rs                   |  24 
crates/terminal/Cargo.toml                            |   1 
crates/terminal/src/terminal.rs                       |  14 
crates/terminal_view/src/terminal_path_like_target.rs |   3 
crates/util/src/paths.rs                              |  24 
crates/worktree/Cargo.toml                            |   1 
crates/worktree/src/ignore.rs                         |  80 ++
crates/worktree/src/worktree.rs                       | 289 +++++++++---
crates/worktree/src/worktree_tests.rs                 | 112 ++++
15 files changed, 454 insertions(+), 136 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -11598,6 +11598,7 @@ name = "paths"
 version = "0.1.0"
 dependencies = [
  "dirs 4.0.0",
+ "ignore",
  "util",
  "workspace-hack",
 ]
@@ -16437,7 +16438,6 @@ dependencies = [
  "alacritty_terminal",
  "anyhow",
  "collections",
- "dirs 4.0.0",
  "futures 0.3.31",
  "gpui",
  "libc",

crates/agent2/src/db.rs 🔗

@@ -428,7 +428,9 @@ mod tests {
     use http_client::FakeHttpClient;
     use language_model::Role;
     use project::Project;
+    use serde_json::json;
     use settings::SettingsStore;
+    use util::test::TempTree;
 
     fn init_test(cx: &mut TestAppContext) {
         env_logger::try_init().ok();
@@ -449,6 +451,8 @@ mod tests {
 
     #[gpui::test]
     async fn test_retrieving_old_thread(cx: &mut TestAppContext) {
+        let tree = TempTree::new(json!({}));
+        util::paths::set_home_dir(tree.path().into());
         init_test(cx);
         let fs = FakeFs::new(cx.executor());
         let project = Project::test(fs, [], cx).await;

crates/client/src/telemetry.rs 🔗

@@ -19,7 +19,7 @@ use std::sync::LazyLock;
 use std::time::Instant;
 use std::{env, mem, path::PathBuf, sync::Arc, time::Duration};
 use telemetry_events::{AssistantEventData, AssistantPhase, Event, EventRequestBody, EventWrapper};
-use util::{ResultExt, TryFutureExt};
+use util::TryFutureExt;
 use worktree::{UpdatedEntriesSet, WorktreeId};
 
 use self::event_coalescer::EventCoalescer;
@@ -209,7 +209,7 @@ impl Telemetry {
             let os_version = os_version();
             state.lock().os_version = Some(os_version);
             async move {
-                if let Some(tempfile) = File::create(Self::log_file_path()).log_err() {
+                if let Some(tempfile) = File::create(Self::log_file_path()).ok() {
                     state.lock().log_file = Some(tempfile);
                 }
             }

crates/fs/src/fs.rs 🔗

@@ -134,7 +134,6 @@ pub trait Fs: Send + Sync {
         Arc<dyn Watcher>,
     );
 
-    fn home_dir(&self) -> Option<PathBuf>;
     fn open_repo(&self, abs_dot_git: &Path) -> Option<Arc<dyn GitRepository>>;
     fn git_init(&self, abs_work_directory: &Path, fallback_branch_name: String) -> Result<()>;
     async fn git_clone(&self, repo_url: &str, abs_work_directory: &Path) -> Result<()>;
@@ -916,10 +915,6 @@ impl Fs for RealFs {
         temp_dir.close()?;
         case_sensitive
     }
-
-    fn home_dir(&self) -> Option<PathBuf> {
-        Some(paths::home_dir().clone())
-    }
 }
 
 #[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
@@ -954,7 +949,6 @@ struct FakeFsState {
     read_dir_call_count: usize,
     path_write_counts: std::collections::HashMap<PathBuf, usize>,
     moves: std::collections::HashMap<u64, PathBuf>,
-    home_dir: Option<PathBuf>,
 }
 
 #[cfg(any(test, feature = "test-support"))]
@@ -1239,7 +1233,6 @@ impl FakeFs {
                 metadata_call_count: 0,
                 path_write_counts: Default::default(),
                 moves: Default::default(),
-                home_dir: None,
             })),
         });
 
@@ -1902,10 +1895,6 @@ impl FakeFs {
     fn simulate_random_delay(&self) -> impl futures::Future<Output = ()> {
         self.executor.simulate_random_delay()
     }
-
-    pub fn set_home_dir(&self, home_dir: PathBuf) {
-        self.state.lock().home_dir = Some(home_dir);
-    }
 }
 
 #[cfg(any(test, feature = "test-support"))]
@@ -2499,10 +2488,6 @@ impl Fs for FakeFs {
     fn as_fake(&self) -> Arc<FakeFs> {
         self.this.upgrade().unwrap()
     }
-
-    fn home_dir(&self) -> Option<PathBuf> {
-        self.state.lock().home_dir.clone()
-    }
 }
 
 fn chunks(rope: &Rope, line_ending: LineEnding) -> impl Iterator<Item = &str> {

crates/paths/Cargo.toml 🔗

@@ -8,10 +8,14 @@ license = "GPL-3.0-or-later"
 [lints]
 workspace = true
 
+[features]
+test-support = []
+
 [lib]
 path = "src/paths.rs"
 
 [dependencies]
 dirs.workspace = true
+ignore.workspace = true
 util.workspace = true
 workspace-hack.workspace = true

crates/paths/src/paths.rs 🔗

@@ -520,3 +520,16 @@ fn add_vscode_user_data_paths(paths: &mut Vec<PathBuf>, product_name: &str) {
         );
     }
 }
+
+#[cfg(any(test, feature = "test-support"))]
+pub fn global_gitignore_path() -> Option<PathBuf> {
+    Some(home_dir().join(".config").join("git").join("ignore"))
+}
+
+#[cfg(not(any(test, feature = "test-support")))]
+pub fn global_gitignore_path() -> Option<PathBuf> {
+    static GLOBAL_GITIGNORE_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
+    GLOBAL_GITIGNORE_PATH
+        .get_or_init(::ignore::gitignore::gitconfig_excludes_path)
+        .clone()
+}

crates/project/src/project_tests.rs 🔗

@@ -31,7 +31,7 @@ use lsp::{
     Uri, WillRenameFiles, notification::DidRenameFiles,
 };
 use parking_lot::Mutex;
-use paths::{config_dir, tasks_file};
+use paths::{config_dir, global_gitignore_path, tasks_file};
 use postage::stream::Stream as _;
 use pretty_assertions::{assert_eq, assert_matches};
 use rand::{Rng as _, rngs::StdRng};
@@ -1391,7 +1391,9 @@ async fn test_reporting_fs_changes_to_language_servers(cx: &mut gpui::TestAppCon
     assert_eq!(fs.read_dir_call_count() - prev_read_dir_count, 5);
 
     let mut new_watched_paths = fs.watched_paths();
-    new_watched_paths.retain(|path| !path.starts_with(config_dir()));
+    new_watched_paths.retain(|path| {
+        !path.starts_with(config_dir()) && !path.starts_with(global_gitignore_path().unwrap())
+    });
     assert_eq!(
         &new_watched_paths,
         &[
@@ -7942,21 +7944,19 @@ async fn test_repository_and_path_for_project_path(
 async fn test_home_dir_as_git_repository(cx: &mut gpui::TestAppContext) {
     init_test(cx);
     let fs = FakeFs::new(cx.background_executor.clone());
+    let home = paths::home_dir();
     fs.insert_tree(
-        path!("/root"),
+        home,
         json!({
-            "home": {
-                ".git": {},
-                "project": {
-                    "a.txt": "A"
-                },
+            ".git": {},
+            "project": {
+                "a.txt": "A"
             },
         }),
     )
     .await;
-    fs.set_home_dir(Path::new(path!("/root/home")).to_owned());
 
-    let project = Project::test(fs.clone(), [path!("/root/home/project").as_ref()], cx).await;
+    let project = Project::test(fs.clone(), [home.join("project").as_ref()], cx).await;
     let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap());
     let tree_id = tree.read_with(cx, |tree, _| tree.id());
 
@@ -7973,7 +7973,7 @@ async fn test_home_dir_as_git_repository(cx: &mut gpui::TestAppContext) {
         assert!(containing.is_none());
     });
 
-    let project = Project::test(fs.clone(), [path!("/root/home").as_ref()], cx).await;
+    let project = Project::test(fs.clone(), [home.as_ref()], cx).await;
     let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap());
     let tree_id = tree.read_with(cx, |tree, _| tree.id());
     project
@@ -7993,7 +7993,7 @@ async fn test_home_dir_as_git_repository(cx: &mut gpui::TestAppContext) {
                 .read(cx)
                 .work_directory_abs_path
                 .as_ref(),
-            Path::new(path!("/root/home"))
+            home,
         );
     });
 }

crates/terminal/Cargo.toml 🔗

@@ -23,7 +23,6 @@ doctest = false
 alacritty_terminal.workspace = true
 anyhow.workspace = true
 collections.workspace = true
-dirs.workspace = true
 futures.workspace = true
 gpui.workspace = true
 libc.workspace = true

crates/terminal/src/terminal.rs 🔗

@@ -48,7 +48,10 @@ use terminal_hyperlinks::RegexSearches;
 use terminal_settings::{AlternateScroll, CursorShape, TerminalSettings};
 use theme::{ActiveTheme, Theme};
 use urlencoding;
-use util::{paths::home_dir, truncate_and_trailoff};
+use util::{
+    paths::{self, home_dir},
+    truncate_and_trailoff,
+};
 
 use std::{
     borrow::Cow,
@@ -291,12 +294,11 @@ impl TerminalError {
                     Err(s) => s,
                 }
             })
-            .unwrap_or_else(|| match dirs::home_dir() {
-                Some(dir) => format!(
+            .unwrap_or_else(|| {
+                format!(
                     "<none specified, using home directory> {}",
-                    dir.into_os_string().to_string_lossy()
-                ),
-                None => "<none specified, could not find home directory>".to_string(),
+                    paths::home_dir().to_string_lossy()
+                )
             })
     }
 

crates/terminal_view/src/terminal_path_like_target.rs 🔗

@@ -546,9 +546,10 @@ mod tests {
         let (workspace, cx) =
             app_cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
 
+        let cwd = std::env::current_dir().expect("Failed to get working directory");
         let terminal = project
             .update(cx, |project: &mut Project, cx| {
-                project.create_terminal_shell(None, cx)
+                project.create_terminal_shell(Some(cwd), cx)
             })
             .await
             .expect("Failed to create a terminal");

crates/util/src/paths.rs 🔗

@@ -12,10 +12,30 @@ use std::{
     sync::LazyLock,
 };
 
+static HOME_DIR: OnceLock<PathBuf> = OnceLock::new();
+
 /// Returns the path to the user's home directory.
 pub fn home_dir() -> &'static PathBuf {
-    static HOME_DIR: OnceLock<PathBuf> = OnceLock::new();
-    HOME_DIR.get_or_init(|| dirs::home_dir().expect("failed to determine home directory"))
+    HOME_DIR.get_or_init(|| {
+        if cfg!(any(test, feature = "test-support")) {
+            if cfg!(target_os = "macos") {
+                PathBuf::from("/Users/zed")
+            } else if cfg!(target_os = "windows") {
+                PathBuf::from("C:\\Users\\zed")
+            } else {
+                PathBuf::from("/home/zed")
+            }
+        } else {
+            dirs::home_dir().expect("failed to determine home directory")
+        }
+    })
+}
+
+#[cfg(any(test, feature = "test-support"))]
+pub fn set_home_dir(path: PathBuf) {
+    HOME_DIR
+        .set(path)
+        .expect("set_home_dir called after home_dir was already accessed");
 }
 
 pub trait PathExt {

crates/worktree/Cargo.toml 🔗

@@ -55,6 +55,7 @@ collections = { workspace = true, features = ["test-support"] }
 git2.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
 http_client.workspace = true
+paths = { workspace = true, features = ["test-support"] }
 pretty_assertions.workspace = true
 rand.workspace = true
 rpc = { workspace = true, features = ["test-support"] }

crates/worktree/src/ignore.rs 🔗

@@ -1,34 +1,60 @@
 use ignore::gitignore::Gitignore;
 use std::{ffi::OsStr, path::Path, sync::Arc};
 
+#[derive(Clone, Debug)]
+pub struct IgnoreStack {
+    pub repo_root: Option<Arc<Path>>,
+    pub top: Arc<IgnoreStackEntry>,
+}
+
 #[derive(Debug)]
-pub enum IgnoreStack {
+pub enum IgnoreStackEntry {
     None,
+    Global {
+        ignore: Arc<Gitignore>,
+    },
     Some {
         abs_base_path: Arc<Path>,
         ignore: Arc<Gitignore>,
-        parent: Arc<IgnoreStack>,
+        parent: Arc<IgnoreStackEntry>,
     },
     All,
 }
 
 impl IgnoreStack {
-    pub fn none() -> Arc<Self> {
-        Arc::new(Self::None)
+    pub fn none() -> Self {
+        Self {
+            repo_root: None,
+            top: Arc::new(IgnoreStackEntry::None),
+        }
     }
 
-    pub fn all() -> Arc<Self> {
-        Arc::new(Self::All)
+    pub fn all() -> Self {
+        Self {
+            repo_root: None,
+            top: Arc::new(IgnoreStackEntry::All),
+        }
+    }
+
+    pub fn global(ignore: Arc<Gitignore>) -> Self {
+        Self {
+            repo_root: None,
+            top: Arc::new(IgnoreStackEntry::Global { ignore }),
+        }
     }
 
-    pub fn append(self: Arc<Self>, abs_base_path: Arc<Path>, ignore: Arc<Gitignore>) -> Arc<Self> {
-        match self.as_ref() {
-            IgnoreStack::All => self,
-            _ => Arc::new(Self::Some {
+    pub fn append(self, abs_base_path: Arc<Path>, ignore: Arc<Gitignore>) -> Self {
+        let top = match self.top.as_ref() {
+            IgnoreStackEntry::All => self.top.clone(),
+            _ => Arc::new(IgnoreStackEntry::Some {
                 abs_base_path,
                 ignore,
-                parent: self,
+                parent: self.top.clone(),
             }),
+        };
+        Self {
+            repo_root: self.repo_root,
+            top,
         }
     }
 
@@ -37,15 +63,37 @@ impl IgnoreStack {
             return true;
         }
 
-        match self {
-            Self::None => false,
-            Self::All => true,
-            Self::Some {
+        match self.top.as_ref() {
+            IgnoreStackEntry::None => false,
+            IgnoreStackEntry::All => true,
+            IgnoreStackEntry::Global { ignore } => {
+                let combined_path;
+                let abs_path = if let Some(repo_root) = self.repo_root.as_ref() {
+                    combined_path = ignore.path().join(
+                        abs_path
+                            .strip_prefix(repo_root)
+                            .expect("repo root should be a parent of matched path"),
+                    );
+                    &combined_path
+                } else {
+                    abs_path
+                };
+                match ignore.matched(abs_path, is_dir) {
+                    ignore::Match::None => false,
+                    ignore::Match::Ignore(_) => true,
+                    ignore::Match::Whitelist(_) => false,
+                }
+            }
+            IgnoreStackEntry::Some {
                 abs_base_path,
                 ignore,
                 parent: prev,
             } => match ignore.matched(abs_path.strip_prefix(abs_base_path).unwrap(), is_dir) {
-                ignore::Match::None => prev.is_abs_path_ignored(abs_path, is_dir),
+                ignore::Match::None => IgnoreStack {
+                    repo_root: self.repo_root.clone(),
+                    top: prev.clone(),
+                }
+                .is_abs_path_ignored(abs_path, is_dir),
                 ignore::Match::Ignore(_) => true,
                 ignore::Match::Whitelist(_) => false,
             },

crates/worktree/src/worktree.rs 🔗

@@ -65,7 +65,7 @@ use std::{
 use sum_tree::{Bias, Dimensions, Edit, KeyedItem, SeekTarget, SumTree, Summary, TreeMap, TreeSet};
 use text::{LineEnding, Rope};
 use util::{
-    ResultExt,
+    ResultExt, debug_panic,
     paths::{PathMatcher, SanitizedPath, home_dir},
 };
 pub use worktree_settings::WorktreeSettings;
@@ -336,26 +336,10 @@ impl Default for WorkDirectory {
     }
 }
 
-#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq)]
-pub struct WorkDirectoryEntry(ProjectEntryId);
-
-impl Deref for WorkDirectoryEntry {
-    type Target = ProjectEntryId;
-
-    fn deref(&self) -> &Self::Target {
-        &self.0
-    }
-}
-
-impl From<ProjectEntryId> for WorkDirectoryEntry {
-    fn from(value: ProjectEntryId) -> Self {
-        WorkDirectoryEntry(value)
-    }
-}
-
 #[derive(Debug, Clone)]
 pub struct LocalSnapshot {
     snapshot: Snapshot,
+    global_gitignore: Option<Arc<Gitignore>>,
     /// All of the gitignore files in the worktree, indexed by their relative path.
     /// The boolean indicates whether the gitignore needs to be updated.
     ignores_by_parent_abs_path: HashMap<Arc<Path>, (Arc<Gitignore>, bool)>,
@@ -504,6 +488,7 @@ impl Worktree {
         cx.new(move |cx: &mut Context<Worktree>| {
             let mut snapshot = LocalSnapshot {
                 ignores_by_parent_abs_path: Default::default(),
+                global_gitignore: Default::default(),
                 git_repositories: Default::default(),
                 snapshot: Snapshot::new(
                     cx.entity_id().as_u64(),
@@ -2807,8 +2792,9 @@ impl LocalSnapshot {
         inodes
     }
 
-    fn ignore_stack_for_abs_path(&self, abs_path: &Path, is_dir: bool) -> Arc<IgnoreStack> {
+    fn ignore_stack_for_abs_path(&self, abs_path: &Path, is_dir: bool, fs: &dyn Fs) -> IgnoreStack {
         let mut new_ignores = Vec::new();
+        let mut repo_root = None;
         for (index, ancestor) in abs_path.ancestors().enumerate() {
             if index > 0 {
                 if let Some((ignore, _)) = self.ignores_by_parent_abs_path.get(ancestor) {
@@ -2817,12 +2803,21 @@ impl LocalSnapshot {
                     new_ignores.push((ancestor, None));
                 }
             }
-            if ancestor.join(*DOT_GIT).exists() {
+            let metadata = smol::block_on(fs.metadata(&ancestor.join(*DOT_GIT)))
+                .ok()
+                .flatten();
+            if metadata.is_some() {
+                repo_root = Some(Arc::from(ancestor));
                 break;
             }
         }
 
-        let mut ignore_stack = IgnoreStack::none();
+        let mut ignore_stack = if let Some(global_gitignore) = self.global_gitignore.clone() {
+            IgnoreStack::global(global_gitignore)
+        } else {
+            IgnoreStack::none()
+        };
+        ignore_stack.repo_root = repo_root;
         for (parent_abs_path, ignore) in new_ignores.into_iter().rev() {
             if ignore_stack.is_abs_path_ignored(parent_abs_path, true) {
                 ignore_stack = IgnoreStack::all();
@@ -2949,9 +2944,15 @@ impl BackgroundScannerState {
                 .any(|p| entry.path.starts_with(p))
     }
 
-    fn enqueue_scan_dir(&self, abs_path: Arc<Path>, entry: &Entry, scan_job_tx: &Sender<ScanJob>) {
+    fn enqueue_scan_dir(
+        &self,
+        abs_path: Arc<Path>,
+        entry: &Entry,
+        scan_job_tx: &Sender<ScanJob>,
+        fs: &dyn Fs,
+    ) {
         let path = entry.path.clone();
-        let ignore_stack = self.snapshot.ignore_stack_for_abs_path(&abs_path, true);
+        let ignore_stack = self.snapshot.ignore_stack_for_abs_path(&abs_path, true, fs);
         let mut ancestor_inodes = self.snapshot.ancestor_inodes_for_path(&path);
 
         if !ancestor_inodes.contains(&entry.inode) {
@@ -3860,14 +3861,36 @@ impl BackgroundScanner {
 
         log::trace!("containing git repository: {containing_git_repository:?}");
 
+        let global_gitignore_path = paths::global_gitignore_path();
+        self.state.lock().snapshot.global_gitignore =
+            if let Some(global_gitignore_path) = global_gitignore_path.as_ref() {
+                build_gitignore(global_gitignore_path, self.fs.as_ref())
+                    .await
+                    .log_err()
+                    .map(Arc::new)
+            } else {
+                None
+            };
+        let mut global_gitignore_events = if let Some(global_gitignore_path) = global_gitignore_path
+        {
+            self.fs
+                .watch(&global_gitignore_path, FS_WATCH_LATENCY)
+                .await
+                .0
+        } else {
+            Box::pin(futures::stream::empty())
+        };
+
         let (scan_job_tx, scan_job_rx) = channel::unbounded();
         {
             let mut state = self.state.lock();
             state.snapshot.scan_id += 1;
             if let Some(mut root_entry) = state.snapshot.root_entry().cloned() {
-                let ignore_stack = state
-                    .snapshot
-                    .ignore_stack_for_abs_path(root_abs_path.as_path(), true);
+                let ignore_stack = state.snapshot.ignore_stack_for_abs_path(
+                    root_abs_path.as_path(),
+                    true,
+                    self.fs.as_ref(),
+                );
                 if ignore_stack.is_abs_path_ignored(root_abs_path.as_path(), true) {
                     root_entry.is_ignored = true;
                     state.insert_entry(root_entry.clone(), self.fs.as_ref(), self.watcher.as_ref());
@@ -3876,6 +3899,7 @@ impl BackgroundScanner {
                     SanitizedPath::cast_arc(root_abs_path),
                     &root_entry,
                     &scan_job_tx,
+                    self.fs.as_ref(),
                 );
             }
         };
@@ -3946,6 +3970,15 @@ impl BackgroundScanner {
                     }
                     self.process_events(paths.into_iter().map(Into::into).collect()).await;
                 }
+
+                paths = global_gitignore_events.next().fuse() => {
+                    match paths.as_deref() {
+                        Some([event, ..]) => {
+                            self.update_global_gitignore(&event.path).await;
+                        }
+                        _ => {},
+                    }
+                }
             }
         }
     }
@@ -4130,11 +4163,20 @@ impl BackgroundScanner {
         )
         .await;
 
-        self.update_ignore_statuses(scan_job_tx).await;
-        self.scan_dirs(false, scan_job_rx).await;
+        let affected_repo_roots = if !dot_git_abs_paths.is_empty() {
+            self.update_git_repositories(dot_git_abs_paths)
+        } else {
+            Vec::new()
+        };
 
-        if !dot_git_abs_paths.is_empty() {
-            self.update_git_repositories(dot_git_abs_paths);
+        {
+            let mut ignores_to_update = self.ignores_needing_update();
+            ignores_to_update.extend(affected_repo_roots);
+            let ignores_to_update = self.order_ignores(ignores_to_update);
+            let snapshot = self.state.lock().snapshot.clone();
+            self.update_ignore_statuses_for_paths(scan_job_tx, snapshot, ignores_to_update)
+                .await;
+            self.scan_dirs(false, scan_job_rx).await;
         }
 
         {
@@ -4147,6 +4189,32 @@ impl BackgroundScanner {
         self.send_status_update(false, SmallVec::new());
     }
 
+    async fn update_global_gitignore(&self, abs_path: &Path) {
+        let ignore = build_gitignore(abs_path, self.fs.as_ref())
+            .await
+            .log_err()
+            .map(Arc::new);
+        let (prev_snapshot, ignore_stack, abs_path) = {
+            let mut state = self.state.lock();
+            state.snapshot.global_gitignore = ignore;
+            let abs_path = state.snapshot.abs_path().clone();
+            let ignore_stack =
+                state
+                    .snapshot
+                    .ignore_stack_for_abs_path(&abs_path, true, self.fs.as_ref());
+            (state.snapshot.clone(), ignore_stack, abs_path)
+        };
+        let (scan_job_tx, scan_job_rx) = channel::unbounded();
+        self.update_ignore_statuses_for_paths(
+            scan_job_tx,
+            prev_snapshot,
+            vec![(abs_path, ignore_stack)].into_iter(),
+        )
+        .await;
+        self.scan_dirs(false, scan_job_rx).await;
+        self.send_status_update(false, SmallVec::new());
+    }
+
     async fn forcibly_load_paths(&self, paths: &[Arc<Path>]) -> bool {
         let (scan_job_tx, scan_job_rx) = channel::unbounded();
         {
@@ -4158,7 +4226,12 @@ impl BackgroundScanner {
                         && entry.kind == EntryKind::UnloadedDir
                     {
                         let abs_path = root_path.as_path().join(ancestor);
-                        state.enqueue_scan_dir(abs_path.into(), entry, &scan_job_tx);
+                        state.enqueue_scan_dir(
+                            abs_path.into(),
+                            entry,
+                            &scan_job_tx,
+                            self.fs.as_ref(),
+                        );
                         state.paths_to_scan.insert(path.clone());
                         break;
                     }
@@ -4309,6 +4382,12 @@ impl BackgroundScanner {
         swap_to_front(&mut child_paths, *GITIGNORE);
         swap_to_front(&mut child_paths, *DOT_GIT);
 
+        if let Some(path) = child_paths.first()
+            && path.ends_with(*DOT_GIT)
+        {
+            ignore_stack.repo_root = Some(job.abs_path.clone());
+        }
+
         for child_abs_path in child_paths {
             let child_abs_path: Arc<Path> = child_abs_path.into();
             let child_name = child_abs_path.file_name().unwrap();
@@ -4534,9 +4613,11 @@ impl BackgroundScanner {
             let abs_path: Arc<Path> = root_abs_path.as_path().join(path).into();
             match metadata {
                 Ok(Some((metadata, canonical_path))) => {
-                    let ignore_stack = state
-                        .snapshot
-                        .ignore_stack_for_abs_path(&abs_path, metadata.is_dir);
+                    let ignore_stack = state.snapshot.ignore_stack_for_abs_path(
+                        &abs_path,
+                        metadata.is_dir,
+                        self.fs.as_ref(),
+                    );
                     let is_external = !canonical_path.starts_with(&root_canonical_path);
                     let mut fs_entry = Entry::new(
                         path.clone(),
@@ -4561,7 +4642,12 @@ impl BackgroundScanner {
                             || (fs_entry.path.as_os_str().is_empty()
                                 && abs_path.file_name() == Some(*DOT_GIT))
                         {
-                            state.enqueue_scan_dir(abs_path, &fs_entry, scan_queue_tx);
+                            state.enqueue_scan_dir(
+                                abs_path,
+                                &fs_entry,
+                                scan_queue_tx,
+                                self.fs.as_ref(),
+                            );
                         } else {
                             fs_entry.kind = EntryKind::UnloadedDir;
                         }
@@ -4616,43 +4702,15 @@ impl BackgroundScanner {
         Some(())
     }
 
-    async fn update_ignore_statuses(&self, scan_job_tx: Sender<ScanJob>) {
-        let mut ignores_to_update = Vec::new();
+    async fn update_ignore_statuses_for_paths(
+        &self,
+        scan_job_tx: Sender<ScanJob>,
+        prev_snapshot: LocalSnapshot,
+        mut ignores_to_update: impl Iterator<Item = (Arc<Path>, IgnoreStack)>,
+    ) {
         let (ignore_queue_tx, ignore_queue_rx) = channel::unbounded();
-        let prev_snapshot;
         {
-            let snapshot = &mut self.state.lock().snapshot;
-            let abs_path = snapshot.abs_path.clone();
-            snapshot
-                .ignores_by_parent_abs_path
-                .retain(|parent_abs_path, (_, needs_update)| {
-                    if let Ok(parent_path) = parent_abs_path.strip_prefix(abs_path.as_path()) {
-                        if *needs_update {
-                            *needs_update = false;
-                            if snapshot.snapshot.entry_for_path(parent_path).is_some() {
-                                ignores_to_update.push(parent_abs_path.clone());
-                            }
-                        }
-
-                        let ignore_path = parent_path.join(*GITIGNORE);
-                        if snapshot.snapshot.entry_for_path(ignore_path).is_none() {
-                            return false;
-                        }
-                    }
-                    true
-                });
-
-            ignores_to_update.sort_unstable();
-            let mut ignores_to_update = ignores_to_update.into_iter().peekable();
-            while let Some(parent_abs_path) = ignores_to_update.next() {
-                while ignores_to_update
-                    .peek()
-                    .is_some_and(|p| p.starts_with(&parent_abs_path))
-                {
-                    ignores_to_update.next().unwrap();
-                }
-
-                let ignore_stack = snapshot.ignore_stack_for_abs_path(&parent_abs_path, true);
+            while let Some((parent_abs_path, ignore_stack)) = ignores_to_update.next() {
                 ignore_queue_tx
                     .send_blocking(UpdateIgnoreStatusJob {
                         abs_path: parent_abs_path,
@@ -4662,8 +4720,6 @@ impl BackgroundScanner {
                     })
                     .unwrap();
             }
-
-            prev_snapshot = snapshot.clone();
         }
         drop(ignore_queue_tx);
 
@@ -4695,6 +4751,57 @@ impl BackgroundScanner {
             .await;
     }
 
+    fn ignores_needing_update(&self) -> Vec<Arc<Path>> {
+        let mut ignores_to_update = Vec::new();
+
+        {
+            let snapshot = &mut self.state.lock().snapshot;
+            let abs_path = snapshot.abs_path.clone();
+            snapshot
+                .ignores_by_parent_abs_path
+                .retain(|parent_abs_path, (_, needs_update)| {
+                    if let Ok(parent_path) = parent_abs_path.strip_prefix(abs_path.as_path()) {
+                        if *needs_update {
+                            *needs_update = false;
+                            if snapshot.snapshot.entry_for_path(parent_path).is_some() {
+                                ignores_to_update.push(parent_abs_path.clone());
+                            }
+                        }
+
+                        let ignore_path = parent_path.join(*GITIGNORE);
+                        if snapshot.snapshot.entry_for_path(ignore_path).is_none() {
+                            return false;
+                        }
+                    }
+                    true
+                });
+        }
+
+        ignores_to_update
+    }
+
+    fn order_ignores(
+        &self,
+        mut ignores: Vec<Arc<Path>>,
+    ) -> impl use<> + Iterator<Item = (Arc<Path>, IgnoreStack)> {
+        let fs = self.fs.clone();
+        let snapshot = self.state.lock().snapshot.clone();
+        ignores.sort_unstable();
+        let mut ignores_to_update = ignores.into_iter().peekable();
+        std::iter::from_fn(move || {
+            let parent_abs_path = ignores_to_update.next()?;
+            while ignores_to_update
+                .peek()
+                .map_or(false, |p| p.starts_with(&parent_abs_path))
+            {
+                ignores_to_update.next().unwrap();
+            }
+            let ignore_stack =
+                snapshot.ignore_stack_for_abs_path(&parent_abs_path, true, fs.as_ref());
+            Some((parent_abs_path, ignore_stack))
+        })
+    }
+
     async fn update_ignore_status(&self, job: UpdateIgnoreStatusJob, snapshot: &LocalSnapshot) {
         log::trace!("update ignore status {:?}", job.abs_path);
 
@@ -4710,6 +4817,12 @@ impl BackgroundScanner {
             .strip_prefix(snapshot.abs_path.as_path())
             .unwrap();
 
+        if let Ok(Some(metadata)) = smol::block_on(self.fs.metadata(&job.abs_path.join(*DOT_GIT)))
+            && metadata.is_dir
+        {
+            ignore_stack.repo_root = Some(job.abs_path.clone());
+        }
+
         for mut entry in snapshot.child_entries(path).cloned() {
             let was_ignored = entry.is_ignored;
             let abs_path: Arc<Path> = snapshot.abs_path().join(&entry.path).into();
@@ -4726,7 +4839,12 @@ impl BackgroundScanner {
                 if was_ignored && !entry.is_ignored && entry.kind.is_unloaded() {
                     let state = self.state.lock();
                     if state.should_scan_directory(&entry) {
-                        state.enqueue_scan_dir(abs_path.clone(), &entry, &job.scan_queue);
+                        state.enqueue_scan_dir(
+                            abs_path.clone(),
+                            &entry,
+                            &job.scan_queue,
+                            self.fs.as_ref(),
+                        );
                     }
                 }
 
@@ -4766,10 +4884,11 @@ impl BackgroundScanner {
         state.snapshot.entries_by_id.edit(entries_by_id_edits, &());
     }
 
-    fn update_git_repositories(&self, dot_git_paths: Vec<PathBuf>) {
+    fn update_git_repositories(&self, dot_git_paths: Vec<PathBuf>) -> Vec<Arc<Path>> {
         log::trace!("reloading repositories: {dot_git_paths:?}");
         let mut state = self.state.lock();
         let scan_id = state.snapshot.scan_id;
+        let mut affected_repo_roots = Vec::new();
         for dot_git_dir in dot_git_paths {
             let existing_repository_entry =
                 state
@@ -4791,8 +4910,12 @@ impl BackgroundScanner {
             match existing_repository_entry {
                 None => {
                     let Ok(relative) = dot_git_dir.strip_prefix(state.snapshot.abs_path()) else {
-                        return;
+                        debug_panic!(
+                            "update_git_repositories called with .git directory outside the worktree root"
+                        );
+                        return Vec::new();
                     };
+                    affected_repo_roots.push(dot_git_dir.parent().unwrap().into());
                     state.insert_git_repository(
                         relative.into(),
                         self.fs.as_ref(),
@@ -4830,7 +4953,15 @@ impl BackgroundScanner {
 
         snapshot
             .git_repositories
-            .retain(|work_directory_id, _| ids_to_preserve.contains(work_directory_id));
+            .retain(|work_directory_id, entry| {
+                let preserve = ids_to_preserve.contains(work_directory_id);
+                if !preserve {
+                    affected_repo_roots.push(entry.dot_git_abs_path.parent().unwrap().into());
+                }
+                preserve
+            });
+
+        affected_repo_roots
     }
 
     async fn progress_timer(&self, running: bool) {
@@ -4870,7 +5001,7 @@ async fn discover_ancestor_git_repo(
     let mut ignores = HashMap::default();
     for (index, ancestor) in root_abs_path.as_path().ancestors().enumerate() {
         if index != 0 {
-            if Some(ancestor) == fs.home_dir().as_deref() {
+            if ancestor == paths::home_dir() {
                 // Unless $HOME is itself the worktree root, don't consider it as a
                 // containing git repository---expensive and likely unwanted.
                 break;
@@ -5052,7 +5183,7 @@ fn char_bag_for_path(root_char_bag: CharBag, path: &Path) -> CharBag {
 struct ScanJob {
     abs_path: Arc<Path>,
     path: Arc<Path>,
-    ignore_stack: Arc<IgnoreStack>,
+    ignore_stack: IgnoreStack,
     scan_queue: Sender<ScanJob>,
     ancestor_inodes: TreeSet<u64>,
     is_external: bool,
@@ -5060,7 +5191,7 @@ struct ScanJob {
 
 struct UpdateIgnoreStatusJob {
     abs_path: Arc<Path>,
-    ignore_stack: Arc<IgnoreStack>,
+    ignore_stack: IgnoreStack,
     ignore_queue: Sender<UpdateIgnoreStatusJob>,
     scan_queue: Sender<ScanJob>,
 }

crates/worktree/src/worktree_tests.rs 🔗

@@ -2120,7 +2120,6 @@ async fn test_repository_above_root(executor: BackgroundExecutor, cx: &mut TestA
     });
     pretty_assertions::assert_eq!(repos, [Path::new(path!("/root")).into()]);
 
-    eprintln!(">>>>>>>>>> touch");
     fs.touch_path(path!("/root/subproject")).await;
     worktree
         .update(cx, |worktree, _| {
@@ -2141,6 +2140,117 @@ async fn test_repository_above_root(executor: BackgroundExecutor, cx: &mut TestA
     pretty_assertions::assert_eq!(repos, [Path::new(path!("/root")).into()]);
 }
 
+#[gpui::test]
+async fn test_global_gitignore(executor: BackgroundExecutor, cx: &mut TestAppContext) {
+    init_test(cx);
+
+    let home = paths::home_dir();
+    let fs = FakeFs::new(executor);
+    fs.insert_tree(
+        home,
+        json!({
+            ".config": {
+                "git": {
+                    "ignore": "foo\n/bar\nbaz\n"
+                }
+            },
+            "project": {
+                ".git": {},
+                ".gitignore": "!baz",
+                "foo": "",
+                "bar": "",
+                "sub": {
+                    "bar": "",
+                },
+                "subrepo": {
+                    ".git": {},
+                    "bar": ""
+                },
+                "baz": ""
+            }
+        }),
+    )
+    .await;
+    let worktree = Worktree::local(
+        home.join("project"),
+        true,
+        fs.clone(),
+        Arc::default(),
+        &mut cx.to_async(),
+    )
+    .await
+    .unwrap();
+    worktree
+        .update(cx, |worktree, _| {
+            worktree.as_local().unwrap().scan_complete()
+        })
+        .await;
+    cx.run_until_parked();
+
+    // .gitignore overrides excludesFile, and anchored paths in excludesFile are resolved
+    // relative to the nearest containing repository
+    worktree.update(cx, |worktree, _cx| {
+        check_worktree_entries(
+            worktree,
+            &[],
+            &["foo", "bar", "subrepo/bar"],
+            &["sub/bar", "baz"],
+            &[],
+        );
+    });
+
+    // Ignore statuses are updated when excludesFile changes
+    fs.write(
+        &home.join(".config").join("git").join("ignore"),
+        "/bar\nbaz\n".as_bytes(),
+    )
+    .await
+    .unwrap();
+    worktree
+        .update(cx, |worktree, _| {
+            worktree.as_local().unwrap().scan_complete()
+        })
+        .await;
+    cx.run_until_parked();
+
+    worktree.update(cx, |worktree, _cx| {
+        check_worktree_entries(
+            worktree,
+            &[],
+            &["bar", "subrepo/bar"],
+            &["foo", "sub/bar", "baz"],
+            &[],
+        );
+    });
+
+    // Statuses are updated when .git added/removed
+    fs.remove_dir(
+        &home.join("project").join("subrepo").join(".git"),
+        RemoveOptions {
+            recursive: true,
+            ..Default::default()
+        },
+    )
+    .await
+    .unwrap();
+    worktree
+        .update(cx, |worktree, _| {
+            worktree.as_local().unwrap().scan_complete()
+        })
+        .await;
+    cx.run_until_parked();
+
+    worktree.update(cx, |worktree, _cx| {
+        check_worktree_entries(
+            worktree,
+            &[],
+            &["bar"],
+            &["foo", "sub/bar", "baz", "subrepo/bar"],
+            &[],
+        );
+    });
+}
+
 #[track_caller]
 fn check_worktree_entries(
     tree: &Worktree,