git: Don't consider $HOME as containing git repository unless it's opened directly (#25948)

Cole Miller created

When a worktree is created, we walk up the ancestors of the root path
trying to find a git repository. In particular, if your `$HOME` is a git
repository and you open some subdirectory of `$HOME` that's *not* a git
repository, we end up scanning `$HOME` and everything under it looking
for changed and untracked files, which is often pretty slow. Consistency
here is not very useful and leads to a bad experience.

This PR adds a special case to not consider `$HOME` as a containing git
repository, unless you ask for it by doing the equivalent of `zed ~`.

Release Notes:

- Changed the behavior of git features to not treat `$HOME` as a git
repository unless opened directly

Change summary

crates/fs/src/fs.rs                   | 15 ++++++
crates/worktree/src/worktree.rs       |  6 ++
crates/worktree/src/worktree_tests.rs | 67 +++++++++++++++++++++++++++++
3 files changed, 87 insertions(+), 1 deletion(-)

Detailed changes

crates/fs/src/fs.rs 🔗

@@ -135,6 +135,7 @@ 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 is_fake(&self) -> bool;
     async fn is_case_sensitive(&self) -> Result<bool>;
@@ -813,6 +814,10 @@ 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")))]
@@ -846,6 +851,7 @@ struct FakeFsState {
     metadata_call_count: usize,
     read_dir_call_count: usize,
     moves: std::collections::HashMap<u64, PathBuf>,
+    home_dir: Option<PathBuf>,
 }
 
 #[cfg(any(test, feature = "test-support"))]
@@ -1031,6 +1037,7 @@ impl FakeFs {
                 read_dir_call_count: 0,
                 metadata_call_count: 0,
                 moves: Default::default(),
+                home_dir: None,
             }),
         });
 
@@ -1524,6 +1531,10 @@ 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"))]
@@ -2079,6 +2090,10 @@ 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/worktree/src/worktree.rs 🔗

@@ -4292,7 +4292,11 @@ impl BackgroundScanner {
         let mut containing_git_repository = None;
         for (index, ancestor) in root_abs_path.as_path().ancestors().enumerate() {
             if index != 0 {
-                if let Ok(ignore) =
+                if Some(ancestor) == self.fs.home_dir().as_deref() {
+                    // Unless $HOME is itself the worktree root, don't consider it as a
+                    // containing git repository---expensive and likely unwanted.
+                    break;
+                } else if let Ok(ignore) =
                     build_gitignore(&ancestor.join(*GITIGNORE), self.fs.as_ref()).await
                 {
                     self.state

crates/worktree/src/worktree_tests.rs 🔗

@@ -2241,6 +2241,73 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_home_dir_as_git_repository(cx: &mut TestAppContext) {
+    init_test(cx);
+    cx.executor().allow_parking();
+    let fs = FakeFs::new(cx.background_executor.clone());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "home": {
+                ".git": {},
+                "project": {
+                    "a.txt": "A"
+                },
+            },
+        }),
+    )
+    .await;
+    fs.set_home_dir(Path::new(path!("/root/home")).to_owned());
+
+    let tree = Worktree::local(
+        Path::new(path!("/root/home/project")),
+        true,
+        fs.clone(),
+        Default::default(),
+        &mut cx.to_async(),
+    )
+    .await
+    .unwrap();
+
+    cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+        .await;
+    tree.flush_fs_events(cx).await;
+
+    tree.read_with(cx, |tree, _cx| {
+        let tree = tree.as_local().unwrap();
+
+        let repo = tree.repository_for_path(path!("a.txt").as_ref());
+        assert!(repo.is_none());
+    });
+
+    let home_tree = Worktree::local(
+        Path::new(path!("/root/home")),
+        true,
+        fs.clone(),
+        Default::default(),
+        &mut cx.to_async(),
+    )
+    .await
+    .unwrap();
+
+    cx.read(|cx| home_tree.read(cx).as_local().unwrap().scan_complete())
+        .await;
+    home_tree.flush_fs_events(cx).await;
+
+    home_tree.read_with(cx, |home_tree, _cx| {
+        let home_tree = home_tree.as_local().unwrap();
+
+        let repo = home_tree.repository_for_path(path!("project/a.txt").as_ref());
+        assert_eq!(
+            repo.map(|repo| &repo.work_directory),
+            Some(&WorkDirectory::InProject {
+                relative_path: Path::new("").into()
+            })
+        );
+    })
+}
+
 #[gpui::test]
 async fn test_git_repository_for_path(cx: &mut TestAppContext) {
     init_test(cx);