worktree: Close single-file worktrees when file is deleted (#49366)

Daniel Strobusch created

When a single-file worktree's root file no longer exists, the background
scanner would previously enter an infinite retry loop attempting to
canonicalize the path. This caused continuous error logging and resource
waste.

This fix detects when a single-file worktree root cannot be
canonicalized (after attempting the file handle fallback) and emits a
new Deleted event, allowing the worktree to be properly closed.

This is most commonly encountered with temporary files, logs, and
similar files that are opened in Zed and then deleted externally, but
persist in the workspace database across sessions.

Closes #34864





## Test
**Logs** from manual testing:

```
2026-02-17T16:16:11+01:00 INFO  [worktree] inserting parent git repo for this worktree: "tmp.md"
2026-02-17T16:16:17+01:00 ERROR [worktree] root path could not be canonicalized: canonicalizing "/Users/***/tmp/tmp.md": No such file or directory (os error 2)
2026-02-17T16:16:17+01:00 INFO  [worktree] single-file worktree root "/Users/***/tmp/tmp.md" no longer exists, marking as deleted
2026-02-17T16:16:17+01:00 INFO  [worktree] worktree root /Users/***/tmp/tmp.md no longer exists, closing worktree
```
Release Notes:

- Fixed an issue where Zed would enter an infinite retry loop when the
backing file for a single-file worktree was deleted

Change summary

crates/edit_prediction/src/license_detection.rs |  4 
crates/project/src/lsp_store.rs                 |  3 
crates/project/src/manifest_tree.rs             |  1 
crates/project/src/worktree_store.rs            |  4 +
crates/worktree/src/worktree.rs                 | 27 +++++++
crates/worktree/tests/integration/main.rs       | 66 +++++++++++++++++++
6 files changed, 103 insertions(+), 2 deletions(-)

Detailed changes

crates/edit_prediction/src/license_detection.rs 🔗

@@ -308,7 +308,9 @@ impl LicenseDetectionWatcher {
                         }
                     }
                 }
-                worktree::Event::DeletedEntry(_) | worktree::Event::UpdatedGitRepositories(_) => {}
+                worktree::Event::DeletedEntry(_)
+                | worktree::Event::UpdatedGitRepositories(_)
+                | worktree::Event::Deleted => {}
             });
 
         let worktree_snapshot = worktree.read(cx).snapshot();

crates/project/src/lsp_store.rs 🔗

@@ -4413,7 +4413,8 @@ impl LspStore {
                         this.update_local_worktree_language_servers(&worktree, changes, cx);
                     }
                     worktree::Event::UpdatedGitRepositories(_)
-                    | worktree::Event::DeletedEntry(_) => {}
+                    | worktree::Event::DeletedEntry(_)
+                    | worktree::Event::Deleted => {}
                 })
                 .detach()
             }

crates/project/src/manifest_tree.rs 🔗

@@ -59,6 +59,7 @@ impl WorktreeRoots {
                         let path = TriePath::from(entry.path.as_ref());
                         this.roots.remove(&path);
                     }
+                    WorktreeEvent::Deleted => {}
                 }
             }),
         })

crates/project/src/worktree_store.rs 🔗

@@ -808,6 +808,10 @@ impl WorktreeStore {
                 worktree::Event::DeletedEntry(id) => {
                     cx.emit(WorktreeStoreEvent::WorktreeDeletedEntry(worktree_id, *id))
                 }
+                worktree::Event::Deleted => {
+                    // The worktree root itself has been deleted (for single-file worktrees)
+                    // The worktree will be removed via the observe_release callback
+                }
             }
         })
         .detach();

crates/worktree/src/worktree.rs 🔗

@@ -355,6 +355,7 @@ enum ScanState {
     RootUpdated {
         new_path: Arc<SanitizedPath>,
     },
+    RootDeleted,
 }
 
 struct UpdateObservationState {
@@ -368,6 +369,8 @@ pub enum Event {
     UpdatedEntries(UpdatedEntriesSet),
     UpdatedGitRepositories(UpdatedGitRepositoriesSet),
     DeletedEntry(ProjectEntryId),
+    /// The worktree root itself has been deleted (for single-file worktrees)
+    Deleted,
 }
 
 impl EventEmitter<Event> for Worktree {}
@@ -1106,6 +1109,7 @@ impl LocalWorktree {
                 };
                 let fs_case_sensitive = fs.is_case_sensitive().await;
 
+                let is_single_file = snapshot.snapshot.root_dir().is_none();
                 let mut scanner = BackgroundScanner {
                     fs,
                     fs_case_sensitive,
@@ -1128,6 +1132,7 @@ impl LocalWorktree {
                     share_private_files,
                     settings,
                     watcher,
+                    is_single_file,
                 };
 
                 scanner
@@ -1156,6 +1161,13 @@ impl LocalWorktree {
                         ScanState::RootUpdated { new_path } => {
                             this.update_abs_path_and_refresh(new_path, cx);
                         }
+                        ScanState::RootDeleted => {
+                            log::info!(
+                                "worktree root {} no longer exists, closing worktree",
+                                this.abs_path().display()
+                            );
+                            cx.emit(Event::Deleted);
+                        }
                     }
                 });
             }
@@ -3799,6 +3811,9 @@ struct BackgroundScanner {
     watcher: Arc<dyn Watcher>,
     settings: WorktreeSettings,
     share_private_files: bool,
+    /// Whether this is a single-file worktree (root is a file, not a directory).
+    /// Used to determine if we should give up after repeated canonicalization failures.
+    is_single_file: bool,
 }
 
 #[derive(Copy, Clone, PartialEq)]
@@ -4096,6 +4111,18 @@ impl BackgroundScanner {
                         .ok();
                 } else {
                     log::error!("root path could not be canonicalized: {err:#}");
+
+                    // For single-file worktrees, if we can't canonicalize and the file handle
+                    // fallback also failed, the file is gone - close the worktree
+                    if self.is_single_file {
+                        log::info!(
+                            "single-file worktree root {:?} no longer exists, marking as deleted",
+                            root_path.as_path()
+                        );
+                        self.status_updates_tx
+                            .unbounded_send(ScanState::RootDeleted)
+                            .ok();
+                    }
                 }
                 return;
             }

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

@@ -14,10 +14,12 @@ use worktree::{Entry, EntryKind, Event, PathChange, Worktree, WorktreeModelHandl
 use serde_json::json;
 use settings::{SettingsStore, WorktreeId};
 use std::{
+    cell::Cell,
     env,
     fmt::Write,
     mem,
     path::{Path, PathBuf},
+    rc::Rc,
     sync::Arc,
 };
 use util::{
@@ -3105,3 +3107,67 @@ async fn test_refresh_entries_for_paths_creates_ancestors(cx: &mut TestAppContex
         );
     });
 }
+
+#[gpui::test]
+async fn test_single_file_worktree_deleted(cx: &mut TestAppContext) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.background_executor.clone());
+
+    fs.insert_tree(
+        "/root",
+        json!({
+            "test.txt": "content",
+        }),
+    )
+    .await;
+
+    let tree = Worktree::local(
+        Path::new("/root/test.txt"),
+        true,
+        fs.clone(),
+        Default::default(),
+        true,
+        WorktreeId::from_proto(0),
+        &mut cx.to_async(),
+    )
+    .await
+    .unwrap();
+
+    cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+        .await;
+
+    tree.read_with(cx, |tree, _| {
+        assert!(tree.is_single_file(), "Should be a single-file worktree");
+        assert_eq!(tree.abs_path().as_ref(), Path::new("/root/test.txt"));
+    });
+
+    // Delete the file
+    fs.remove_file(Path::new("/root/test.txt"), Default::default())
+        .await
+        .unwrap();
+
+    // Subscribe to worktree events
+    let deleted_event_received = Rc::new(Cell::new(false));
+    let _subscription = cx.update({
+        let deleted_event_received = deleted_event_received.clone();
+        |cx| {
+            cx.subscribe(&tree, move |_, event, _| {
+                if matches!(event, Event::Deleted) {
+                    deleted_event_received.set(true);
+                }
+            })
+        }
+    });
+
+    // Trigger filesystem events - the scanner should detect the file is gone immediately
+    // and emit a Deleted event
+    cx.background_executor.run_until_parked();
+    cx.background_executor
+        .advance_clock(std::time::Duration::from_secs(1));
+    cx.background_executor.run_until_parked();
+
+    assert!(
+        deleted_event_received.get(),
+        "Should receive Deleted event when single-file worktree root is deleted"
+    );
+}