Gracefully handle when linked worktree .git path is outside worktree root (#53443)

Richard Feldman created

In `update_git_repositories`, a `.git` path outside the worktree root
can occur legitimately when `.git` is a gitfile (as in linked worktrees
and submodules) pointing to a directory in the parent repo. Previously
this triggered a `debug_panic!`, crashing debug builds.

Now we skip the path with a `debug_assert!` that it is indeed a file
(not a directory), so a genuine `.git` directory outside the worktree
root would still be caught in debug builds.

(No release notes because this is extremely hard to encounter until
https://github.com/zed-industries/zed/pull/53215 lands)

Release Notes:

- N/A

Change summary

crates/worktree/src/worktree.rs           | 15 +++-
crates/worktree/tests/integration/main.rs | 76 +++++++++++++++++++++++++
2 files changed, 87 insertions(+), 4 deletions(-)

Detailed changes

crates/worktree/src/worktree.rs 🔗

@@ -68,7 +68,7 @@ use std::{
 use sum_tree::{Bias, Dimensions, Edit, KeyedItem, SeekTarget, SumTree, Summary, TreeMap, TreeSet};
 use text::{LineEnding, Rope};
 use util::{
-    ResultExt, debug_panic, maybe,
+    ResultExt, maybe,
     paths::{PathMatcher, PathStyle, SanitizedPath, home_dir},
     rel_path::RelPath,
 };
@@ -5245,10 +5245,17 @@ impl BackgroundScanner {
             match existing_repository_entry {
                 None => {
                     let Ok(relative) = dot_git_dir.strip_prefix(state.snapshot.abs_path()) else {
-                        debug_panic!(
-                            "update_git_repositories called with .git directory outside the worktree root"
+                        // This can happen legitimately when `.git` is a
+                        // gitfile (e.g. in a linked worktree or submodule)
+                        // pointing to a directory outside the worktree root.
+                        // Skip it — the repository was already registered
+                        // during the initial scan via `discover_git_paths`.
+                        debug_assert!(
+                            self.fs.is_file(&dot_git_dir).await,
+                            "update_git_repositories: .git path outside worktree root \
+                             is not a gitfile: {dot_git_dir:?}",
                         );
-                        return Vec::new();
+                        continue;
                     };
                     affected_repo_roots.push(dot_git_dir.parent().unwrap().into());
                     state

crates/worktree/tests/integration/main.rs 🔗

@@ -2827,6 +2827,82 @@ async fn test_root_repo_common_dir(executor: BackgroundExecutor, cx: &mut TestAp
     );
 }
 
+#[gpui::test]
+async fn test_linked_worktree_git_file_event_does_not_panic(
+    executor: BackgroundExecutor,
+    cx: &mut TestAppContext,
+) {
+    // Regression test: in a linked worktree, `.git` is a file (containing
+    // "gitdir: ..."), not a directory. When the background scanner receives
+    // a filesystem event for a path inside the main repo's `.git` directory
+    // (which it watches via the commondir), the ancestor-walking code in
+    // `process_events` calls `is_git_dir` on each ancestor. If `is_git_dir`
+    // treats `.git` files the same as `.git` directories, it incorrectly
+    // identifies the gitfile as a git dir, adds it to `dot_git_abs_paths`,
+    // and `update_git_repositories` panics because the path is outside the
+    // worktree root.
+    init_test(cx);
+
+    use git::repository::Worktree as GitWorktree;
+
+    let fs = FakeFs::new(executor);
+
+    fs.insert_tree(
+        path!("/main_repo"),
+        json!({
+            ".git": {},
+            "file.txt": "content",
+        }),
+    )
+    .await;
+    fs.add_linked_worktree_for_repo(
+        Path::new(path!("/main_repo/.git")),
+        false,
+        GitWorktree {
+            path: PathBuf::from(path!("/linked_worktree")),
+            ref_name: Some("refs/heads/feature".into()),
+            sha: "abc123".into(),
+            is_main: false,
+        },
+    )
+    .await;
+    fs.write(
+        path!("/linked_worktree/file.txt").as_ref(),
+        "content".as_bytes(),
+    )
+    .await
+    .unwrap();
+
+    let tree = Worktree::local(
+        path!("/linked_worktree").as_ref(),
+        true,
+        fs.clone(),
+        Arc::default(),
+        true,
+        WorktreeId::from_proto(0),
+        &mut cx.to_async(),
+    )
+    .await
+    .unwrap();
+    tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
+        .await;
+    cx.run_until_parked();
+
+    // Trigger a filesystem event inside the main repo's .git directory
+    // (which the linked worktree scanner watches via the commondir). This
+    // uses the sentinel-file helper to ensure the event goes through the
+    // real watcher path, exactly as it would in production.
+    tree.flush_fs_events_in_root_git_repository(cx).await;
+
+    // The worktree should still be intact.
+    tree.read_with(cx, |tree, _| {
+        assert_eq!(
+            tree.snapshot().root_repo_common_dir().map(|p| p.as_ref()),
+            Some(Path::new(path!("/main_repo/.git"))),
+        );
+    });
+}
+
 fn init_test(cx: &mut gpui::TestAppContext) {
     zlog::init_test();