git: Make worktrees work for bare git repositories (#21596)

Thorsten Ball and Peter Tripp created

Fixes #21210 by ensuring that Zed can open worktrees of bare git repositories.

Co-authored-by: Peter Tripp <peter@zed.dev>

Change summary

crates/worktree/src/worktree.rs       | 33 +++++++++++++++++++++++-----
crates/worktree/src/worktree_tests.rs | 22 ++++++++++++++----
2 files changed, 44 insertions(+), 11 deletions(-)

Detailed changes

crates/worktree/src/worktree.rs 🔗

@@ -3110,12 +3110,8 @@ impl BackgroundScannerState {
         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))?,
-        );
 
+        let actual_dot_git_dir_abs_path = smol::block_on(find_git_dir(&actual_repo_path, fs))?;
         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
@@ -3161,6 +3157,31 @@ impl BackgroundScannerState {
     }
 }
 
+async fn is_git_dir(path: &Path, fs: &dyn Fs) -> bool {
+    if path.file_name() == Some(&*DOT_GIT) {
+        return true;
+    }
+
+    // If we're in a bare repository, we are not inside a `.git` folder. In a
+    // bare repository, the root folder contains what would normally be in the
+    // `.git` folder.
+    let head_metadata = fs.metadata(&path.join("HEAD")).await;
+    if !matches!(head_metadata, Ok(Some(_))) {
+        return false;
+    }
+    let config_metadata = fs.metadata(&path.join("config")).await;
+    matches!(config_metadata, Ok(Some(_)))
+}
+
+async fn find_git_dir(path: &Path, fs: &dyn Fs) -> Option<Arc<Path>> {
+    for ancestor in path.ancestors() {
+        if is_git_dir(ancestor, fs).await {
+            return Some(Arc::from(ancestor));
+        }
+    }
+    None
+}
+
 async fn build_gitignore(abs_path: &Path, fs: &dyn Fs) -> Result<Gitignore> {
     let contents = fs.load(abs_path).await?;
     let parent = abs_path.parent().unwrap_or_else(|| Path::new("/"));
@@ -3967,7 +3988,7 @@ impl BackgroundScanner {
                         } else if fsmonitor_parse_state == Some(FsMonitorParseState::Cookies) && file_name == Some(*FSMONITOR_DAEMON) {
                             fsmonitor_parse_state = Some(FsMonitorParseState::FsMonitor);
                             false
-                        } else if fsmonitor_parse_state != Some(FsMonitorParseState::FsMonitor) && file_name == Some(*DOT_GIT) {
+                        } else if fsmonitor_parse_state != Some(FsMonitorParseState::FsMonitor) && smol::block_on(is_git_dir(ancestor, self.fs.as_ref())) {
                             true
                         } else {
                             fsmonitor_parse_state.take();

crates/worktree/src/worktree_tests.rs 🔗

@@ -12,7 +12,13 @@ use pretty_assertions::assert_eq;
 use rand::prelude::*;
 use serde_json::json;
 use settings::{Settings, SettingsStore};
-use std::{env, fmt::Write, mem, path::Path, sync::Arc};
+use std::{
+    env,
+    fmt::Write,
+    mem,
+    path::{Path, PathBuf},
+    sync::Arc,
+};
 use util::{test::temp_tree, ResultExt};
 
 #[gpui::test]
@@ -532,14 +538,20 @@ async fn test_open_gitignored_files(cx: &mut TestAppContext) {
         assert_eq!(fs.read_dir_call_count() - prev_read_dir_count, 1);
     });
 
+    let path = PathBuf::from("/root/one/node_modules/c/lib");
+
     // No work happens when files and directories change within an unloaded directory.
     let prev_fs_call_count = fs.read_dir_call_count() + fs.metadata_call_count();
-    fs.create_dir("/root/one/node_modules/c/lib".as_ref())
-        .await
-        .unwrap();
+    // When we open a directory, we check each ancestor whether it's a git
+    // repository. That means we have an fs.metadata call per ancestor that we
+    // need to subtract here.
+    let ancestors = path.ancestors().count();
+
+    fs.create_dir(path.as_ref()).await.unwrap();
     cx.executor().run_until_parked();
+
     assert_eq!(
-        fs.read_dir_call_count() + fs.metadata_call_count() - prev_fs_call_count,
+        fs.read_dir_call_count() + fs.metadata_call_count() - prev_fs_call_count - ancestors,
         0
     );
 }